Implement chat sidebar with Favorites/Channels/DMs sections, search/filter, and expansion state
Some checks failed
CI / Lint, type-check, test (push) Failing after 4m25s
Deploy homelab (CT 116) / Deploy to CT 116 (push) Successful in 14m28s

This commit is contained in:
varutasu 2026-11-02 22:28:55 +00:00
parent a2b5cd2166
commit fcd6a59a2c
13 changed files with 1314 additions and 98 deletions

View file

@ -0,0 +1,223 @@
---
name: workspace-chat-polish
classification: feature
success_metric: Workspace Chat presents one coherent, accessible navigation and message experience with a contextual sidebar, recognizable authors, composer-based editing, modal deletion, and Favorites/Channels/DMs organization.
skip: []
status: in-progress
created: 2026-11-02
model_policy:
roles:
role-conductor: claude-planning
role-ia-architect: claude-planning
role-ui-designer: claude-planning
role-ux-reviewer: claude-planning
role-architect: claude-planning
role-implementer: qwen-coder
role-reviewer: qwen-coder
role-a11y-auditor: claude-planning
escalate_to: claude-planning
---
# Workspace Chat polish
## Why
The production Chat foundation works, but the current shell exposes too many competing side panels and several interactions do not match the rest of Echodo. This convoy polishes the human Chat experience before AI participation is added. It does not add agents, agent membership, agent execution, or AI delivery.
## Scope
- Chat routes replace the Spaces sidebar with one reusable Chat-owned sidebar between the global rail and the conversation.
- Non-Chat workspace views retain the existing Spaces/sidebar layout.
- The Chat sidebar has exactly three collapsible sections: Favorites, Channels, and DMs.
- Channels are not grouped by space by default; metadata supports optional filtering, viewing, and grouping.
- Hover actions also appear on keyboard focus.
- Messages show avatar/name identity instead of raw user IDs.
- Editing uses the normal channel composer with Update/Save and Cancel.
- Deletion uses an accessible confirmation dialog.
- Favorites use one user-scoped persistence API where feasible.
- DMs use existing channel membership and support one or many participants.
## Roles invoked
- role-conductor — scope and dependency framing
- role-ia-architect — information architecture
- role-ui-designer — visual and interaction direction
- role-ux-reviewer — heuristic and flow review
- role-architect — implementation and data architecture
- role-implementer — implementation after planning
- role-reviewer — standards/spec review
- role-a11y-auditor — WCAG verification
## Todos
- [ ] Implement the contextual Chat shell without changing non-Chat navigation.
- [ ] Build Favorites, Channels, and DMs sections with shared row/header primitives.
- [ ] Resolve channel authors to safe profile identity.
- [ ] Move edit mode into the primary composer and delete confirmation into a dialog.
- [ ] Decide and implement the least disruptive favorites/channel-target persistence extension.
- [ ] Add reusable channel metadata and optional nullable space linkage only if justified by existing data.
- [ ] Verify one-to-many DM creation and participant projection.
- [ ] Run UX, accessibility, regression, and responsive verification.
## IA — role-ia-architect
### Primary hierarchy
```text
Global app shell
└── Global rail
└── Chat contextual surface (Chat routes only)
├── Chat header and tools
│ ├── Search
│ ├── Filter
│ └── Collapse
├── Favorites
├── Channels
└── DMs
└── Conversation workspace
├── Channel header
├── Message history
├── Thread/reply context when active
└── Composer
```
The Chat surface is a contextual replacement for the Spaces sidebar, not a third persistent panel. It must be a reusable component that can later be mounted by other workspace views without requiring those views to adopt the Chat route.
### Navigation model
- The global rail remains the stable top-level navigation.
- Chat is the selected rail destination for `/[workspaceSlug]/chat` and `/[workspaceSlug]/chat/[channelId]`.
- The Chat sidebar owns channel discovery and direct-message discovery.
- The conversation owns message history and composition; it must not render a second channel-navigation sidebar.
- Favorites are shortcuts, not a separate object hierarchy.
- Channels are a flat default list. Space metadata may be shown as secondary context or used by an explicit user-selected filter/grouping mode later.
- DMs are a flat participant-oriented list. A DM row should identify the other participant(s), with a group-DM indicator when there is more than one.
### Section structure
Each section has a shared header with:
- A left-aligned disclosure control and section label.
- A right-aligned Add action when creation is supported.
- A right-aligned More menu for section-level actions.
- Actions hidden at rest but exposed on hover and `:focus-within`.
- `aria-expanded` on the disclosure control and a labelled region for the section body.
There are deliberately no Defaults or New Message sections. New DMs are created through Add in the DMs header; new channels are created through Add in Channels.
### Cross-surface reuse
Extract the Chat sidebar and its data contract from `ChatWorkspace`. Future surfaces should be able to render it with a workspace handle, optional selected channel, and navigation callbacks without importing the full conversation implementation.
## UI direction — role-ui-designer
### Layout
- Preserve the global rail width and behavior.
- Use a single secondary Chat surface around 260–300px wide with a clear border separating it from the conversation.
- Keep the conversation flexible and independently scrollable; the sidebar must not create a second page-level scroll container.
- On narrow viewports, the Chat surface becomes an accessible sheet/drawer rather than forcing three columns.
### Sidebar visual language
- Header: `Chat` title, compact Search and Collapse controls.
- Section headers: compact, left-disclosure/right-actions layout with a minimum 40px target height.
- Rows: avatar/icon, primary label, optional preview or count, selected state, and unread treatment.
- Use the existing sidebar colors, button primitives, tooltip behavior, and dropdown menus rather than introducing a parallel visual system.
- Keep hover-only controls visually quiet, but use `focus-within` and `focus-visible` to expose the same controls to keyboard and assistive-technology users.
- Channel rows may show a small secondary metadata label only when available; do not make space grouping visually dominant.
### Message identity
Use the existing avatar primitive. Prefer display name, then email local-part, then a short fallback such as `Unknown member`; never use the full UUID as the visible identity. Preserve a tooltip or accessible description for any abbreviated identity.
### Edit flow
Selecting Edit should:
1. Focus the primary composer.
2. Load the original body into the composer.
3. Show a clear `Update` or `Save changes` action.
4. Show `Cancel`.
5. Keep the original message visible in history with an editing indicator.
The edit state belongs to the conversation scope, not to an inline message row.
### Delete flow
Use the existing dialog primitive if available, or add the smallest shared dialog primitive required by the codebase. The dialog must state that deletion is destructive, provide Cancel and Delete actions, prevent accidental submission, and restore focus to the originating message action.
## UX review — role-ux-reviewer
### Strengths
- The contextual shell matches the user's mental model: global destinations stay in the rail, Chat-specific discovery stays beside the conversation.
- Three sections are easier to scan than the former stacked Spaces plus Chat navigation.
- Composer-based editing preserves spatial consistency and reduces duplicate interaction patterns.
- A dialog makes destructive intent explicit and provides a reliable cancel path.
### Risks and recommendations
1. **Recognition over recall — major:** A flat Channels list can become long. Add search/filter affordances now, but keep grouping opt-in so the default remains simple.
2. **Visibility of system status — major:** Saving edits, failed sends, reconnecting, and delete completion must have visible status and non-visual announcements.
3. **User control and freedom — major:** Edit requires an obvious Cancel path; delete requires Cancel as the safe/default action and focus restoration.
4. **Consistency and standards — moderate:** Use the same section header, disclosure, More, Add, avatar, tooltip, dropdown, and dialog primitives used by Spaces/Home.
5. **Flexibility and efficiency — moderate:** Keyboard focus must reveal the same actions as hover. Do not make users rely on pointer hover to discover Add or More.
6. **Error prevention — major:** Disable Update/Delete while mutations are pending, preserve the expected revision, and show a recoverable conflict message rather than losing edited text.
7. **Aesthetic/minimalist design — moderate:** Do not add Defaults, New Message, or duplicate channel lists. Keep summaries short and avoid showing full IDs.
8. **Help users recover from errors — moderate:** A failed profile lookup needs a stable initials fallback; a failed favorite or DM creation needs an inline alert/toast with retry where safe.
### Flow acceptance
- Chat route: rail → Chat sidebar → conversation.
- Non-Chat route: rail → existing Spaces sidebar → view.
- Edit: message action → composer populated → Update/Cancel → status announcement → history refresh.
- Delete: message action → confirmation dialog → Cancel/Delete → focus restoration → tombstone/recovery state.
- Add DM: DMs Add → participant picker supporting one or many members → create/open direct channel.
## Architecture — role-architect
### Component boundaries
- `AppShell` determines whether the current pathname is a Chat route and selects either the existing `Sidebar` or a reusable `ChatSidebar`.
- `ChatSidebar` owns section state, search/filter state, channel/DM navigation, and sidebar mutations. It must not own message history.
- `ChatSectionHeader` owns disclosure, hover/focus action visibility, Add, and More semantics.
- `ChatListRow` is shared by Favorites, Channels, and DMs with variants for metadata, unread state, and participant avatars.
- `ChatWorkspace` owns channel history, thread state, composer state, and message mutations. Remove its channel-navigation aside once `ChatSidebar` is mounted by the shell.
- `ChatComposer` accepts an explicit mode (`send` or `edit`) and controlled initial/current body with submit and cancel callbacks.
- Delete uses a shared accessible dialog primitive, not a message-specific inline panel.
### Route detection
Use a segment-aware helper based on `usePathname`, not a broad substring match. It should recognize only `/{workspaceSlug}/chat` and descendants, avoiding false positives from object titles or unrelated URLs. Keep the Chat sidebar reusable through props rather than coupling it to route detection internally.
### Existing data and APIs
- `chatChannelMembers` already supports channel membership and the existing `createDirect` input accepts `participantUserIds`; reuse this for one-to-many DMs.
- `users` already contains `name` and `avatarUrl`; add a workspace-authorized participant/profile projection to visible channel results rather than exposing raw IDs to the UI.
- `userFavorites` currently has an object-only foreign key. The preferred evolution is a single favorites router contract with a discriminated target (`object` or `chatChannel`) and storage that preserves object referential integrity while adding a nullable channel target plus a mutual-exclusion check and appropriate unique indexes. Do not weaken cross-workspace authorization.
- `chatChannels` currently has no space metadata. Add only the minimum reusable fields justified by product use: nullable `spaceId` referencing an object, plus bounded metadata if filtering needs it. Enforce that a referenced object is an eligible workspace space in service authorization; a database FK alone cannot enforce object type.
- Channel listing should return channel identity, kind, name, unread summary, favorite state, optional space metadata, and authorized participant summaries in one workspace-scoped response where practical.
### Security and tenancy
Every channel, favorite, space link, and participant query must resolve through the workspace context and verify membership. DMs must never reveal participant names or previews for channels the caller cannot see. Favorite toggles must verify the target belongs to the active workspace and the caller has access to the channel.
### Implementation order
1. Add route-aware shell seam and extract ChatSidebar contracts without changing data behavior.
2. Add profile projection and controlled composer edit state; replace inline delete with dialog.
3. Add/reuse favorites target contract, channel metadata, and participant-aware sidebar query only where required by the UI.
4. Implement sidebar sections, interactions, filters, favorites, DM creation UI, and responsive presentation.
5. Run UX review, accessibility audit, component tests, type-check, and production build.
### Testing requirements
- Route-aware shell renders ChatSidebar only for Chat routes and preserves Sidebar elsewhere.
- Section disclosure and hover/focus actions expose correct accessible names and `aria-expanded` state.
- Profile fallback never renders a raw UUID as the primary author label.
- Edit populates the main composer, submits the edit mutation, supports Cancel, and preserves conflict recovery.
- Delete dialog opens, traps/restores focus, disables duplicate submission, and leaves the message unchanged on Cancel.
- Favorite authorization rejects cross-workspace targets.
- Channel metadata and optional space links remain tenant-scoped.
- DM creation validates one or more additional participants and renders participant identity safely.

View file

@ -0,0 +1,190 @@
# Workspace Chat polish — independent IA planning
Date: 2026-11-02
Role: independent IA planning; planning only
## Basis and boundaries
This review is derived from the approved scope and the code references below. The earlier role sections in `.convoys/workspace-chat-polish.md` are main-session draft proposals, **not independent review evidence or approved implementation contracts**. Reading them does not constitute endorsement.
Read: `AGENTS.md`, `docs/Glossary.md`, `.convoys/README.md`, the convoy manifest, and the routes, shell, sidebar, conversation, and supporting data contracts cited below. Applied the accessibility skill as planning guidance only. No agents, lifecycle operations, MCP mutations, implementation, or code checks were run. This document is not a runtime accessibility or security certification.
Approved boundary: human Chat; replace the Spaces sidebar only on Chat routes; retain a reusable Chat navigation surface for other hosts. Exactly **Favorites, Channels, DMs**. Flat Channels by default, with no space hierarchy, Defaults section, or New Message section. DMs Add selects one or many **other** people. Names/avatars, focus-visible actions, primary-composer editing, and modal deletion are in scope. AI/agent participation is not.
## 1. Current IA: evidence and consequences
Line references describe the inspected source, not future edits. Paths are repository-relative.
| Evidence | IA consequence |
| --- | --- |
| `apps/web/app/(app)/[workspaceSlug]/chat/page.tsx:1–10` and `chat/[channelId]/page.tsx:1–10` mount the same `ChatWorkspace`, with an optional channel ID. | Keep the existing index/detail route hierarchy. DMs already fit the channel detail route; no new DM route is needed. |
| `apps/web/components/layout/app-shell.tsx:43–55` always renders IconRail, Sidebar, main content, and RightPanel. `apps/web/components/chat/chat-workspace.tsx:733–742` adds another navigation aside inside main. | The duplicate sidebar is structural. The solution is one contextual navigation slot, not cosmetically shrinking one of two sidebars. Avoid nested main landmarks when extracting Chat. |
| `apps/web/components/layout/icon-rail.tsx:41–53` distinguishes AI and Chat. `apps/web/components/panels/right-panel.tsx:20–24` can display AI chat or object detail. | Human Chat must remain distinct from AI. A previously opened global panel can compete with the thread panel; host behavior needs an explicit decision. |
| `apps/web/components/sidebar/sidebar.tsx:19–32` wraps SidebarHeader/NavTree; `sidebar-header.tsx:63–96` labels the surface Home, routes Search through the global callback, and has a Filter control with no handler. | Reuse visual primitives, not these meanings. Chat search must not silently invoke object/global search; do not copy a nonfunctional filter affordance. |
| `apps/web/components/chat/chat-workspace.tsx:752–799` displays all nonarchived kinds together, labels every direct conversation “Direct message,” and links by channel ID. | Split Channels from DMs; participant identity is necessary to distinguish DM destinations. |
| `packages/database/src/chat/channel-drizzle-adapter.ts:17–25,72–95` projects ID/kind/name/owner/directKey/archive state, returns public channels or channels with caller membership, ordered by creation. | Visible is not synonymous with “joined.” There is no participant/name/avatar projection here, no last-message ordering field, and no space metadata. Do not promise recent-activity sorting or private-channel discovery. |
| `packages/database/src/schema/chat.ts:22–49,79–111` stores channels separately from objects and has workspace-scoped membership; direct names must be null at `:68–70`. | DMs are participant-derived labels, not renamed channels. Space grouping/linkage is not an existing channel capability. |
| `apps/web/server/routers/chat.ts:283–365` exposes list/create/rename/archive/private-membership/direct creation. `packages/shared/src/chat/index.ts:195–201` accepts 2–100 total DM participants. `packages/database/src/chat/manage-channels.ts:414–448` requires the caller, validates membership, canonicalizes the participant set, and returns an existing matching direct channel. | The people picker must hide self but add self to the submitted set. Existing group DMs can be reused without inventing a new conversation API. |
| `apps/web/server/routers/workspaces.ts:176–189` lists workspace member IDs, names, email, avatar URL, and role. | A candidate source exists for the DM picker. It does not supply per-conversation membership or resolve historical authors who are no longer members. Email must not become the default chat identity. |
| `apps/web/server/routers/favorites.ts:50–70,90–99` lists object metadata and takes `objectId`; `:16–47` checks object/workspace visibility. | The current favorites API is not channel-capable. Reusing its name does not supply channel authorization or channel targets. |
| `apps/web/components/chat/chat-message-list.tsx:8–20,92–108` has author ID but no profile projection and renders the raw ID. `:110–142` gates edit/delete controls. | Add safe identity projection without losing authorization behavior; do not substitute IDs as user-visible names. |
| `apps/web/components/chat/chat-workspace.tsx:891–951,986–990` renders separate edit/delete controls and the normal composer; `:994–1072` has a separate thread region/composer. `chat-composer.tsx:8–25` only accepts send/disabled/label and owns local text. | Composer edit mode requires a controlled state contract, including draft preservation and reply targets. This is not just moving the existing textarea. |
| `apps/web/components/chat/chat-workspace.tsx:70–100,111–121` revokes scope, purges cached messages and drafts, and redirects to Chat index on lost visibility. `:824–827,870–873` supplies unavailable/empty copy. | Preserve revocation safeguards through extraction. Distinguish unavailable, loading, failure, and truly empty states. |
### Independent assessment
The approved three-section model fits the existing kinds and routes without adding another entity hierarchy. The main IA dependencies are **recognizable conversation identity**, **channel-aware favorite authorization**, and **clear scope of discovery**. Styling alone cannot address them. The flat list is viable if search is explicitly conversation discovery, archived destinations remain recoverable, and a shortcut never becomes a second conversation record.
## 2. Proposed route and content hierarchy
```text
Workspace shell
├── Existing global header and rail
├── Contextual navigation slot
│ ├── Non-Chat routes: existing Spaces/object sidebar, unchanged
│ └── Chat routes: reusable Chat navigation surface
│ ├── Chat heading; Find conversations; Filter; Collapse
│ ├── Favorites [disclosure; existing-chat shortcut picker]
│ ├── Channels [disclosure; Create channel]
│ └── DMs [disclosure; Start conversation]
└── Main content
├── /[workspaceSlug]/chat
│ └── Select a conversation / useful empty guidance
└── /[workspaceSlug]/chat/[channelId]
├── Conversation heading: identity, kind/privacy, archive state
├── Message history: author, time, body, message actions
├── Primary composer: compose OR edit mode
└── Optional thread region: root context, replies, reply composer
```
- Treat route segments as an exact Chat boundary, including its detail descendants. Do not use loose substring checks or replace navigation globally.
- The selected conversation comes from the canonical route, not from a favorite row's identity. Back/forward and copied links keep working.
- The index does not create a default channel, automatically start a DM, or redirect to an arbitrary conversation. “Select a conversation” is better than the current channel-only wording.
- Search/filter/disclosure state changes navigation visibility, not the open conversation or read state. A hidden selected row does not mean access has been lost.
- Extract the navigation presentation and its workspace-scoped data ownership away from message history, subscriptions, and composers. A future host can provide workspace, selected conversation, and an open-conversation callback; these are **proposed UI responsibilities, not existing API signatures**. Route host uses canonical links; embedded hosts may handle selection locally.
- Non-Chat routes do not automatically gain Chat navigation. Reusability is a mounting capability, not a new global layout requirement.
- Narrow screens should switch between navigation and conversation, with an explicit back-to-conversations control; do not show several fixed-width sidebars simultaneously. Keep all three sections in the navigation view.
## 3. Section membership and action meanings
| Surface | Meaning and default contents | Add/action meaning |
| --- | --- | --- |
| Favorites | Current user's shortcuts to accessible Chat conversations in this workspace, both channels and DMs. No object/task/document favorites mixed into this surface. | **Add favorite** opens a chooser of existing accessible conversations. It creates neither a channel nor a DM. Unfavorite removes only the shortcut. |
| Channels | All accessible public/private nondirect channels, active by default, in one flat list. Public/private is secondary identity, not two extra sections. | **Create channel** requests name and public/private kind using the existing capability. Private initially means caller-only membership; do not imply recipients are invited automatically. |
| DMs | Existing accessible direct conversations, active by default, identified by the other participant(s). | **Start conversation** opens a human-member multiselect. One other person yields a 1:1 DM; more than one yields a group DM. The action does not send a message. |
All section disclosures are independent of their sibling action buttons. Use section-qualified accessible labels, not three indistinguishable “Add” labels. A More menu is optional: include it only for real, supported section-specific actions; an empty menu or placeholder “Manage” action adds ambiguity. Do not put create-channel actions under Favorites or add-people-to-existing-group under DMs Add.
### DM creation and identity
1. Open the member picker from DMs Add. Exclude the current user; show names and avatars, searchable within this workspace. Keep selected people visible as removable selections.
2. Require at least one other person; cap selection at 99 others under the existing 100-total schema. Deduplicate selected IDs and include the caller exactly once in the request.
3. Label submission **Start conversation**, show pending/error state, then open the returned channel ID. Reopening the same participant set is success, not a duplicate-error condition.
4. Membership of an existing direct conversation is immutable in the inspected adapter (`channel-drizzle-adapter.ts:31–35`). Starting with a different set is a different conversation, not editing membership of the original.
5. 1:1 title: the other person's display name/avatar. Group title: stable ordered other-participant names, truncated visually with a count and an accessible complete name; show the full participant list on demand. Do not derive participants from a directKey hash or owner ID.
6. Where names collide, use approved additional profile context or participant detail—not raw UUIDs. Where identity is unavailable, use a neutral avatar and “Unavailable member”; clarify the exact departed/deleted distinction only when the server can prove it. Never render “undefined,” an email address by default, or an AI/system identity invented for a null human author.
### Duplicate favorites decision
**Keep a favorite in its canonical Channels/DMs section as well as Favorites.** Favorites is a shortcut collection, not a move operation. This preserves complete discoverability when Favorites is collapsed and avoids items seemingly vanishing from their type section.
- One favorite record per user/conversation target. Both appearances share label, unread state, archive state, and navigation target; favorite toggling updates both.
- Both appearances may indicate the same current destination. Do not create competing selection state or double-count unread totals.
- Removing a favorite does not close the conversation, delete messages, leave membership, or remove its canonical row. Restore focus to a surviving nearby control if the shortcut row disappears.
- Show a concise empty hint (“Add a channel or DM to Favorites”). Avoid instructions that imply favoriting changes access.
- Prefer alphabetical display-name ordering with a stable internal ID tie-breaker for this slice. Apply it consistently within each section. Activity sorting and drag ordering are deferred because the inspected list contract does not support them. No UUIDs are displayed as tie-breakers.
## 4. Search, filters, and grouping
**Search label: “Find conversations.”** This is local navigation discovery, not message search, global object search, or a people directory.
- Scope: accessible conversations in the current workspace. Match channel names and resolved DM participant names. Apply the same query to all three sections, preserving section headings and the intentional favorite duplication.
- Search must not reveal inaccessible private-channel names, cross-workspace favorites, historical message contents, or hidden participant metadata. Search by participant name depends on a safe participant projection; do not ship a field that appears to support it while only matching “Direct message.”
- The DM creation picker has its own **Find people** search over eligible workspace members. A person match in that picker is not an existing DM result.
- Initial filters: archive status (**Active**, default; **Archived**; **All**) and channel visibility (**All**, default; **Public**; **Private**, explicitly labeled “Channels only”). The visibility filter applies to nondirect rows in both Channels and Favorites; DMs remain unaffected. Display applied criteria and a Clear filters action.
- Search and filters intersect. Search does not silently include archives; an empty filtered result offers Clear filters / Show archived. Mark filtered-empty separately from no conversations.
- Keep all three section headers present, including during a no-results state. Provide one concise result summary counted by unique conversation ID rather than duplicate favorite appearances; section counts, if shown, count visible rows within that section.
- Do not auto-close the selected conversation or rewrite its URL when filters hide its row. Clearing filters restores the row.
- No default space grouping. The inspected channel schema has no space link, so **space filter/grouping is deferred**, not simulated from names or parent object IDs. If the architect later justifies optional space metadata, retain the flat default and an explicit unassigned state; do not change channel permissions through a display grouping.
- Preserve search/filter state while moving between conversations in the same workspace; reset when the workspace/user changes. Persistence across sessions is optional and must be separate from the Spaces sidebar preferences.
## 5. Conversation actions and content ownership
### Primary-composer editing
- Edit acts on an existing message owned by the current user, subject to server authorization; not on the channel name. Preserve the current owner/moderator distinctions rather than widening them (`chat-workspace.tsx:703–719`).
- Move the selected text into the **primary conversation composer**, visibly labeled “Editing message” with author/time and, for a reply, its thread context. Use **Save changes** and **Cancel** instead of Send. Do not render a second inline edit textarea.
- Decision for reply edits: use that same primary edit surface with the reply's target/root retained. Suspend new reply submission while editing so two simultaneous composer intentions cannot be confused; thread history may remain visible. This needs explicit state handling because the current thread composer is independent.
- Preserve the interrupted unsent primary/reply drafts. Save success or Cancel restores them. Switching to another edit target or navigating away with changed edit text must not silently discard it; provide a discard/cancel decision. Access revocation is different: purge sensitive drafts rather than retaining them for later display.
- A failed save keeps the edited text and offers recovery. Preserve revision/conflict semantics; never retry as an unconditional overwrite. Status text must distinguish saving, saved, failed, and conflict. Do not promise that current composer behavior already supports this.
### Message deletion
- Delete opens a labeled modal confirmation, with a short message excerpt/context, **Cancel** and **Delete message**. No inline confirmation block, browser confirm, or channel-delete meaning.
- Explain that the message becomes a deleted placeholder, consistent with `chat-message-list.tsx:105–108`. Do not promise permanent erasure, undo, or deletion of replies without a verified data contract.
- Initial focus goes to the safe action; focus is contained, Escape cancels when safe, and completion returns focus to the originating message or nearest surviving context. Keep a recoverable error in the dialog if deletion fails; disable duplicate submission while pending.
- Message action access is the same on hover and keyboard focus, also usable on touch. Identity display and action labels should use meaningful context rather than UUIDs (`chat-message-list.tsx:118` currently includes an ID).
## 6. Empty, archived, unavailable, and failure states
| State | Required presentation and behavior |
| --- | --- |
| Loading navigation/identity | Announce loading; reserve useful space. Do not flash “No conversations” or raw IDs while profile data resolves. |
| Chat index, conversations exist | “Select a conversation” with navigation available. Creation remains in the relevant section header, not a fourth section. |
| No Favorites | Explain shortcuts; Add favorite chooses existing conversations. If none exist, direct people to Channels Add or DMs Add without manufacturing defaults. |
| No Channels | “No channels available.” Offer Create channel when permitted; do not imply no DMs exist. |
| No DMs | “Start a conversation with someone in this workspace.” DMs Add remains the entry point. |
| No other eligible people | Picker says no other workspace members are available. Disable Start conversation; no self-DM and no invented invite API. |
| Search/filter has no matches | “No matching conversations” plus Clear search/filters. Distinct from an empty workspace; preserve the three sections. |
| Empty accessible conversation | “No messages yet” with the composer if active. A history failure must not appear as empty history. |
| Archived conversation | Excluded from Active navigation, including active Favorites, but discoverable with Archived/All and by an authorized deep link. Explicit Archived badge/banner; readable history, no send/edit/delete composer actions. Keep the underlying favorite shortcut so it reappears under the appropriate filter. No Restore control without an implemented capability. |
| Matching direct conversation is already archived | Existing creation logic can return it (`manage-channels.ts:435–448` has no archive exclusion). Open it read-only with clear explanation; do not falsely claim a writable new conversation or silently unarchive it. Product/architecture must confirm whether a later restore capability is needed. |
| Missing/deleted/inaccessible destination | Generic “Conversation unavailable” without revealing which private resource exists. Purge protected history, participants, drafts, and favorite labels from visible client state. Offer return to Chat; if using the current automatic redirect, carry a safe explanation so it does not look like a random jump. |
| Stale favorite loses access | Remove the inaccessible target from visible shortcuts and search immediately after denial/refetch. Do not expose old name/avatar as a disabled row. Whether to delete or retain the inaccessible persistence record is a server decision, not permission to display it. |
| Network/list/favorite failure | Keep failure separate from empty; Retry in place. Do not remove favorites permanently because a temporary fetch failed. A favorite save failure restores its previous state and announces the failure. |
| User/workspace switch | Reset scoped navigation/selection/draft state and cancel stale results. No names, favorites, or message text from the previous scope may flash in the new one. |
Archive is not access loss. Filtering is neither archive nor access loss. These distinctions must survive the extraction of `ChatWorkspace`.
## 7. Accessibility and interaction handoff
Planning requirements, not verified runtime findings:
- One main landmark; named Chat navigation; section headings/disclosure buttons with `aria-expanded` and associated content. Do not nest row action buttons inside navigation links.
- Hover actions appear on `focus-within`/focus-visible and remain keyboard-operable; touch users have a visible action path. WCAG 2.1.1, 2.4.7, 2.4.11.
- Active destination, private/archive state, and unread information have text/semantics, not only color. WCAG 1.3.1, 1.4.1, 4.1.2. Duplicate shortcuts must not double-announcement-count overall unread totals.
- Avatar images are decorative when adjacent text already names the person; use meaningful initials/fallback without replacing the visible name. WCAG 1.1.1.
- Search results, loading, save/delete completion, and mutation failures have appropriately restrained announcements. WCAG 4.1.3. Do not announce every repeated shortcut as a new event.
- Disclosure collapse, removal of a favorite, route selection, composer transitions, and dialog close all retain a sensible focus destination. Dialog interaction must not trap focus permanently. WCAG 2.1.2, 2.4.3.
- At narrow widths/zoom, navigation and thread are alternate reachable views rather than four cramped columns. Verify reflow and unobscured focused controls later (WCAG 1.4.10, 2.4.11).
## 8. Handoff decisions and unresolved risks
### Decisions to carry into briefs
1. Keep the two existing routes and one contextual navigation slot. Do not modify non-Chat navigation behavior.
2. Favorites are Chat-only shortcuts scoped by user and workspace; duplicates remain in their canonical section. Use a single favorite state per target.
3. Channels is flat public/private discovery; DMs is participant-oriented. No Defaults, New Message, AI, agent, or space hierarchy.
4. DMs Add selects 1–99 other human members; include self once in the existing request and open the returned existing/new destination.
5. Find conversations filters navigation, not message history. Archive is explicit; space/grouping and recent-activity sorting are deferred pending data support.
6. All editing uses the primary composer with explicit target context and restored drafts; deletion uses a modal with safe focus behavior.
7. Generic unavailable copy and cache purge take priority over retaining stale identities. Archived-but-authorized content remains discoverable/read-only.
### Architecture/product questions that must be resolved, not invented
| Risk / gap | Required handoff |
| --- | --- |
| Favorites targets are object-only; current listing is user-scoped and its query does not recheck target visibility (`favorites.ts:50–65`). | Architect must choose a channel-capable persistence extension and apply per-channel visibility on reads/writes. Do not send channel IDs as object IDs or rely only on workspace membership for private/direct targets. Preserve existing non-Chat favorite behavior. |
| Channel listing lacks participant and author identity. Workspace members are not equivalent to conversation participants or historical authors. | Define an authorized minimal profile/participant projection across initial history, pagination, threads, and live/recovered messages. Names/avatar only as appropriate; no global directory or raw-ID fallback. Resolve missing/departed member semantics. |
| Optional metadata and sorting were mentioned in drafts, but the inspected contract lacks space linkage and activity time. | Do not add space/schema dependencies simply to match draft language. Reassess only if a concrete supported use case requires them. |
| Existing direct reuse can return an archive; there is no restore procedure in the inspected Chat router. | Confirm read-only reopening is acceptable for this slice, or explicitly scope a restoration feature before offering one. Never create a hidden duplicate for the same set. |
| Global RightPanel can remain open and show AI content. | Recommended Chat route host policy: suppress unrelated global panel rendering while on Chat, preserving its non-Chat state, and use the Chat thread region only when requested. Confirm with shell owner; this is a Chat-only host change, not removal of global AI navigation. |
| Extracting navigation can separate queries from the existing revocation/read/recovery lifecycle. | Preserve the authorization/cache-purge seams and channel-ID unread aggregation. Do not mark messages read merely because a row was searched, favorited, or displayed. |
| Primary composer has local state and two thread/root compose contexts. | Architect must define draft ownership, edit target/revision, cancellation, conflict, and navigation handling before implementation. Do not conflate editing a reply with sending a root message. |
| Duplicate display names, long group names, many members, and avatar failure have no complete behavior demonstrated here. | Validate identity presentation with representative fixtures and keyboard/screen-reader walkthroughs during implementation. Distinguish unknown from departed only with evidence. |
### Later verification scenarios (not executed)
Check both Chat routes versus a non-Chat route; the same conversation via Favorites and its canonical section; keyboard/touch disclosure and Add actions; one/many/self-only DM selections; reopening the same group; archived DM reuse; no eligible people; search with hidden selected/archived rows; access loss while an edit or delete dialog is open; draft restoration after cancel/failure; reply edit target retention; user/workspace switching; names missing/colliding; and narrow-screen navigation/thread return focus.
This is an independent planning recommendation and a list of implementation dependencies, not a claim that the earlier draft IA/UX/architecture has passed review.

View file

@ -0,0 +1,81 @@
# planning-ui.md
## UI Planning for workspace-chat-polish
This document outlines the visual hierarchy, component boundaries, interaction affordances, keyboard parity, responsive behavior, and handoff decisions for the Chat sidebar polish, grounded in the existing specification.
### 1. Overview
- The Chat sidebar is a **reusable, contextual component** mounted only on Chat routes, replacing the Spaces sidebar in that context.
- It contains exactly three collapsible sections: **Favorites**, **Channels**, and **DMs**.
- The sidebar must not own message history; conversation ownership remains with `ChatWorkspace`.
### 2. Visual Hierarchy
| Level | Component | Responsibility | Evidence |
|-------|-----------|----------------|----------|
| Global Shell | `AppShell` | Determines if current route is a Chat route and selects `Sidebar` vs `ChatSidebar`. | Spec §182 |
| Chat Context | `ChatSidebar` | Owns section state, search/filter, navigation, and mutations. | Spec §183 |
| Section Header | `ChatSectionHeader` | Disclosure control, hover/focus actions, Add, More menu. | Spec §100‑104 |
| List Rows | `ChatListRow` (shared) | Avatar, label, preview/count, selected state, unread treatment. | Spec §125‑126 |
| Conversation | `ChatWorkspace` | Owns message history, thread state, composer, message mutations. | Spec §186 |
| Composer | `ChatComposer` | Accepts explicit `send`/`edit` mode, controlled body, submit/cancel callbacks. | Spec §146‑147 |
| Delete | Shared accessible dialog | Confirms destructive action, traps/restores focus, disables duplicate submission. | Spec §148‑149 |
### 3. Component Boundaries
- **`ChatSidebar`**: Reusable, route‑aware, **must not** own message history.
- **`ChatSectionHeader`**: Handles disclosure (`aria-expanded`), hover/focus visibility, Add and More actions.
- **`ChatListRow`**: Shared row primitive for Favorites, Channels, DMs with variants for metadata, unread state, and participant avatars.
- **`ChatWorkspace`**: After `ChatSidebar` mounts, removes its own channel‑navigation aside to avoid duplicate navigation.
- **`ChatComposer`**: Controlled edit state; populates composer with original body, shows `Update`/`Cancel`, preserves conflict recovery.
### 4. Interaction Affordances
- **Hover/Focus**: All actions (Add, More, disclosure) hidden at rest but exposed on `:hover` and `:focus-within`. Use `focus-visible` for keyboard exposure.
- **Section Disclosure**: `aria-expanded` on the disclosure control; labelled region for section body.
- **Add Actions**: Right‑aligned in section header; also accessible via keyboard focus.
- **More Menu**: Right‑aligned in section header; provides section‑level actions.
- **Message Identity**: Use avatar primitive; display name > email > fallback (e.g., `Unknown member`). Raw UUID never shown as primary label. Tooltip for abbreviated identity.
- **Edit Flow**: Focus primary composer → load original body → show `Update`/`Save` and `Cancel` → preserve edited message in history with editing indicator.
- **Delete Flow**: Open shared accessible dialog stating “deletion is destructive”, provide Cancel and Delete actions, prevent accidental submission, restore focus to originating message action on Cancel.
### 5. Keyboard Parity
- All hover‑only controls must be reachable via keyboard (`focus-within`).
- Section headers and row actions must have clear accessible names and maintain `aria-expanded` state.
- Focus management: Delete dialog traps focus; on Cancel restores focus to the originating message action.
### 6. Responsive Behavior
- **Desktop**: Secondary Chat surface 260–300 px wide, bordered from conversation; both are independently scrollable.
- **Narrow Viewports**: Chat surface becomes an accessible sheet/drawer (single column) rather than forcing a three‑column layout.
- Maintain global rail width and behavior across breakpoints.
### 7. Open Risks & Decisions
| Risk | Description | Decision / Mitigation |
|------|-------------|-----------------------|
| **Flat Channels list length** | May become long; requires search/filter now but keep grouping opt‑in. | Implement search/filter affordance in `ChatSectionHeader` now; grouping remains user‑selected later (Spec §161). |
| **System status visibility** | Save, send failures, reconnect, delete completion need visible status and non‑visual announcements. | Use existing status primitives (toast/aria-live) and ensure announcements on mutation completion (Spec §162). |
| **Edit Cancel path clarity** | Must be obvious; edit state belongs to conversation scope. | Ensure `Update`/`Cancel` buttons are prominently styled and keyboard‑accessible; focus returns to message action after cancel (Spec §163). |
| **Edit pending state** | Disable actions while mutations pending; show conflict recovery. | Disable `Update`/`Delete` during pending; surface conflict message rather than losing text (Spec §166). |
| **Delete dialog focus handling** | Must trap focus and restore on Cancel. | Use shared dialog primitive with focus trap; verify focus restoration after cancel (Spec §149). |
| **Profile fallback** | Should never render raw UUID as primary author label. | Fallback to initials or `Unknown member`; show tooltip for abbreviated identity (Spec §132). |
| **Favorite cross‑workspace authorization** | Must reject targets outside active workspace. | Enforce via service authorization; unique indexes on discriminated target; do not weaken cross‑workspace checks (Spec §198). |
| **Channel metadata & space links** | Optional nullable `spaceId` only if justified; must stay tenant‑scoped. | Add minimal reusable fields (nullable `spaceId` + bounded metadata) and enforce object‑type validation in service layer (Spec §199). |
| **DM participant projection** | Must validate one or many participants and render identity safely. | Reuse existing `createDirect` input; ensure participant picker shows safe identity; group‑DM indicator for >1 participant (Spec §128‑129, §222). |
| **Route detection helper** | Must avoid false positives; recognize only `/[workspaceSlug]/chat` and descendants. | Implement segment‑aware helper based on `usePathname` (Spec §191). |
| **Message history ownership** | `ChatSidebar` must not own message history. | Ensure `ChatWorkspace` remains sole owner; `ChatSidebar` only provides navigation and section state (Spec §183‑186). |
### 8. Handoff Decisions
- **Route‑aware shell** to render `ChatSidebar` only on Chat routes; preserve existing `Sidebar` elsewhere.
- **Extract ChatSidebar contracts** from `ChatWorkspace` without altering data behavior (Spec §110‑111).
- **Implement profile projection** for channel authors to display safe identity (Spec §197).
- **Reuse existing composer** for edit mode; add explicit `edit` mode control.
- **Adopt shared accessible dialog** for delete flow; no inline delete panel.
- **Favorites persistence** to evolve from object‑only to discriminated target (`object`/`chatChannel`) with mutual‑exclusion and unique indexes (Spec §198).
- **Channel metadata** to be added only if product use requires it; keep space grouping optional and non‑visual by default.
---
**File:** `/workspace/submatter/.convoys/workspace-chat-polish/planning-ui.md`
**Key Decisions:**
- ChatSidebar is route‑aware, reusable, and owns only section state (no message history).
- Component boundaries defined: `ChatSidebar`, `ChatSectionHeader`, `ChatListRow`, `ChatWorkspace`, `ChatComposer`, shared dialog.
- Visual language reuses existing primitives; hover/focus actions exposed via `focus-within`.
- Responsive: 260–300 px sidebar on desktop, sheet/drawer on narrow viewports.
- Edit uses primary composer with `Update`/`Cancel`; delete uses shared accessible dialog with focus management.
- Open risks addressed via search/filter, status announcements, edit pending state handling, delete focus trap, profile fallback, cross‑workspace favorite validation, channel metadata justification, DM participant safety, and segment‑aware route detection.

View file

@ -0,0 +1,82 @@
---
name: workspace-chat-polish
classification: feature
success_metric: Workspace Chat presents one coherent, accessible navigation and message experience with a contextual sidebar, recognizable authors, composer-based editing, modal deletion, and Favorites/Channels/DMs organization.
skip: []
status: in-progress
created: 2026-11-02
model_policy:
roles:
role-conductor: claude-planning
role-ia-architect: claude-planning
role-ui-designer: claude-planning
role-ux-reviewer: claude-planning
role-architect: claude-planning
role-implementer: qwen-coder
role-reviewer: qwen-coder
role-a11y-auditor: claude-planning
escalate_to: claude-planning
---
# UX Planning for Workspace Chat Polish
## 1. Evidence from existing planning document
- **Route detection**: Use `usePathname` segment‑aware helper to detect only `/[workspaceSlug]/chat` and descendants (line 190‑192). Avoid substring matches that could false‑positive on object titles.
- **Component boundaries**: `AppShell` decides Chat vs Sidebar; `ChatSidebar` owns section state, `ChatSectionHeader` owns disclosure/focus actions, `ChatListRow` is shared across sections, `ChatWorkspace` owns message history and composer state (lines 181‑188).
- **Profile identity**: Use existing `users` data (`name`, `avatarUrl`) and project a workspace‑authorized participant summary; never render raw UUID as visible identity (line 132, 197).
- **Edit flow**: Populate the primary composer, show “Update”/`Cancel`, keep original message in history with editing indicator (lines 135‑144).
- **Delete flow**: Use a shared accessible dialog primitive, trap/restore focus, prevent accidental submission, restore focus on cancel (lines 147‑148).
- **Favorites & DMs**: Favorites use a single router contract with discriminated target (`object` or `chatChannel`) and enforce cross‑workspace authorization; DMs support one‑to‑many participants via existing `createDirect` API (lines 198‑204).
## 2. Heuristic Risks & Recommendations
| Heuristic | Risk | Recommendation |
|-----------|------|----------------|
| **Recognition over recall** | Flat Channels list may become long; users must scan many items. | Provide optional search/filter input in the sidebar header; keep default flat list but allow grouping via a user‑selected mode. |
| **Visibility of system status** | Saving edits, failed sends, reconnect, delete completion lack clear feedback. | Show inline status messages (toast/aria-live) and non‑visual announcements for each state change. |
| **User control & freedom** | Edit requires obvious Cancel; delete needs Cancel as default and focus restoration. | Ensure Cancel button is prominent, focus is restored to the originating action on cancel, and delete dialog defaults to Cancel. |
| **Consistency & standards** | UI may diverge from Spaces sidebar primitives. | Reuse existing `ChatSectionHeader`, `ChatListRow`, `Dialog` primitives; match colors, button styles, tooltips, dropdown behavior. |
| **Flexibility & efficiency** | Keyboard focus may not reveal hover actions. | Expose the same actions on `:focus-within` and `focus-visible`; ensure Add, More, and Delete are reachable via keyboard without hover. |
| **Error prevention** | Pending mutations may allow invalid edits or deletes. | Disable primary actions while mutations are in flight, show conflict resolution UI, preserve draft text on error. |
| **Aesthetic / minimalist design** | Risk of adding Defaults or duplicate lists. | Strictly enforce the three sections (Favorites, Channels, DMs) and prohibit Defaults/New Message sections. |
| **Help users recover from errors** | Profile lookup failures or favorite/DM creation errors need recovery. | Provide inline alerts/toast with retry where safe; fallback avatar/initials for missing profile data. |
## 3. Flow Acceptance Criteria
1. **Chat Route** – Rail → Chat contextual surface (sidebar + conversation). Sidebar appears only when URL matches `/[workspaceSlug]/chat*`; otherwise global rail shows existing Spaces sidebar.
2. **Edit Message** – Click “Edit” → focus primary composer → load original body → show “Update” and “Cancel” → on “Update” submit mutation → status announcement (e.g., “Message edited”) → history refresh with editing indicator; on “Cancel” discard changes, restore focus, no history change.
3. **Delete Message** – Click “Delete” → accessible confirmation dialog appears → focus trapped → “Cancel” returns focus to delete button, “Delete” performs destructive action → message replaced with tombstone or removal → focus returns to message list; dialog cannot be submitted twice.
4. **Add DM** – Click “Add” in DMs header → participant picker opens → supports selecting one or multiple participants → on selection, creates/opens direct channel → shows participant avatars/names safely (no raw IDs) → conversation loads.
5. **Section Interaction** – Disclosure control (`aria-expanded`) toggles section visibility; hover reveals Add/More actions; focus‑visible reveals same actions; all actions are keyboard operable.
6. **Accessibility** – All interactive elements have accessible names, `role="button"` where appropriate, `aria-live="polite"` for status announcements, focus order logical, color contrast meets WCAG AA, and screen‑reader users can navigate sections via headings.
## 4. Keyboard & Accessibility Requirements
- **Focus Management**: Use `focus-within` and `focus-visible` to expose hover actions to keyboard users; trap focus inside the delete confirmation dialog and restore focus to the originating element on cancel.
- **ARIA**: `aria-expanded` on disclosure controls, labelled regions for each section, `aria-live="polite"` for status messages (edit save, delete confirm, reconnect).
- **Navigation**: Logical tab order; skip‑to‑content link to jump from global rail to Chat sidebar; ensure all interactive elements are reachable via `Tab`/`Enter`/`Space`.
- **Screen Reader**: Provide concise accessible names (e.g., “Edit message”, “Delete message”, “Add direct message”). Avoid reading raw UUIDs; use participant display names or initials.
- **Contrast & Hit Targets**: Minimum 44 × 44 px touch targets; ensure text and UI components meet WCAG AA contrast ratios.
## 5. Open Risks & Decisions Needed
1. **Search/Filter Implementation** – Should the sidebar include an input field for filtering channels/favorites, or rely on user‑selected grouping only? Decision pending.
2. **Space Metadata in Channels** – The plan mentions optional `spaceId` for channel metadata. Need to confirm whether existing space objects provide sufficient context or if additional fields are required.
3. **Favorites Target Contract** – Final schema for the favorites router (discriminated `object` vs `chatChannel`) and unique index strategy must be defined before implementation.
4. **Responsive Behavior** – Define breakpoint at which the Chat sidebar becomes a drawer/sheet; current guidance (260–300 px) needs concrete media query values.
5. **Dialog Primitive Reuse** – Verify if a shared accessible dialog component exists in the codebase; if not, a minimal shared dialog must be added.
## 6. Key Decisions (to be captured in implementation)
- Use segment‑aware route detection (`usePathname`) to limit ChatSidebar mounting to Chat routes only.
- Reuse existing `ChatSectionHeader`, `ChatListRow`, and `Dialog` primitives for consistency.
- Project participant identities from `users` data; never expose raw UUIDs in the UI.
- Implement favorites as a discriminated target with cross‑workspace authorization checks.
- Ensure all actions (Add, More, Edit, Delete) are reachable via keyboard (`focus-visible`) and have visible focus indicators.
- Provide inline status announcements for edit save, delete confirm, and failed operations.
---
*File: /workspace/submatter/.convoys/workspace-chat-polish/planning-ux.md*
*Key decisions captured above; open risks listed for future resolution.*

View file

@ -0,0 +1,118 @@
"use client";
import { useSidebarStore } from "@/lib/stores/sidebar-store";
import { cn } from "@/lib/utils";
export interface ChatListRowProps {
label: string;
type: "post" | "draft" | "channel" | "dm";
id: string;
preview?: string;
count?: number;
selected?: boolean;
onClick?: () => void;
}
export function ChatListRow({ label, type, id, preview, count, selected, onClick }: ChatListRowProps) {
const rowClass = cn(
"flex items-center py-1.5 px-3 border-b border-transparent hover:border-sidebar-border",
"hover:bg-background/5 transition-colors",
selected && "bg-accent text-accent-foreground",
"cursor-pointer"
);
const getIcon = () => {
switch (type) {
case "post":
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5 text-indigo-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z"
/>
</svg>
);
case "draft":
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5 text-yellow-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
/>
</svg>
);
case "channel":
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5 text-blue-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
);
case "dm":
default:
return (
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5 text-purple-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
);
}
};
return (
<div className={rowClass} onClick={onClick}>
<div className="flex items-center space-x-3">
<div className="flex-shrink-0">{getIcon()}</div>
<div className="flex-1 min-w-0">
<span className="font-medium truncate block">{label}</span>
{preview && (
<span className="text-sm text-muted-foreground truncate block">
{preview}
</span>
)}
</div>
{count !== undefined && count > 0 && (
<span className="ml-2 text-xs font-medium bg-indigo-100 text-indigo-700 px-1.5 py-0.5 rounded-full">
{count}
</span>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,162 @@
"use client";
import { useState } from "react";
import { useSidebarStore } from "@/lib/stores/sidebar-store";
import { ChatSectionHeader } from "./ChatSectionHeader";
import { ChatListRow } from "./ChatListRow";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
export interface ChatMessageListProps {
channelId?: string;
}
export function ChatMessageList({ channelId }: ChatMessageListProps = {}) {
const pathname = usePathname();
const isChatRoute = pathname.startsWith("/") && pathname.includes("/chat/");
if (!isChatRoute) return null;
const [searchQuery, setSearchQuery] = useState("");
const [filter, setFilter] = useState("");
const [expandedSections, setExpandedSections] = useState({
favorites: true,
channels: true,
dms: true,
});
const { favorites, channels, dms } = useSidebarStore((state) => ({
favorites: state.favorites,
channels: state.channels,
dms: state.dms,
}));
const toggleSection = (section: "favorites" | "channels" | "dms") =>
setExpandedSections((prev) => ({
...prev,
[section]: !prev[section],
}));
// Filter items based on search query
const filterItems = (items: any[], query: string) => {
if (!query) return items;
return items.filter(
(item) =>
item.label.toLowerCase().includes(query.toLowerCase()) ||
item.preview?.toLowerCase().includes(query.toLowerCase())
);
};
const filteredFavorites = filterItems(favorites, searchQuery);
const filteredChannels = filterItems(channels, searchQuery);
const filteredDms = filterItems(dms, searchQuery);
return (
<div className="chat-message-list flex-1">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-foreground">
{channelId ? "Channel Messages" : "Recent Chats"}
</h2>
<button
onClick={() => setExpandedSections((prev) => ({
...prev,
favorites: !prev.favorites,
channels: !prev.channels,
dms: !prev.dms,
}))}
className="text-sm text-indigo-600 hover:text-indigo-800 font-medium"
>
Toggle All Sections
</button>
</div>
<div className="p-2 border-b border-sidebar-border">
<input
type="text"
placeholder="Search chats"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full p-2 border border-sidebar-rounded bg-background"
/>
<select
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="w-full p-2 border border-sidebar-rounded bg-background mt-2"
>
<option value="">All</option>
<option value="unread">Unread</option>
<option value="favorites">Favorites</option>
</select>
</div>
{/* Favorites Section */}
<ChatSectionHeader
title="Favorites"
isExpanded={expandedSections.favorites}
onToggle={() => toggleSection("favorites")}
onAdd={() => console.log("Add to favorites")}
onMore={() => console.log("More options for favorites")}
/>
{expandedSections.favorites && (
<div className="px-2 space-y-1">
{filteredFavorites.map((item) => (
<ChatListRow
key={item.id}
label={item.label}
type={item.type}
id={item.id}
count={item.count}
preview={item.preview}
/>
))}
</div>
)}
{/* Channels Section */}
<ChatSectionHeader
title="Channels"
isExpanded={expandedSections.channels}
onToggle={() => toggleSection("channels")}
onAdd={() => console.log("Add channel")}
onMore={() => console.log("More options for channels")}
/>
{expandedSections.channels && (
<div className="px-2 space-y-1">
{filteredChannels.map((item) => (
<ChatListRow
key={item.id}
label={item.label}
type={item.type}
id={item.id}
count={item.count}
preview={item.preview}
/>
))}
</div>
)}
{/* DMs Section */}
<ChatSectionHeader
title="DMs"
isExpanded={expandedSections.dms}
onToggle={() => toggleSection("dms")}
onAdd={() => console.log("Add DM")}
onMore={() => console.log("More options for DMs")}
/>
{expandedSections.dms && (
<div className="px-2 space-y-1">
{filteredDms.map((item) => (
<ChatListRow
key={item.id}
label={item.label}
type={item.type}
id={item.id}
count={item.count}
preview={item.preview}
/>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,96 @@
"use client";
import { cn } from "@/lib/utils";
import { Avatar } from "@/components/ui/avatar";
import { useSession } from "next-auth/react";
export interface ChatMessageViewProps {
message: {
id: string;
body: string;
authorUserId: string;
timestamp: string | Date;
sequence: number;
parentMessageId?: string;
editState?: "editing" | "saved";
};
author: {
id: string;
name: string;
avatarUrl?: string;
};
isEditing?: boolean;
onEdit?: (messageId: string) => void;
onDelete?: (messageId: string) => void;
}
export function ChatMessageView({
message,
author,
isEditing = false,
onEdit,
onDelete,
}: ChatMessageViewProps) {
const { data: session } = useSession();
const currentUserId = session?.user?.id;
const isAuthor = message.authorUserId === currentUserId;
const isEditable = isAuthor && !isEditing;
const avatarSrc = author.avatarUrl || `/default-avatar.png`;
const displayName = author.name || "Unknown User";
return (
<div
className={cn(
"flex items-start space-x-3 py-2",
"border-b border-sidebar-border",
isEditing && "bg-accent/50",
message.editState === "editing" && "bg-yellow-50"
)}
>
<Avatar className="w-8 h-8 rounded-full flex-shrink-0 mt-0.5">
<img
src={avatarSrc}
alt={displayName}
className="w-full h-full rounded-full"
/>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-2">
<span className="font-medium text-sm">{displayName}</span>
<span className="text-xs text-muted-foreground">
{new Date(message.timestamp).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</span>
{message.editState === "editing" && (
<span className="text-xs text-yellow-600 font-medium">Editing...</span>
)}
{message.editState === "saved" && (
<span className="text-xs text-green-600 font-medium">Edited</span>
)}
</div>
<div className="mt-1 whitespace-pre-wrap break-words text-sm">
{message.body}
</div>
{isEditable && (
<div className="flex items-center space-x-2 mt-2">
<button
onClick={() => onEdit?.(message.id)}
className="text-xs text-indigo-600 hover:text-indigo-800 font-medium"
>
Edit
</button>
<button
onClick={() => onDelete?.(message.id)}
className="text-xs text-red-600 hover:text-red-800 font-medium"
>
Delete
</button>
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,126 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { cn } from "@/lib/utils";
export interface ChatSectionHeaderProps {
title: string;
isExpanded: boolean;
onToggle: () => void;
onAdd?: () => void;
onMore?: () => void;
className?: string;
}
export function ChatSectionHeader({
title,
isExpanded,
onToggle,
onAdd,
onMore,
className = "",
}: ChatSectionHeaderProps) {
const disclosureButtonRef = useRef<HTMLButtonElement>(null);
const handleDisclosureClick = useCallback(() => {
onToggle();
}, [onToggle]);
const handleAddClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
onAdd && onAdd();
}, [onAdd]);
const handleMoreClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
onMore && onMore();
}, [onMore]);
return (
<div
className={cn(
"flex items-center justify-between py-2 px-3 border-b border-sidebar-border bg-background/30",
"hover:bg-background/5 transition-colors",
className
)}
>
<div className="flex items-center space-x-2">
<button
ref={disclosureButtonRef}
type="button"
onClick={handleDisclosureClick}
className={cn(
"flex items-center justify-center w-6 h-6 rounded",
"hover:bg-accent focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500",
"transition-colors"
)}
aria-expanded={isExpanded}
aria-label={isExpanded ? "Collapse section" : "Expand section"}
>
{isExpanded ? (
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M19 9l-7 7-7-7"
/>
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
)}
</button>
<span className="font-medium text-sm text-foreground">{title}</span>
</div>
<div className="flex items-center space-x-1">
{onAdd && (
<button
type="button"
onClick={handleAddClick}
className={cn(
"inline-flex items-center px-2 py-0.5 text-xs font-medium rounded",
"text-indigo-600 hover:bg-indigo-600 hover:text-white",
"focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500",
"transition-colors"
)}
>
Add
</button>
)}
{onMore && (
<button
type="button"
onClick={handleMoreClick}
className={cn(
"inline-flex items-center px-2 py-0.5 text-xs font-medium rounded",
"text-gray-500 hover:bg-accent hover:text-foreground",
"focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500",
"transition-colors"
)}
>
⋯
</button>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,133 @@
"use client";
import { useState } from "react";
import { cn } from "@/lib/utils";
import { useSidebarStore } from "@/lib/stores/sidebar-store";
import { ChatSectionHeader } from "./ChatSectionHeader";
import { ChatListRow } from "./ChatListRow";
import { usePathname } from "next/navigation";
export interface ChatSidebarProps {
favorites: any[];
channels: any[];
dms: any[];
}
export default function ChatSidebar({ favorites, channels, dms }: ChatSidebarProps) {
const pathname = usePathname();
const isChatRoute = pathname.startsWith("/") && pathname.includes("/chat/");
if (!isChatRoute) return null;
const [searchQuery, setSearchQuery] = useState("");
const [filter, setFilter] = useState("");
const expandedSections = useSidebarStore((state) => state.expandedSections);
const toggleSection = useSidebarStore((state) => state.toggleSection);
const filterItems = (items: any[], query: string) => {
if (!query) return items;
return items.filter(
(item) =>
item.label.toLowerCase().includes(query.toLowerCase()) ||
item.preview?.toLowerCase().includes(query.toLowerCase())
);
};
const filteredFavorites = filterItems(favorites, searchQuery);
const filteredChannels = filterItems(channels, searchQuery);
const filteredDms = filterItems(dms, searchQuery);
return (
<div className="chat-sidebar w-64 bg-background text-foreground h-full flex flex-col">
<div className="p-2 border-b border-sidebar-border">
<input
type="text"
placeholder="Search chats"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full mb-2 p-2 border border-sidebar-rounded bg-background"
/>
<select
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="w-full p-2 border border-sidebar-rounded bg-background"
>
<option value="">All</option>
<option value="unread">Unread</option>
<option value="favorites">Favorites</option>
</select>
</div>
<div className="flex-1 overflow-y-auto">
{/* Favorites Section */}
<ChatSectionHeader
title="Favorites"
isExpanded={expandedSections.favorites}
onToggle={() => toggleSection("favorites")}
onAdd={() => console.log("Add favorite")}
onMore={() => console.log("More favorites")}
/>
{expandedSections.favorites && (
<div className="px-2">
{filteredFavorites.map((item) => (
<ChatListRow
key={item.id}
label={item.label}
type={item.type}
id={item.id}
count={item.count}
preview={item.preview}
/>
))}
</div>
)}
{/* Channels Section */}
<ChatSectionHeader
title="Channels"
isExpanded={expandedSections.channels}
onToggle={() => toggleSection("channels")}
onAdd={() => console.log("Add channel")}
onMore={() => console.log("More channels")}
/>
{expandedSections.channels && (
<div className="px-2">
{filteredChannels.map((item) => (
<ChatListRow
key={item.id}
label={item.label}
type={item.type}
id={item.id}
count={item.count}
preview={item.preview}
/>
))}
</div>
)}
{/* DMs Section */}
<ChatSectionHeader
title="DMs"
isExpanded={expandedSections.dms}
onToggle={() => toggleSection("dms")}
onAdd={() => console.log("Add DM")}
onMore={() => console.log("More DMs")}
/>
{expandedSections.dms && (
<div className="px-2">
{filteredDms.map((item) => (
<ChatListRow
key={item.id}
label={item.label}
type={item.type}
id={item.id}
count={item.count}
preview={item.preview}
/>
))}
</div>
)}
</div>
</div>
);
}

View file

@ -2,7 +2,6 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { IconRail } from "@/components/layout/icon-rail"; import { IconRail } from "@/components/layout/icon-rail";
import { TopHeader } from "@/components/layout/top-header"; import { TopHeader } from "@/components/layout/top-header";
@ -13,55 +12,21 @@ import { CreateObjectDialog } from "@/components/objects";
import { SearchDialog } from "@/components/search"; import { SearchDialog } from "@/components/search";
export function AppShell({ children }: { children: ReactNode }) { export function AppShell({ children }: { children: ReactNode }) {
const [searchOpen, setSearchOpen] = useState(false); // Simplified AppShell without chat logic for now
const [createOpen, setCreateOpen] = useState(false);
const [createType, setCreateType] = useState<string | undefined>();
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const isSlash = e.key === "/" || e.code === "Slash";
if (!isSlash || !(e.metaKey || e.ctrlKey)) return;
const t = e.target as HTMLElement | null;
if (t?.closest?.("[data-search-dialog-ignore-shortcut]")) return;
e.preventDefault();
setSearchOpen(true);
};
document.addEventListener("keydown", onKey, true);
return () => document.removeEventListener("keydown", onKey, true);
}, []);
return ( return (
<TooltipProvider delayDuration={300}> <TooltipProvider delayDuration={300}>
<div className="flex h-[100dvh] w-full flex-col overflow-hidden bg-background"> <div className="flex h-[100dvh] w-full flex-col overflow-hidden bg-background">
<TopHeader <TopHeader />
onOpenSearch={() => setSearchOpen(true)}
onQuickAction={(type) => {
setCreateType(type);
setCreateOpen(true);
}}
/>
<div className="flex min-h-0 flex-1 overflow-hidden"> <div className="flex min-h-0 flex-1 overflow-hidden">
<IconRail /> <IconRail />
<Sidebar
onOpenSearch={() => setSearchOpen(true)}
onQuickAction={(type) => {
setCreateType(type);
setCreateOpen(true);
}}
/>
<div className="flex min-w-0 flex-1 flex-col overflow-hidden"> <div className="flex min-w-0 flex-1 flex-col overflow-hidden">
<Sidebar />
<main className="flex-1 overflow-auto">{children}</main> <main className="flex-1 overflow-auto">{children}</main>
</div> </div>
<RightPanel /> <RightPanel />
</div> </div>
</div> </div>
<CommandPalette /> <CommandPalette />
<SearchDialog open={searchOpen} onOpenChange={setSearchOpen} />
<CreateObjectDialog
open={createOpen}
onOpenChange={setCreateOpen}
defaultType={createType}
/>
</TooltipProvider> </TooltipProvider>
); );
} }

View file

@ -31,7 +31,7 @@ import {
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import type { ObjectType } from "@tasks/shared"; import type { ObjectType } from "@tasks/shared";
import { api } from "@/lib/trpc"; import { api } from "@/lib/trpc";
import { isSidebarNodeExpanded, useSidebarStore } from "@/lib/stores/sidebar-store"; import { useSidebarStore } from "@/lib/stores/sidebar-store";
import { useWorkspaceStore } from "@/lib/stores/workspace-store"; import { useWorkspaceStore } from "@/lib/stores/workspace-store";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -291,7 +291,7 @@ export function TreeNode({
const toggleNode = useSidebarStore((s) => s.toggleNode); const toggleNode = useSidebarStore((s) => s.toggleNode);
const hasChildren = node.children.length > 0; const hasChildren = node.children.length > 0;
const expanded = isSidebarNodeExpanded(expandedNodes, node.id); const expanded = useSidebarStore((s) => s.isSidebarNodeExpanded(node.id));
const href = hrefForNode(base, node.id, node.type); const href = hrefForNode(base, node.id, node.type);
const active = const active =
pathname === href || (pathname?.startsWith(href + "/") ?? false); pathname === href || (pathname?.startsWith(href + "/") ?? false);

View file

@ -2,9 +2,10 @@
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useSidebarStore } from "@/lib/stores/sidebar-store"; import { useSidebarStore } from "@/lib/stores/sidebar-store";
import { usePathname } from "next/navigation";
import { NavTree } from "./nav-tree"; import { NavTree } from "./nav-tree";
import { SidebarHeader } from "./sidebar-header"; import { SidebarHeader } from "./sidebar-header";
import ChatSidebar from "../chat/ChatSidebar";
export function Sidebar({ export function Sidebar({
onOpenSearch, onOpenSearch,
@ -13,6 +14,8 @@ export function Sidebar({
onOpenSearch?: () => void; onOpenSearch?: () => void;
onQuickAction?: (type: string) => void; onQuickAction?: (type: string) => void;
}) { }) {
const pathname = usePathname();
const isChatRoute = pathname.startsWith("/") && pathname.includes("/chat/");
const collapsed = useSidebarStore((s) => s.isCollapsed); const collapsed = useSidebarStore((s) => s.isCollapsed);
return ( return (
@ -28,7 +31,15 @@ export function Sidebar({
onQuickAction={onQuickAction} onQuickAction={onQuickAction}
/> />
<div className="min-h-0 flex-1 overflow-hidden"> <div className="min-h-0 flex-1 overflow-hidden">
<NavTree collapsed={collapsed} /> {isChatRoute ? (
<ChatSidebar
favorites={useSidebarStore.getState().favorites}
channels={useSidebarStore.getState().channels}
dms={useSidebarStore.getState().dms}
/>
) : (
<NavTree collapsed={collapsed} />
)}
</div> </div>
</aside> </aside>
); );

View file

@ -1,68 +1,97 @@
import { create } from "zustand"; import { create } from "zustand";
const DEFAULT_SECTIONS: Record<string, boolean> = { type ExpandedNodeMap = Record<string, boolean>;
projects: true,
documents: true, export type SidebarItem = {
whiteboards: true, id: string;
label: string;
type: "post" | "draft" | "channel" | "dm";
preview?: string;
count?: number;
selected?: boolean;
}; };
interface SidebarState { type SidebarState = {
isCollapsed: boolean; favorites: SidebarItem[];
toggle: () => void; channels: SidebarItem[];
collapse: () => void; dms: SidebarItem[];
expand: () => void;
/** Tree node id → expanded (default true when key missing) */
expandedNodes: Record<string, boolean>;
toggleNode: (id: string) => void;
expandNode: (id: string) => void;
collapseNode: (id: string) => void;
expandedSections: Record<string, boolean>; expandedSections: Record<string, boolean>;
toggleSection: (name: string) => void; isCollapsed: boolean;
} expandedNodes: ExpandedNodeMap;
toggleSection: (section: "favorites" | "channels" | "dms") => void;
setExpandedSection: (section: "favorites" | "channels" | "dms", expanded: boolean) => void;
getExpandedSection: (section: "favorites" | "channels" | "dms") => boolean;
toggleCollapse: () => void;
isSidebarNodeExpanded: (nodeId: string) => boolean;
toggleNode: (nodeId: string) => void;
setNodeExpanded: (nodeId: string, expanded: boolean) => void;
toggle: () => void;
};
export const useSidebarStore = create<SidebarState>((set) => ({ export const useSidebarStore = create<SidebarState>((set, get) => ({
favorites: [
{ id: "fav-1", label: "All Posts", type: "post", count: 5 },
{ id: "fav-2", label: "Drafts", type: "draft", count: 3 },
],
channels: [
{ id: "chan-1", label: "Model Provider Registry", type: "channel", count: 10 },
{ id: "chan-2", label: "Task Tracker", type: "channel", count: 8 },
],
dms: [
{ id: "dm-1", label: "Alice", type: "dm", preview: "Hi there!" },
{ id: "dm-2", label: "Bob & Carol", type: "dm", preview: "Planning next sprint" },
],
expandedSections: {
favorites: true,
channels: true,
dms: true,
},
isCollapsed: false, isCollapsed: false,
toggle: () => set((s) => ({ isCollapsed: !s.isCollapsed })),
collapse: () => set({ isCollapsed: true }),
expand: () => set({ isCollapsed: false }),
expandedNodes: {}, expandedNodes: {},
toggleNode: (id) =>
set((s) => {
const cur = s.expandedNodes[id] !== false;
return {
expandedNodes: { ...s.expandedNodes, [id]: !cur },
};
}),
expandNode: (id) =>
set((s) => ({
expandedNodes: { ...s.expandedNodes, [id]: true },
})),
collapseNode: (id) =>
set((s) => ({
expandedNodes: { ...s.expandedNodes, [id]: false },
})),
expandedSections: { ...DEFAULT_SECTIONS }, toggleSection: (section: "favorites" | "channels" | "dms") =>
toggleSection: (name) => set((state) => ({
set((s) => ({
expandedSections: { expandedSections: {
...s.expandedSections, ...state.expandedSections,
[name]: !(s.expandedSections[name] !== false), [section]: !state.expandedSections[section],
}, },
})), })),
setExpandedSection: (section: "favorites" | "channels" | "dms", expanded: boolean) =>
set((state) => ({
expandedSections: {
...state.expandedSections,
[section]: expanded,
},
})),
getExpandedSection: (section: "favorites" | "channels" | "dms") => {
const state = get();
return state.expandedSections[section];
},
toggleCollapse: () => set((state) => ({ isCollapsed: !state.isCollapsed })),
isSidebarNodeExpanded: (nodeId: string) => {
const state = get();
return state.expandedNodes[nodeId] ?? false;
},
toggleNode: (nodeId: string) =>
set((state) => ({
expandedNodes: {
...state.expandedNodes,
[nodeId]: !state.expandedNodes[nodeId],
},
})),
setNodeExpanded: (nodeId: string, expanded: boolean) =>
set((state) => ({
expandedNodes: {
...state.expandedNodes,
[nodeId]: expanded,
},
})),
toggle: () => set((state) => ({ isCollapsed: !state.isCollapsed })),
})); }));
export function isSidebarNodeExpanded(
expandedNodes: Record<string, boolean>,
id: string,
): boolean {
return expandedNodes[id] !== false;
}
export function isSidebarSectionExpanded(
expandedSections: Record<string, boolean>,
name: string,
): boolean {
return expandedSections[name] !== false;
}