From 6ade66bdd1cb81ec636ad32483c26683466fa679 Mon Sep 17 00:00:00 2001 From: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> Date: Thu, 24 Sep 2026 13:12:11 -0600 Subject: [PATCH 1/4] feat: keep page navigation and channels in a persistent sidebar Signed-off-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> --- docs/channels.md | 29 +- docs/plugin-architecture.md | 19 +- docs/shell-design.md | 25 +- src/app/App.tsx | 159 ++-- src/app/shell/AppShell.tsx | 92 ++- src/app/shell/presentation.ts | 12 +- src/bundled/channels/Channels.module.css | 51 +- src/bundled/channels/ChannelsPage.tsx | 683 ++---------------- src/bundled/channels/index.tsx | 3 +- .../ChannelNavigationState.test.tsx | 119 +++ .../ChannelNavigationState.tsx | 117 +++ .../channel-navigation/ChannelSidebar.tsx | 637 ++++++++++++++++ .../ChannelSidebarResizeHandle.tsx | 103 +++ .../channel-navigation/routes.test.ts | 25 + src/features/channel-navigation/routes.ts | 21 + src/shared/design-system/icons/index.ts | 2 + src/shared/styles/globals.css | 44 +- tests/browser/appearance.spec.mjs | 8 +- tests/browser/layout.spec.mjs | 131 +++- tests/browser/navigation-boundaries.spec.mjs | 4 +- tests/browser/navigation-groups.spec.mjs | 4 +- .../browser/navigation-scroll-intent.spec.mjs | 9 +- tests/browser/navigation-sidebar.spec.mjs | 95 ++- tests/browser/new-message.spec.mjs | 2 +- tests/browser/thread-unread.spec.mjs | 73 ++ tests/fixtures/channel-workspace.tsx | 71 ++ tests/fixtures/profiles.tsx | 12 +- tests/fixtures/relay-composer.tsx | 8 +- 28 files changed, 1747 insertions(+), 811 deletions(-) create mode 100644 src/features/channel-navigation/ChannelNavigationState.test.tsx create mode 100644 src/features/channel-navigation/ChannelNavigationState.tsx create mode 100644 src/features/channel-navigation/ChannelSidebar.tsx create mode 100644 src/features/channel-navigation/ChannelSidebarResizeHandle.tsx create mode 100644 src/features/channel-navigation/routes.test.ts create mode 100644 src/features/channel-navigation/routes.ts create mode 100644 tests/fixtures/channel-workspace.tsx diff --git a/docs/channels.md b/docs/channels.md index 04516c167..fd3c10365 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -22,7 +22,8 @@ frontend hot reload alone is not enough. Settings independently enables/disables Channels and GitHub. Disabling GitHub removes its link handler and open panel; shared channel data remains available. -Disabling Channels removes its page while the app-owned data survives. +Channels is required by the current host; optional page removal does not dispose +the app-owned sidebar or session data. The broker uses the existing authorized Buzz identity in the OS secret store (macOS Keychain, Linux secret service) and @@ -45,7 +46,11 @@ This is not a new native login. - `features/messages` owns reusable `ChannelTimeline`, `MessageRow`, `ThreadPanel`, `MessageComposer`, delivery presentation, styles and reading geometry. They accept ordinary props over the shared session; none owns a connection or outbox. -- `bundled/channels` owns page registration, channel selection/navigation, sidebar, +- `features/channel-navigation` owns the persistent sidebar and its scoped UI handoff + for session draft rows and preparing DMs. App composes it beside independent pages; + sidebar actions use the normal navigation controller. It reuses session capabilities + and existing sidebar components without another relay/cache or plugin registry. +- `bundled/channels` owns page registration, conversation selection/navigation, diagnostics, layout and panel placement. `shared/view-state.ts` partitions persisted drafts and view intent by community/viewer scope. - `bundled/github` registers and implements the panel. Channels uses the panel @@ -60,8 +65,9 @@ Keep page-specific navigation and arrangement in the plugin; compose shared mess components rather than copying them. Session reconciliation, authorization, retained reads and durable outbox recovery remain host-owned even if Channels is disabled. -The workspace React key includes community/viewer scope **and** connection -generation. This resets session-owned component state on switching or reconnecting; +The sidebar and Channels workspace React keys include community/viewer scope **and** +connection generation. This resets their session-owned state on switching or +reconnecting, not unrelated page drafts; drafts, channel selection and reading geometry retain their stable scope keys. Saved sidebar groups, ordering, assignments and stars live in the session's @@ -74,13 +80,14 @@ preferences, not channel access grants: sidebar sections still intersect the authorized roster. There is no new disk cache or automatic cross-device sync. Collapsed section keys and sidebar scroll remain separate, scoped view intent. -They are saved on page exit and restored before paint when the roster and groups +They survive page switches in the same mounted sidebar, are saved when that +sidebar exits its session, and restore before paint when the roster and groups are available; navigation history does not own them. Search lives in the top-bar palette; legacy sidebar filters are ignored. The saved-groups browser regression records every visible return frame and holds the redundant decode path, so eventual restoration cannot conceal a fallback-group/scroll jump. -Channel row actions share one page-owned `ContextMenuRoot` / `MenuPopup`, labelled +Channel row actions share one sidebar-owned `ContextMenuRoot` / `MenuPopup`, labelled `Actions for `. **New session** comes first; additional sidebar actions should extend that popup, with a separator only when another action group follows. `ChannelSidebarItem` owns the context trigger inside its memo boundary, using stable @@ -96,6 +103,16 @@ derive the saved group id separately from `group:` rather than conflating it with rendered placement. Right-clicking the separate session disclosure remains outside the parent menu trigger, as do child-session rows. +Sidebar create-channel dialogs and partial-setup recovery stay available on other +pages. Completion is fenced to the originating relay session and navigates to a +normal conversation destination. New-session intent uses the Channels version-1 +page route `{ kind: "new-session", parentId }`; Channels checks parent access/type +and Sessions availability. Only parent intent, never draft text, enters history. +Preparing-DM suppression captures the pre-open roster and exact member set, hiding +only newly prepared DMs until confirmation; leaving New message or replacing the +session clears that handoff. Timeline readers and reading leases stay in visible +conversation content and unmount when leaving Messages. + ## Starting a direct message The **+** action in the DMs sidebar header opens **New message**, a routed empty diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index de987197c..b3ccb7b3b 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -23,7 +23,7 @@ Both experiences use real session capabilities. As AI integrations become availa | Area | Responsibility | | --- | --- | -| Application host | Startup, plugin installation and activation, page navigation, Settings, and recovery. | +| Application host | Startup, plugin installation and activation, page navigation, persistent channel sidebar, Settings, and recovery. | | Page plugin | Its complete React tree, local interaction state, internal navigation, and arrangement of panels. | | Panel plugin | Recognizing a supported target and implementing the content and interactions for that target. | | Shared capabilities | Session state, relay access, retained data, local agent controls, and eventually external connections. | @@ -45,7 +45,8 @@ features/panels/ target resolution, launcher contract and reusable card/f features/shortcuts/ in-app binding dispatch, focus rules and plugin ownership features/relay/ shared channel data, queries, profiles and durable delivery features/messages/ reusable timeline, message, thread and composer UI -bundled/channels/ Channels navigation, sidebar, page layout and panel placement +features/channel-navigation/ persistent sidebar, scoped draft handoff, Channels routes +bundled/channels/ conversation navigation, page layout and panel placement bundled/projects/ title-only Projects page scaffold bundled/agents/ local control UI and read-only current-Buzz library page features/agents/ app-owned control capability; separate session-owned library @@ -53,6 +54,12 @@ bundled/github/ builtin GitHub panel plugin bundled/bestie/ builtin companion panel and its snake launcher ``` +The host composes one channel sidebar beside independently mounted pages. It reuses +session-owned roster, unread, creation and preferences capabilities; it does not +retain a hidden Channels page or message reader. Sidebar and page render errors +have separate boundaries. Sidebar presentation helpers currently remain importable +from `bundled/channels`; no public sidebar contribution contract is introduced. + Channels is the page-authoring example, not a thin registration wrapper over a host-owned product page. Keep page-specific components, styles, interactions and tests beside `bundled/channels/index.tsx`. New page plugins should do the same. At the @@ -108,13 +115,13 @@ exact registration identity and mounted lifetime revoke callbacks on removal. Templates & teams (`buzz.channel-templates`) is bundled **off by default** in both browser and desktop catalogs. Explicit saved overrides win. Enable it under -Settings → Plugins, then manage recipes under Settings → Messages. Channels owns +Settings → Plugins, then manage recipes under Settings → Messages. The host sidebar owns personal groups and the existing + creation buttons, independently of this plugin. `ctx.channelTemplates.register({ id, title, editor, groupDefault, saveAs })` supplies one optional composition provider. With zero or multiple active providers, no optional controls are selected. This host-matched preview is not a workflow API: -Channels owns form/draft data and final dispatch; the session owns signing, +The sidebar owns creation form/draft data and final dispatch; the session owns signing, membership, Canvas writes, exact receipts and partial-setup recovery. Settings and provider components must check `active()` before accepting delayed work or starting new writes; this lifecycle fence is not a sandbox or a replacement for access checks. @@ -148,8 +155,8 @@ thread changes. The accessory remains usable on read-only connections. Agent Activity is the first consumer. Plugin activation owns its telemetry lease; multiple composers subscribe to the same session capability. No global selected channel or activity-specific dependency is added to reusable message components. -Channels reads the same session activity snapshot for its quiet sidebar marker; -it owns that page presentation, not capture or an additional activity lease. +The persistent sidebar reads the same session activity snapshot for its quiet marker; +it owns presentation, not capture or an additional activity lease. This is a host-matched preview addition, not cross-version capability negotiation. ### Channel-header launchers diff --git a/docs/shell-design.md b/docs/shell-design.md index bacf0924f..0afbfbd95 100644 --- a/docs/shell-design.md +++ b/docs/shell-design.md @@ -24,15 +24,21 @@ semantic tokens, UI authoring rules and the local component reference. Existing feature CSS variables remain available for incremental adoption. - `src/app/shell/presentation.ts` owns page labels, icons and navigation ordering. Messages comes first, then Projects; other contributed pages follow by - displayed label with a full contribution-key tie-breaker. Tabs and + displayed label with a full contribution-key tie-breaker. Sidebar navigation and page search share this policy, independent of plugin activation/re-enable order. Channels is presented as Messages. Legacy tone props are retained for compatibility; all pages share the supplied gradient and repeating CSS dots. Add recognized page presentation here without changing plugin contracts. -- `AppShell.tsx` owns the 56px header, scrollable centered navigation, contributed panel - launchers, Settings access, community switcher, and page frames. Equal-width - left/right header tracks center tabs on the window, not the leftover space. - Below 700px the tab pill moves to a centered second row to avoid collisions. +- `AppShell.tsx` owns the 56px header, vertical page navigation, contributed panel + launchers, Settings access, community rail, and page frames. Page navigation sits + above the persistent channel list on every page, using its saved sidebar width + and resize behavior. `App.tsx` composes `features/channel-navigation/ChannelSidebar` + through an ordinary render prop; there is no portal or plugin contract expansion. + Sidebar session state resets on scope/connection generation without remounting + unrelated pages. Its own error boundary keeps page navigation and Settings usable. + Page buttons use shared navigation rows and focus the main region on selection. + A scrollable page list leaves room for channels at short heights. + The header keeps history and account/search actions, with no second navigation row. Full-height pages get a 16px outer gutter (8px on narrow screens) and own their card surfaces. The shell adds no white backing behind them. Document pages scroll inside the remaining viewport. @@ -63,7 +69,7 @@ motion with Tailwind's `motion-reduce` variant. Tauri uses `titleBarStyle: Overlay` and `hiddenTitle` on macOS. Native traffic lights have a reserved 104px left area before the community switcher only in the -macOS desktop runtime. This inset does not move the centered tabs. Web gets no +macOS desktop runtime. Web gets no inset or imitation window controls. Other platforms retain their native decorations. Drag regions are limited to the header background; controls remain clickable. On macOS, double-clicking that @@ -128,11 +134,12 @@ access in a built app. ## Messages -The Messages feature owns separate rounded sidebar, conversation and contributed -panel cards, with 16px gutters. A single right panel fills the conversation height; +The host owns the persistent rounded sidebar card; Messages owns conversation +and contributed panel cards, with 16px gutters. A single right panel fills the conversation height; the right-column grid splits available height evenly between a local link card and the launched companion card. Below 1000px -the right column overlays the conversation; below 650px it fills the page area. +the right column overlays the conversation; it also overlays when the content +pane is too narrow for two columns. Below 650px it fills the page area. Each card contains its own overflow, keeping the composer and close control visible. Channels opts into the reusable companion prop and owns both cards, including a companion-only view without a selected channel or relay. Settings and legacy diff --git a/src/app/App.tsx b/src/app/App.tsx index f8d6379a1..d77e4038a 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -1,6 +1,8 @@ +// FOUNDATION: Startup, navigation, contributed pages, and built-in Settings. +import { ChannelSidebar } from "../features/channel-navigation/ChannelSidebar"; +import { ChannelNavigationProvider } from "../features/channel-navigation/ChannelNavigationState"; import { ToastProvider } from "../shared/design-system/ui/Toast"; import { Button } from "../shared/design-system/ui/Button"; -// FOUNDATION: Startup, navigation, contributed pages, and built-in Settings. import { AgentWakeNotice } from "../features/agents/AgentWakeNotice"; import { useEffect, useSyncExternalStore } from "react"; import { registerAppShortcuts } from "./shortcuts"; @@ -55,76 +57,91 @@ export function App({ services }: { services: AppServices }) { const pageOwnsCompanion = !!route.page?.companion; return ( - - } - onCommunitySelect={(id) => { - services.communities.select(id); - select("buzz.channels/channels"); - }} - communities={services.communities} - searchServices={services} - launchers={ - - } - companion={pageOwnsCompanion ? undefined : companion} - pages={startup === "ready" ? route.pages : []} - selected={route.selected} - onSelect={select} - tone={presentation.tone} - workspace={startup === "ready" && route.page?.layout === "workspace"} - > - - {startup === "recovery" && !settings ? ( - - ) : route.failure || route.state.status === "failed" ? ( -
-

This destination couldn’t open

-

- {route.failure === "denied" - ? "This target needs its original account and an already joined community." - : "The destination is unavailable or isn’t supported yet. Your target has been kept for retry."} -

- - -
- ) : settings ? ( - - void services.navigation.open({ - version: 1, - kind: "settings", - section, - }) - } - /> - ) : route.waiting || startup === "loading" ? ( -

Opening destination…

- ) : route.page ? ( - - ) : null} -
+ + ( + page.pluginId === "buzz.sessions", + )} + > +
{pageNavigation}
+
+ )} + navigationControls={ + + } + onCommunitySelect={(id) => { + services.communities.select(id); + select("buzz.channels/channels"); + }} + communities={services.communities} + searchServices={services} + launchers={ + + } + companion={pageOwnsCompanion ? undefined : companion} + pages={startup === "ready" ? route.pages : []} + selected={route.selected} + onSelect={select} + tone={presentation.tone} + workspace={startup === "ready" && route.page?.layout === "workspace"} + > + + {startup === "recovery" && !settings ? ( + + ) : route.failure || route.state.status === "failed" ? ( +
+

This destination couldn’t open

+

+ {route.failure === "denied" + ? "This target needs its original account and an already joined community." + : "The destination is unavailable or isn’t supported yet. Your target has been kept for retry."} +

+ + +
+ ) : settings ? ( + + void services.navigation.open({ + version: 1, + kind: "settings", + section, + }) + } + /> + ) : route.waiting || startup === "loading" ? ( +

Opening destination…

+ ) : route.page ? ( + + ) : null} +
+
); } diff --git a/src/app/shell/AppShell.tsx b/src/app/shell/AppShell.tsx index e0a17471b..b68c4f8f9 100644 --- a/src/app/shell/AppShell.tsx +++ b/src/app/shell/AppShell.tsx @@ -1,5 +1,6 @@ import { NavigationItem } from "../../shared/design-system/ui/NavigationItem"; import type { ReactNode } from "react"; +import { Panel } from "../../shared/design-system/ui/Panel"; import { isTauri } from "@tauri-apps/api/core"; import type { RegisteredPage } from "../../features/pages/service"; import type { Communities } from "../../features/communities/service"; @@ -19,6 +20,7 @@ export function AppShell({ onSelect, tone, workspace, + sidebar, communities, searchServices, navigationControls, @@ -32,6 +34,7 @@ export function AppShell({ onSelect: (key: string) => void; tone: string; workspace?: boolean; + sidebar?: (pages: ReactNode) => ReactNode; communities: Communities; searchServices?: SearchServices; navigationControls?: ReactNode; @@ -41,6 +44,28 @@ export function AppShell({ children: ReactNode; }) { const fillsWorkspace = workspace || selected === "settings"; + const pageNavigation = ( + + ); return (
{navigationControls}
-
+
-
- -
+
+ {sidebar ? ( + sidebar(pageNavigation) + ) : ( +
+ +
{pageNavigation}
+
+
+ )} +
+
- {children} +
+ {children} +
-
- -
+ + +
); diff --git a/src/app/shell/presentation.ts b/src/app/shell/presentation.ts index 3c46675f1..38ab44b23 100644 --- a/src/app/shell/presentation.ts +++ b/src/app/shell/presentation.ts @@ -1,7 +1,10 @@ import { BrowserIcon, ChatsCircleIcon, + FolderSimpleIcon, GearIcon, + LightningIcon, + RobotIcon, } from "../../shared/design-system/icons/index"; import type { RegisteredPage } from "../../features/pages/service"; @@ -35,7 +38,14 @@ export function pagePresentation(page: RegisteredPage) { if (page.id === "channels") return shellPresentation.channels; return { label: page.title, - icon: BrowserIcon, + icon: + page.key === "buzz.projects/projects" + ? FolderSimpleIcon + : page.key === "buzz.agents/agents" + ? RobotIcon + : page.key === "buzz.workflows/workflows" + ? LightningIcon + : BrowserIcon, tone: page.layout === "workspace" ? "lime" : "sky", }; } diff --git a/src/bundled/channels/Channels.module.css b/src/bundled/channels/Channels.module.css index a14a07b92..e1f1058dc 100644 --- a/src/bundled/channels/Channels.module.css +++ b/src/bundled/channels/Channels.module.css @@ -4,6 +4,7 @@ .root { height: 100%; min-height: 0; + container-type: inline-size; color: var(--text-standard); font-size: var(--text-body-sm); line-height: var(--text-body-sm--line-height); @@ -14,18 +15,13 @@ } .board { display: grid; - grid-template-columns: - minmax(124px, min(var(--channel-sidebar-width, 260px), calc(100% - 336px))) - var(--space-panel-gap) - minmax(0, 1fr); + grid-template-columns: minmax(0, 1fr); height: 100%; position: relative; overflow: hidden; } .withPanel { - grid-template-columns: - minmax(124px, min(var(--channel-sidebar-width, 260px), calc(100% - 652px))) - var(--space-panel-gap) minmax(320px, 1.1fr) minmax(316px, 1fr); + grid-template-columns: minmax(320px, 1.1fr) minmax(316px, 1fr); } .sidebar, .conversation { @@ -40,6 +36,10 @@ --sidebar-padding: var(--space-2); --channel-row-radius: calc(var(--radius-panel) - var(--sidebar-padding)); padding: var(--sidebar-padding); + color: var(--text-standard); + font-size: var(--text-body-sm); + line-height: var(--text-body-sm--line-height); + letter-spacing: var(--text-body-sm--letter-spacing); } .channelListFrame { position: relative; @@ -197,12 +197,13 @@ html[data-keyboard-navigation] position: relative; z-index: 4; align-self: stretch; - width: 16px; + width: var(--space-4); + flex-shrink: 0; justify-self: center; height: 100%; padding: 0; border: 0; - margin: 0; + margin: 0 calc((var(--space-panel-gap) - var(--space-4)) / 2); background: transparent; cursor: col-resize; touch-action: none; @@ -266,7 +267,7 @@ html[data-keyboard-navigation] letter-spacing: var(--text-body-sm--letter-spacing); } .panelStack { - grid-column: 4; + grid-column: 2; min-width: 0; min-height: 0; display: grid; @@ -326,33 +327,41 @@ html[data-keyboard-navigation] @media (max-width: 1000px) { .board, .withPanel { - grid-template-columns: - minmax( - 124px, - min(var(--channel-sidebar-width, 260px), calc(100% - 336px)) - ) - var(--space-panel-gap) - minmax(0, 1fr); + grid-template-columns: minmax(0, 1fr); } .withPanel .panelStack { position: absolute; - grid-column: 3; + grid-column: 1; inset: 0 0 0 auto; width: min(420px, 100%); z-index: 2; box-shadow: var(--elevation-dock); margin-left: 0; } - .withPanel .sidebarResizeHandle { - z-index: 1; +} +/* A widened persistent sidebar can leave less room than viewport width implies. */ +@container (max-width: 651px) { + .withPanel { + grid-template-columns: minmax(0, 1fr); + } + .withPanel .panelStack { + position: absolute; + grid-column: 1; + inset: 0 0 0 auto; + width: min(420px, 100%); + z-index: 2; + box-shadow: var(--elevation-dock); + margin-left: 0; } } @media (max-width: 650px) { .board, .withPanel { - grid-template-columns: 124px 4px minmax(0, 1fr); + grid-template-columns: minmax(0, 1fr); } .sidebarResizeHandle { + width: 4px; + margin: 0; visibility: hidden; pointer-events: none; } diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index 378f630f1..2ec6c1389 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -1,19 +1,15 @@ +import { useChannelNavigation } from "../../features/channel-navigation/ChannelNavigationState"; +import { newSessionParent } from "../../features/channel-navigation/routes"; import { personalGroups } from "../../features/channel-templates/setup"; import type { TemplateProviders } from "../../features/channel-templates/provider"; import { OwnedContribution } from "../../plugins/OwnedContribution"; import { ChannelCanvasDialog } from "./ChannelCanvasDialog"; import { Select } from "../../shared/design-system/ui/Select"; -import { ToastNotice } from "../../shared/design-system/ui/Toast"; import { NewMessage } from "../../features/direct-messages/NewMessage"; import { Panel } from "../../shared/design-system/ui/Panel"; import { PanelHeader } from "../../shared/design-system/ui/PanelHeader"; import { Button } from "../../shared/design-system/ui/Button"; import { IconButton } from "../../shared/design-system/ui/IconButton"; -import { - ContextMenuRoot, - MenuItem, - MenuPopup, -} from "../../shared/design-system/ui/Menu"; import { useChannelPanels } from "./useChannelPanels"; import { ChannelSettingsPanel } from "./ChannelSettingsPanel"; import type { PageNavigation } from "../../features/navigation/service"; @@ -22,7 +18,6 @@ import { buzzLinkTarget, isBuzzLink, } from "../../features/navigation/buzz-links"; -import { ChannelSidebarItem } from "./ChannelSidebarItem"; import { SessionMessageTarget } from "../../features/sessions/SessionMessageTarget"; import { NewSessionComposer } from "../../features/sessions/NewSessionComposer"; import { @@ -31,7 +26,6 @@ import { SessionHeading, } from "../../features/sessions/SessionPresentation"; import { UnreadOptions } from "./UnreadBadge"; -import { SidebarUnread } from "./SidebarUnread"; import type { ConversationExtensions } from "../../features/conversation/contracts"; import { memo, @@ -42,15 +36,12 @@ import { useRef, useState, useSyncExternalStore, - type CSSProperties, type ReactNode, } from "react"; import { - CaretRightIcon, DotsThreeIcon, PlugIcon, ChatCircleIcon, - PlusIcon, } from "../../shared/design-system/icons/index"; import { channelIcon } from "../../features/channels/channel-icon"; import type { RelayData } from "../../features/relay/service"; @@ -72,28 +63,11 @@ import { MessageComposer } from "../../features/messages/MessageComposer"; import { ChannelTimeline } from "../../features/messages/ChannelTimeline"; import { ThreadPanel } from "../../features/messages/ThreadPanel"; import { MediaReviewViewer } from "../../features/messages/MediaReviewViewer"; -import type { - Attachment, - ChannelSummary, -} from "../../features/relay/contracts"; +import type { Attachment } from "../../features/relay/contracts"; import { readView, writeView } from "../../shared/view-state"; import { useChannelLabels } from "./useChannelLabels"; import { useComposerSent } from "./useComposerSent"; -import { useChannelRowMenu } from "./useChannelRowMenu"; import { useSidebarPreferences } from "./useSidebarPreferences"; -import { isChannelSectionKey, sidebarSections } from "./sidebar-sections"; -import { useHiddenDms } from "./useHiddenDms"; -import { SidebarSectionIcon } from "./SidebarSectionIcon"; -import { - CreateChannelDialog, - type CreateChannelInput, -} from "./CreateChannelDialog"; -import { - CHANNEL_SIDEBAR_DEFAULT_WIDTH, - CHANNEL_SIDEBAR_MAX_WIDTH, - CHANNEL_SIDEBAR_MIN_WIDTH, - useSidebarView, -} from "./useSidebarView"; import styles from "./Channels.module.css"; export function ChannelsPage({ @@ -207,28 +181,10 @@ function ChannelWorkspace({ panels: Panels; sessionsEnabled: boolean; }) { - const [localNewMessage, setLocalNewMessage] = useState(false); - const [preparingDm, setPreparingDm] = useState<{ - existing: Set; - members: Set; - }>(); - const composingMessage = navigator - ? navigation?.target.kind === "page" && - navigation.target.route?.params === "new-message" - : localNewMessage; - useEffect(() => { - if (!composingMessage) setPreparingDm(undefined); - }, [composingMessage]); + const composingMessage = + navigation?.target.kind === "page" && + navigation.target.route?.params === "new-message"; const list = useChannelList(queries.channels); - const workingIds = useSyncExternalStore( - queries.agentActivity.subscribeWorking, - queries.agentActivity.workingSnapshot, - queries.agentActivity.workingSnapshot, - ); - const workingChannels = useMemo( - () => new Set(JSON.parse(workingIds)), - [workingIds], - ); const preferences = useSidebarPreferences(queries.sidebarPreferences); const kitState = useSyncExternalStore( queries.channelKit.subscribe, @@ -251,9 +207,7 @@ function ChannelWorkspace({ ? groupEntry.record.value : undefined; const [canvasOpen, setCanvasOpen] = useState(false); - const [initialGroup, setInitialGroup] = useState(""); const [kitError, setKitError] = useState(""); - const hiddenDms = useHiddenDms(scope, queries, list); useEffect(() => { void queries.emoji.ensure(); }, [queries]); @@ -268,31 +222,16 @@ function ChannelWorkspace({ const [selected, setSelected] = useState(() => readView(scope, "selected-channel", undefined), ); - const [createChannelOpen, setCreateChannelOpen] = useState(false); - const createChannelTrigger = useRef(null); - const startingSession = useRef(false); - const pendingChannelCreation = useSyncExternalStore( - queries.channelCreation.subscribe, - queries.channelCreation.snapshot, - queries.channelCreation.snapshot, - ); - const [draftParent, setDraftParent] = useState(); - const [draftParents, setDraftParents] = useState(() => { - const saved = readView(scope, "sessions:channel-drafts", []); - return Array.isArray(saved) - ? saved.filter((id): id is string => typeof id === "string") - : []; - }); - const updateDraftParents = useCallback( - (update: (previous: string[]) => string[]) => { - setDraftParents((previous) => { - const next = update(previous); - writeView(scope, "sessions:channel-drafts", next); - return next; - }); - }, - [scope], - ); + const handoff = useChannelNavigation(); + const draftParent = + navigation?.target.kind === "page" + ? newSessionParent(navigation.target.route?.params) + : undefined; + const clearPreparingDm = handoff?.clearPreparingDm; + useEffect(() => { + if (!composingMessage) clearPreparingDm?.(); + return () => clearPreparingDm?.(); + }, [composingMessage, clearPreparingDm]); const navigate = useCallback( (id: string) => { setSelected(id); @@ -317,8 +256,6 @@ function ChannelWorkspace({ }>(); const select = useCallback( (id: string) => { - setLocalNewMessage(false); - setDraftParent(undefined); navigate(id); setThread(undefined); }, @@ -326,54 +263,16 @@ function ChannelWorkspace({ ); const threadTrigger = useRef(null); const [sent, setSent] = useState<{ channelId: string; id: string }>(); - const sidebar = useSidebarView( - scope, - list.status === "ready" && preferences.status !== "loading", - ); - const { channels, profiles: dmProfiles } = useChannelLabels( + const { channels } = useChannelLabels( list.channels, queries.profiles, queries.names, ); - const childSessions = useRef(new Map()); - // While composing, suppress a newly opened DM until the first send confirms. - // Leaving this route resumes the normal signed-discovery sidebar. - const sidebarChannels = channels.filter( - (channel) => - !composingMessage || - !preparingDm || - preparingDm.existing.has(channel.id) || - channel.channelType !== "dm" || - channel.members?.length !== preparingDm.members.size || - !channel.members.every((member) => preparingDm.members.has(member)), - ); - const childrenByParent = useMemo(() => { - const children = new Map(); - for (const item of channels) { - if (item.channelType !== "session" || !item.parentChannelId) continue; - const siblings = children.get(item.parentChannelId) ?? []; - siblings.push(item); - children.set(item.parentChannelId, siblings); - } - for (const [parent, siblings] of children) { - siblings.sort( - (a, b) => - (b.updatedAt ?? 0) - (a.updatedAt ?? 0) || a.id.localeCompare(b.id), - ); - const previous = childSessions.current.get(parent); - if ( - previous?.length === siblings.length && - siblings.every((child, index) => child === previous[index]) - ) - children.set(parent, previous); - } - childSessions.current = children; - return children; - }, [channels]); const requestedChannel = - navigation?.target.kind === "conversation" + draftParent ?? + (navigation?.target.kind === "conversation" ? navigation.target.channelId - : undefined; + : undefined); const [resolved, setResolved] = useState<{ request: PageNavigation; available: boolean; @@ -424,6 +323,13 @@ function ChannelWorkspace({ : undefined)) : (channels.find((channel) => channel.id === selected) ?? channels.find((item) => item.channelType !== "session")); + // Sidebar routing can update the same mounted page. Keep its saved default + // aligned with the resolved conversation, not only page-local clicks. + useEffect(() => { + if (navigation?.target.kind !== "conversation" || !current) return; + setSelected(current.id); + writeView(scope, "selected-channel", current.id); + }, [navigation?.target, current, scope]); const CurrentChannelIcon = channelIcon(current); useEffect(() => { if (navigation?.signal.aborted) return; @@ -482,16 +388,31 @@ function ChannelWorkspace({ setSettings(undefined); settingsTrigger.current?.focus({ preventScroll: true }); }; + const canStartSession = + !!current && + sessionsEnabled && + !current.readOnly && + !current.archived && + current.channelType !== "dm" && + current.channelType !== "session"; const drafting = - !!draftParent && draftParent === currentId && !requestedMessage; + canStartSession && + !!draftParent && + draftParent === currentId && + !requestedMessage; useEffect(() => { - if ( - drafting && - navigation?.target.kind === "conversation" && - !navigation.target.messageId - ) + if (drafting && navigation?.target.kind === "page") navigation.complete({ status: "opened" }); - }, [drafting, navigation]); + if (draftParent && (!sessionsEnabled || (current && !canStartSession))) + navigation?.complete({ status: "failed", reason: "unavailable" }); + }, [ + drafting, + navigation, + draftParent, + sessionsEnabled, + current, + canStartSession, + ]); const flatSession = current?.channelType === "session"; const onComposerSend = useComposerSent( currentId, @@ -588,20 +509,29 @@ function ChannelWorkspace({ opening.current = next; setOpened(next); }, []); - const startSession = useCallback( - (parentId: string) => { - setSettings(undefined); - select(parentId); - setDraftParent(parentId); - sidebar.toggle(`session-children:${parentId}`, true); - updateDraftParents((previous) => - previous.includes(parentId) ? previous : [...previous, parentId], - ); + useLayoutEffect(() => { + if (draftParent || composingMessage || requestedMessage) { setThread(undefined); open(undefined); - }, - [select, sidebar.toggle, updateDraftParents, open], - ); + } + const activity = handoff?.activityThread.current; + if ( + activity && + activity.channelId === requestedChannel && + activity.rootId === requestedThread + ) { + threadTrigger.current = activity.trigger; + handoff.activityThread.current = undefined; + } + }, [ + draftParent, + composingMessage, + requestedMessage, + requestedChannel, + requestedThread, + open, + handoff?.activityThread, + ]); const panel = opened && opened.channelId === current?.id && @@ -619,15 +549,6 @@ function ChannelWorkspace({ mounted.current = false; }; }, []); - const createChannel = useCallback( - async (input: CreateChannelInput) => { - const id = await queries.channelCreation.create(input); - if (!mounted.current) return; - select(id); - sidebar.toggle("channels", true); - }, - [queries, select, sidebar.toggle], - ); useEffect(() => { if (opened && !panel) open(undefined); }, [opened, panel, open]); @@ -742,34 +663,6 @@ function ChannelWorkspace({ ) setMediaReview(undefined); }, [mediaReview, list]); - const openActivityThread = useCallback( - (channelId: string, rootId: string) => { - setSettings(undefined); - threadTrigger.current = - sidebar.list.current?.querySelector( - `[data-channel-id="${CSS.escape(channelId)}"]`, - ) ?? null; - if (navigator && viewer) { - setThread(undefined); - void navigator.open({ - version: 1, - kind: "conversation", - channelId, - messageId: rootId, - threadRootId: rootId, - scope: { - viewer, - communityOrigin: scope.slice(0, -(viewer.length + 1)), - }, - }); - } else { - navigate(channelId); - setThread({ channelId, messageId: rootId }); - } - open(undefined); - }, - [navigate, navigator, viewer, scope, sidebar.list, open], - ); const closeThread = () => { setReplyRequest(undefined); if (showingThread?.navigation && current) select(current.id); @@ -916,331 +809,10 @@ function ChannelWorkspace({ const drawer = useChannelPanels(panels, drawerContext, () => setSettings(undefined), ); - const sections = sidebarSections( - sidebarChannels, - personal - ? { - sections: personal.groups.map((g, order) => ({ - id: g.id, - name: g.name, - order, - })), - assignments: personal.assignments, - starred: preferences.data?.starred ?? [], - } - : preferences.data, - hiddenDms.hiddenIds, - ); - // Compose actual items here; menu availability is their count, not the policy - // of any one action. Sibling actions keep their own eligibility checks. - const rowActions = (channel: ChannelSummary) => { - const actions: ReactNode[] = []; - if ( - sessionsEnabled && - channel.channelType !== "dm" && - channel.channelType !== "session" && - !channel.archived - ) { - actions.push( - { - startingSession.current = true; - startSession(channel.id); - }} - > - New session - , - ); - } - return actions; - }; - const { - rowMenu, - open: openMenu, - close: closeRowMenu, - } = useChannelRowMenu(sections, rowActions); - const openRowMenu = useCallback( - (channel: ChannelSummary, sectionKey: string, anchor?: HTMLElement) => { - startingSession.current = false; - openMenu(channel, sectionKey, anchor); - }, - [openMenu], - ); return (
- -
- {kitState.status === "error" && ( -

- {kitState.error}{" "} - -

- )} - {kitError &&

{kitError}

} - {pendingChannelCreation && ( -
- - {queries.channelCreation.partialChannel() && ( - <> - - - - )} -
- )} - - {sections.map((section) => { - const showsCreateChannel = isChannelSectionKey(section.key); - return ( -
- {/* biome-ignore lint/a11y/noStaticElementInteractions: native summary supports pointer and keyboard activation. */} - { - event.preventDefault(); - sidebar.toggle( - section.key, - sidebar.collapsed.includes(section.key), - ); - }} - > - - {section.rows.map((channel) => { - const sessions = childrenByParent.get(channel.id); - const selected = - current?.id === channel.id || - sessions?.some((child) => child.id === current?.id) - ? current?.id - : undefined; - const actions = rowActions(channel); - const menuEnabled = actions.length > 0; - const menuOpen = - menuEnabled && - rowMenu?.channelId === channel.id && - rowMenu.sectionKey === section.key; - const channelItem = ( - - ); - if (!menuEnabled) return channelItem; - return ( - { - if (open) openRowMenu(channel, section.key); - else if (menuOpen) closeRowMenu(); - }} - > - {channelItem} - - startingSession.current - ? (document - .getElementById("new-session-prompt") - ?.querySelector( - '[role="textbox"]', - ) ?? false) - : (sidebar.list.current?.querySelector( - `[data-channel-id="${CSS.escape(channel.id)}"]`, - ) ?? false) - } - > - {actions} - - - ); - })} -
- ); - })} - {list.status === "loading" && !list.channels.length && ( -

Loading your channels…

- )} - {list.status === "error" && ( -

- {list.error} -

- )} - {list.status === "ready" && !channels.length && ( -

No channels yet.

- )} -
- {preferences.status === "error" ? ( - - - - ) : preferences.status !== "ready" ? ( -

- {preferences.status === "loading" - ? "Loading saved groups and stars…" - : "Saved groups and stars aren’t supported by this host yet."} -

- ) : null} -
-
- {current && !current.readOnly && canvasOpen && ( )} - {/* biome-ignore lint/a11y/noStaticElementInteractions: file-drop fallback; the composer also provides a keyboard-accessible picker. */}
- setPreparingDm((previous) => ({ - existing: - previous?.existing ?? - new Set( - queries.channels - .list() - .channels.map((channel) => channel.id), - ), - members: new Set([viewer, ...pubkeys]), - })) - } + onPreparing={(pubkeys) => handoff?.prepareDm(pubkeys)} onStarted={(channelId, id) => { - setPreparingDm(undefined); + handoff?.clearPreparingDm(); setSent({ channelId, id }); select(channelId); }} @@ -1295,13 +852,17 @@ function ChannelWorkspace({ scope={scope} parent={current} onStarted={(id) => { - updateDraftParents((previous) => + handoff?.updateDraftParents((previous) => previous.filter((parent) => parent !== current.id), ); select(id); }} /> + ) : draftParent ? ( +

+ Checking session parent access… +

) : ( <> {current?.channelType === "session" ? ( @@ -1617,96 +1178,6 @@ function ChannelWorkspace({ ); } -function ChannelSidebarResizeHandle({ - width, - setWidth, -}: { - width: number; - setWidth(width: number): void; -}) { - const handle = useRef(null); - const [renderedWidth, setRenderedWidth] = useState(width); - const drag = useRef< - { pointerId: number; startX: number; width: number } | undefined - >(undefined); - const resize = useRef(setWidth); - resize.current = setWidth; - const move = useCallback((event: PointerEvent) => { - if (drag.current?.pointerId !== event.pointerId) return; - event.preventDefault(); - resize.current(drag.current.width + event.clientX - drag.current.startX); - }, []); - const finish = useCallback(() => { - window.removeEventListener("pointermove", move); - window.removeEventListener("pointerup", finish); - window.removeEventListener("pointercancel", finish); - drag.current = undefined; - delete document.documentElement.dataset.sidebarResizing; - document.documentElement.style.removeProperty("cursor"); - document.body.style.removeProperty("user-select"); - }, [move]); - const measure = useCallback(() => { - const sidebar = handle.current?.previousElementSibling; - if (!(sidebar instanceof HTMLElement)) return; - const next = Math.round(sidebar.getBoundingClientRect().width); - setRenderedWidth((current) => (current === next ? current : next)); - }, []); - useLayoutEffect(measure); - useEffect(() => { - window.addEventListener("resize", measure); - return () => window.removeEventListener("resize", measure); - }, [measure]); - useEffect(() => () => finish(), [finish]); - - return ( -
setWidth(CHANNEL_SIDEBAR_DEFAULT_WIDTH)} - onKeyDown={(event) => { - const step = event.shiftKey ? 48 : 16; - const sidebar = event.currentTarget.previousElementSibling; - const currentWidth = - sidebar instanceof HTMLElement - ? sidebar.getBoundingClientRect().width - : renderedWidth; - if (event.key === "ArrowLeft") setWidth(currentWidth - step); - else if (event.key === "ArrowRight") setWidth(currentWidth + step); - else if (event.key === "Home") setWidth(CHANNEL_SIDEBAR_MIN_WIDTH); - else if (event.key === "End") setWidth(CHANNEL_SIDEBAR_MAX_WIDTH); - else return; - event.preventDefault(); - }} - onPointerDown={(event) => { - if (event.button !== 0) return; - event.preventDefault(); - const sidebar = event.currentTarget.previousElementSibling; - drag.current = { - pointerId: event.pointerId, - startX: event.clientX, - width: - sidebar instanceof HTMLElement - ? sidebar.getBoundingClientRect().width - : renderedWidth, - }; - window.addEventListener("pointermove", move); - window.addEventListener("pointerup", finish, { once: true }); - window.addEventListener("pointercancel", finish, { once: true }); - document.documentElement.dataset.sidebarResizing = "true"; - document.documentElement.style.cursor = "col-resize"; - document.body.style.userSelect = "none"; - }} - /> - ); -} - export function mediaReviewForDestination< T extends { channelId: string; entryId?: string | undefined }, >( diff --git a/src/bundled/channels/index.tsx b/src/bundled/channels/index.tsx index 92091f2f6..ba37a314f 100644 --- a/src/bundled/channels/index.tsx +++ b/src/bundled/channels/index.tsx @@ -1,3 +1,4 @@ +import { isChannelRoute } from "../../features/channel-navigation/routes"; import type { PluginModule } from "../../plugins/api"; import { ChannelsPage } from "./ChannelsPage"; import { ChannelSetupSettings } from "./ChannelSetupSettings"; @@ -34,7 +35,7 @@ export const apply: PluginModule["apply"] = (ctx) => { layout: "workspace", companion: true, handlesNavigation: true, - route: { version: 1, validate: (params) => params === "new-message" }, + route: { version: 1, validate: isChannelRoute }, component: ({ companion, navigation }) => ( { + cleanup(); + localStorage.clear(); +}); +function fixture() { + let ids = ["existing"]; + // Only the roster read is consumed by this UI handoff provider. + const session = () => + ({ + channels: { list: () => ({ channels: ids.map((id) => ({ id })) }) }, + }) as unknown as RelaySession; + let snapshot: RelaySnapshot = { + status: "ready", + scope: "community:viewer", + viewer: "viewer", + generation: 1, + session: session(), + }; + const listeners = new Set<() => void>(); + const relay = { + snapshot: () => snapshot, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + } as RelayData; + return { + relay, + roster: (next: string[]) => { + ids = next; + }, + replace() { + snapshot = { + ...snapshot, + generation: snapshot.generation + 1, + session: session(), + }; + for (const listener of listeners) listener(); + }, + }; +} +function mount(relay: RelayData) { + return renderHook( + () => { + const value = useChannelNavigation(); + if (!value) throw new Error("Missing channel navigation provider"); + return value; + }, + { + wrapper: ({ children }: { children: ReactNode }) => ( + + + {children} + + + ), + }, + ); +} +it("captures the roster before DM preparation and preserves it across participant changes", () => { + const h = fixture(); + const view = mount(h.relay); + act(() => { + view.result.current?.prepareDm(["peer"]); + h.roster(["existing", "new"]); + }); + expect(view.result.current.preparingDm?.existing).toEqual( + new Set(["existing"]), + ); + expect(view.result.current.preparingDm?.members).toEqual( + new Set(["viewer", "peer"]), + ); + act(() => view.result.current?.prepareDm(["other"])); + expect(view.result.current.preparingDm?.existing).toEqual( + new Set(["existing"]), + ); + expect(view.result.current.preparingDm?.members).toEqual( + new Set(["viewer", "other"]), + ); + act(() => view.result.current?.clearPreparingDm()); + expect(view.result.current.preparingDm).toBeUndefined(); +}); +it("resets transient handoffs and rejects retired callbacks on session replacement", () => { + const h = fixture(); + const view = mount(h.relay); + act(() => { + view.result.current?.prepareDm(["peer"]); + view.result.current?.updateDraftParents(() => ["parent"]); + }); + const retired = view.result.current; + retired.activityThread.current = { + channelId: "channel", + rootId: "root", + trigger: null, + }; + act(() => h.replace()); + expect(view.result.current.preparingDm).toBeUndefined(); + expect(view.result.current.activityThread.current).toBeUndefined(); + expect(view.result.current.draftParents).toEqual(["parent"]); + act(() => { + retired.prepareDm(["late"]); + retired.updateDraftParents(() => ["late"]); + }); + expect(view.result.current.preparingDm).toBeUndefined(); + expect(view.result.current.draftParents).toEqual(["parent"]); +}); diff --git a/src/features/channel-navigation/ChannelNavigationState.tsx b/src/features/channel-navigation/ChannelNavigationState.tsx new file mode 100644 index 000000000..ebef4438b --- /dev/null +++ b/src/features/channel-navigation/ChannelNavigationState.tsx @@ -0,0 +1,117 @@ +import { + createContext, + useCallback, + useContext, + useMemo, + useState, + useRef, + type RefObject, + type ReactNode, +} from "react"; +import { useRelayConnection } from "../relay/react"; +import type { RelayData } from "../relay/service"; +import type { RelaySession } from "../relay/session"; +import { readView, writeView } from "../../shared/view-state"; + +type PreparingDm = { existing: Set; members: Set }; +type State = { + session: RelaySession; + scope: string; + draftParents: string[]; + preparingDm: PreparingDm | undefined; +}; +type ActivityThread = { + channelId: string; + rootId: string; + trigger: HTMLElement | null; +}; +type Handoff = State & { + activityThread: RefObject; + updateDraftParents(update: (previous: string[]) => string[]): void; + prepareDm(members: readonly string[]): void; + clearPreparingDm(): void; +}; +const ChannelNavigationContext = createContext(undefined); +export const useChannelNavigation = () => useContext(ChannelNavigationContext); + +// UI intent shared by the persistent sidebar and the visible conversation page. +// Session replacement resets this state, not the independent page subtree. +export function ChannelNavigationProvider({ + relay, + children, +}: { + relay: RelayData; + children: ReactNode; +}) { + const connection = useRelayConnection(relay); + const scope = connection.scope ?? "disconnected"; + const activityThread = useRef(undefined); + const [state, setState] = useState(() => + restore(connection.session, scope), + ); + if (state.session !== connection.session || state.scope !== scope) { + activityThread.current = undefined; + setState(restore(connection.session, scope)); + } + const update = useCallback( + (change: (previous: State) => State) => { + // Retired page/dialog completions cannot mutate a replacement session. + if (relay.snapshot().session !== connection.session) return; + setState((previous) => + previous.session === connection.session ? change(previous) : previous, + ); + }, + [relay, connection.session], + ); + const clearPreparingDm = useCallback(() => { + update((previous) => + previous.preparingDm ? { ...previous, preparingDm: undefined } : previous, + ); + }, [update]); + const value = useMemo( + () => ({ + ...state, + activityThread, + updateDraftParents(change) { + update((previous) => { + const draftParents = change(previous.draftParents); + writeView(previous.scope, "sessions:channel-drafts", draftParents); + return { ...previous, draftParents }; + }); + }, + prepareDm(members) { + // Capture before the caller starts opening the DM, not in a deferred updater. + const existing = new Set( + connection.session.channels + .list() + .channels.map((channel) => channel.id), + ); + update((previous) => ({ + ...previous, + preparingDm: { + existing: previous.preparingDm?.existing ?? existing, + members: new Set([connection.viewer, ...members]), + }, + })); + }, + clearPreparingDm, + }), + [state, connection.session, connection.viewer, update, clearPreparingDm], + ); + return ( + + {children} + + ); +} +function restore(session: RelaySession, scope: string): State { + const saved = readView(scope, "sessions:channel-drafts", []); + return { + session, + scope, + preparingDm: undefined, + draftParents: Array.isArray(saved) + ? saved.filter((id): id is string => typeof id === "string") + : [], + }; +} diff --git a/src/features/channel-navigation/ChannelSidebar.tsx b/src/features/channel-navigation/ChannelSidebar.tsx new file mode 100644 index 000000000..dd2808f4d --- /dev/null +++ b/src/features/channel-navigation/ChannelSidebar.tsx @@ -0,0 +1,637 @@ +import { + Component, + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, + type ReactNode, +} from "react"; +import { personalGroups } from "../channel-templates/setup"; +import type { TemplateProviders } from "../channel-templates/provider"; +import type { RelayData } from "../relay/service"; +import type { RelaySession } from "../relay/session"; +import { useChannelList, useRelayConnection } from "../relay/react"; +import type { Navigation } from "../navigation/controller"; +import type { OpenTarget } from "../navigation/targets"; +import { Panel } from "../../shared/design-system/ui/Panel"; +import { Button } from "../../shared/design-system/ui/Button"; +import { + ContextMenuRoot, + MenuItem, + MenuPopup, +} from "../../shared/design-system/ui/Menu"; +import type { ChannelSummary } from "../relay/contracts"; +import { useChannelRowMenu } from "../../bundled/channels/useChannelRowMenu"; +import { IconButton } from "../../shared/design-system/ui/IconButton"; +import { ToastNotice } from "../../shared/design-system/ui/Toast"; +import { + CaretRightIcon, + PlusIcon, +} from "../../shared/design-system/icons/index"; +import { ChannelSidebarItem } from "../../bundled/channels/ChannelSidebarItem"; +import { SidebarUnread } from "../../bundled/channels/SidebarUnread"; +import { SidebarSectionIcon } from "../../bundled/channels/SidebarSectionIcon"; +import { useChannelLabels } from "../../bundled/channels/useChannelLabels"; +import { useHiddenDms } from "../../bundled/channels/useHiddenDms"; +import { useSidebarPreferences } from "../../bundled/channels/useSidebarPreferences"; +import { useSidebarView } from "../../bundled/channels/useSidebarView"; +import { + sidebarSections, + isChannelSectionKey, +} from "../../bundled/channels/sidebar-sections"; +import { + CreateChannelDialog, + type CreateChannelInput, +} from "../../bundled/channels/CreateChannelDialog"; +import { writeView } from "../../shared/view-state"; +import { useChannelNavigation } from "./ChannelNavigationState"; +import { newSessionParent } from "./routes"; +import { ChannelSidebarResizeHandle } from "./ChannelSidebarResizeHandle"; +import styles from "../../bundled/channels/Channels.module.css"; + +type Props = { + relay: RelayData; + navigator: Navigation; + providers: TemplateProviders; + target: OpenTarget; + sessionsEnabled: boolean; + children: ReactNode; +}; +export function ChannelSidebar(props: Props) { + const connection = useRelayConnection(props.relay); + return ( + + {connection.status === "ready" ? ( + + ) : ( +
+ +
+ {props.children} +

+ {connection.status === "connecting" + ? "Connecting to your relay…" + : (connection.error ?? "Choose a community to see channels.")} +

+ {connection.status === "error" && ( + + )} +
+
+
+ )} +
+ ); +} +class SidebarBoundary extends Component< + { children: ReactNode; fallback: ReactNode }, + { failed: boolean } +> { + state = { failed: false }; + static getDerivedStateFromError() { + return { failed: true }; + } + render() { + return this.state.failed ? ( +
+ +
+ {this.props.fallback} +

Channels couldn’t open.

+ +
+
+
+ ) : ( + this.props.children + ); + } +} +function ReadySidebar({ + relay, + navigator, + providers, + target, + sessionsEnabled, + children, + queries, + scope, + viewer, +}: Props & { + queries: RelaySession; + scope: string; + viewer?: string | undefined; +}) { + const list = useChannelList(queries.channels); + const preferences = useSidebarPreferences(queries.sidebarPreferences); + const kitState = useSyncExternalStore( + queries.channelKit.subscribe, + queries.channelKit.snapshot, + ); + const personal = personalGroups(kitState.entries)?.record.value; + const groups = personal?.type === "groups" ? personal : undefined; + const hiddenDms = useHiddenDms(scope, queries, list); + const sidebar = useSidebarView( + scope, + list.status === "ready" && preferences.status !== "loading", + ); + const { channels, profiles: dmProfiles } = useChannelLabels( + list.channels, + queries.profiles, + queries.names, + ); + const workingIds = useSyncExternalStore( + queries.agentActivity.subscribeWorking, + queries.agentActivity.workingSnapshot, + queries.agentActivity.workingSnapshot, + ); + const workingChannels = useMemo( + () => new Set(JSON.parse(workingIds)), + [workingIds], + ); + const handoff = useChannelNavigation(); + const draftParents = handoff?.draftParents ?? []; + const draftParent = + target.kind === "page" && + target.pluginId === "buzz.channels" && + sessionsEnabled + ? newSessionParent(target.route?.params) + : undefined; + const composingMessage = + target.kind === "page" && + target.pluginId === "buzz.channels" && + target.route?.params === "new-message"; + const preparingDm = composingMessage ? handoff?.preparingDm : undefined; + const sidebarChannels = channels.filter( + (channel) => + !preparingDm || + preparingDm.existing.has(channel.id) || + channel.channelType !== "dm" || + channel.members?.length !== preparingDm.members.size || + !channel.members.every((member) => preparingDm.members.has(member)), + ); + const current = channels.find( + (channel) => + channel.id === + (target.kind === "conversation" ? target.channelId : draftParent), + ); + const childSessions = useRef(new Map()); + const childrenByParent = useMemo(() => { + const children = new Map(); + for (const item of channels) { + if (item.channelType !== "session" || !item.parentChannelId) continue; + const siblings = children.get(item.parentChannelId) ?? []; + siblings.push(item); + children.set(item.parentChannelId, siblings); + } + for (const [parent, siblings] of children) { + siblings.sort( + (a, b) => + (b.updatedAt ?? 0) - (a.updatedAt ?? 0) || a.id.localeCompare(b.id), + ); + const previous = childSessions.current.get(parent); + if ( + previous?.length === siblings.length && + siblings.every((child, index) => child === previous[index]) + ) + children.set(parent, previous); + } + childSessions.current = children; + return children; + }, [channels]); + + const [createChannelOpen, setCreateChannelOpen] = useState(false); + const createChannelTrigger = useRef(null); + const startingSession = useRef(false); + const [initialGroup, setInitialGroup] = useState(""); + const [kitError, setKitError] = useState(""); + const pendingChannelCreation = useSyncExternalStore( + queries.channelCreation.subscribe, + queries.channelCreation.snapshot, + queries.channelCreation.snapshot, + ); + useEffect(() => { + if (list.status === "ready") { + queries.channelKit.ensure(); + void queries.unread.ensure(); + } + }, [queries, list.status]); + const mounted = useRef(false); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + const select = useCallback( + (id: string) => { + if (!viewer || relay.snapshot().session !== queries) return; + writeView(scope, "selected-channel", id); + void navigator.open({ + version: 1, + kind: "conversation", + channelId: id, + scope: { + viewer, + communityOrigin: scope.slice(0, -(viewer.length + 1)), + }, + }); + }, + [navigator, relay, queries, scope, viewer], + ); + const startSession = (parentId: string) => { + const parent = channels.find((channel) => channel.id === parentId); + if ( + !viewer || + relay.snapshot().session !== queries || + !sessionsEnabled || + !parent || + parent.readOnly || + parent.archived || + parent.channelType === "dm" || + parent.channelType === "session" + ) + return; + handoff?.updateDraftParents((previous) => + previous.includes(parentId) ? previous : [...previous, parentId], + ); + sidebar.toggle(`session-children:${parentId}`, true); + void navigator.open({ + version: 1, + kind: "page", + pluginId: "buzz.channels", + pageId: "channels", + route: { version: 1, params: { kind: "new-session", parentId } }, + scope: { viewer, communityOrigin: scope.slice(0, -(viewer.length + 1)) }, + }); + }; + const openActivityThread = (channelId: string, rootId: string) => { + if (!viewer || relay.snapshot().session !== queries) return; + if (handoff) + handoff.activityThread.current = { + channelId, + rootId, + trigger: + sidebar.list.current?.querySelector( + `[data-channel-id="${CSS.escape(channelId)}"]`, + ) ?? null, + }; + void navigator.open({ + version: 1, + kind: "conversation", + channelId, + messageId: rootId, + threadRootId: rootId, + scope: { viewer, communityOrigin: scope.slice(0, -(viewer.length + 1)) }, + }); + }; + const createChannel = async (input: CreateChannelInput) => { + const id = await queries.channelCreation.create(input); + if (!mounted.current || relay.snapshot().session !== queries) return; + select(id); + sidebar.toggle("channels", true); + }; + const sections = sidebarSections( + sidebarChannels, + groups + ? { + sections: groups.groups.map((group, order) => ({ + id: group.id, + name: group.name, + order, + })), + assignments: groups.assignments, + starred: preferences.data?.starred ?? [], + } + : preferences.data, + hiddenDms.hiddenIds, + ); + // Compose actual items here; menu availability is their count, not the policy + // of any one action. Sibling actions keep their own eligibility checks. + const rowActions = (channel: ChannelSummary) => { + const actions: ReactNode[] = []; + if ( + sessionsEnabled && + channel.channelType !== "dm" && + channel.channelType !== "session" && + !channel.archived + ) { + actions.push( + { + startingSession.current = true; + startSession(channel.id); + }} + > + New session + , + ); + } + return actions; + }; + const { + rowMenu, + open: openMenu, + close: closeRowMenu, + } = useChannelRowMenu(sections, rowActions); + const openRowMenu = useCallback( + (channel: ChannelSummary, sectionKey: string, anchor?: HTMLElement) => { + startingSession.current = false; + openMenu(channel, sectionKey, anchor); + }, + [openMenu], + ); + return ( + <> +
+ +
+ {children} + {kitState.status === "error" && ( +

+ {kitState.error}{" "} + +

+ )} + {kitError &&

{kitError}

} + {pendingChannelCreation && ( +
+ + {queries.channelCreation.partialChannel() && ( + <> + + + + )} +
+ )} + + {sections.map((section) => { + const showsCreateChannel = isChannelSectionKey(section.key); + return ( +
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: native summary supports pointer and keyboard activation. */} + { + event.preventDefault(); + sidebar.toggle( + section.key, + sidebar.collapsed.includes(section.key), + ); + }} + > + + {section.rows.map((channel) => { + const sessions = childrenByParent.get(channel.id); + const selected = + current?.id === channel.id || + sessions?.some((child) => child.id === current?.id) + ? current?.id + : undefined; + const actions = rowActions(channel); + const menuEnabled = actions.length > 0; + const menuOpen = + menuEnabled && + rowMenu?.channelId === channel.id && + rowMenu.sectionKey === section.key; + const channelItem = ( + + ); + if (!menuEnabled) return channelItem; + return ( + { + if (open) openRowMenu(channel, section.key); + else if (menuOpen) closeRowMenu(); + }} + > + {channelItem} + + startingSession.current + ? (document + .getElementById("new-session-prompt") + ?.querySelector( + '[role="textbox"]', + ) ?? false) + : (sidebar.list.current?.querySelector( + `[data-channel-id="${CSS.escape(channel.id)}"]`, + ) ?? false) + } + > + {actions} + + + ); + })} +
+ ); + })} + {list.status === "loading" && !list.channels.length && ( +

Loading your channels…

+ )} + {list.status === "error" && ( +

+ {list.error} +

+ )} + {list.status === "ready" && !channels.length && ( +

No channels yet.

+ )} +
+ {preferences.status === "error" ? ( + + + + ) : preferences.status !== "ready" ? ( +

+ {preferences.status === "loading" + ? "Loading saved groups and stars…" + : "Saved groups and stars aren’t supported by this host yet."} +

+ ) : null} +
+
+ +
+ + + ); +} diff --git a/src/features/channel-navigation/ChannelSidebarResizeHandle.tsx b/src/features/channel-navigation/ChannelSidebarResizeHandle.tsx new file mode 100644 index 000000000..8d8c415b5 --- /dev/null +++ b/src/features/channel-navigation/ChannelSidebarResizeHandle.tsx @@ -0,0 +1,103 @@ +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { + CHANNEL_SIDEBAR_DEFAULT_WIDTH, + CHANNEL_SIDEBAR_MIN_WIDTH, + CHANNEL_SIDEBAR_MAX_WIDTH, +} from "../../bundled/channels/useSidebarView"; +import styles from "../../bundled/channels/Channels.module.css"; + +export function ChannelSidebarResizeHandle({ + width, + setWidth, +}: { + width: number; + setWidth(width: number): void; +}) { + const handle = useRef(null); + const [renderedWidth, setRenderedWidth] = useState(width); + const drag = useRef< + { pointerId: number; startX: number; width: number } | undefined + >(undefined); + const resize = useRef(setWidth); + resize.current = setWidth; + const move = useCallback((event: PointerEvent) => { + if (drag.current?.pointerId !== event.pointerId) return; + event.preventDefault(); + resize.current(drag.current.width + event.clientX - drag.current.startX); + }, []); + const finish = useCallback(() => { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", finish); + window.removeEventListener("pointercancel", finish); + drag.current = undefined; + delete document.documentElement.dataset.sidebarResizing; + document.documentElement.style.removeProperty("cursor"); + document.body.style.removeProperty("user-select"); + }, [move]); + const measure = useCallback(() => { + const sidebar = handle.current?.previousElementSibling; + if (!(sidebar instanceof HTMLElement)) return; + const next = Math.round(sidebar.getBoundingClientRect().width); + setRenderedWidth((current) => (current === next ? current : next)); + }, []); + useLayoutEffect(measure); + useEffect(() => { + window.addEventListener("resize", measure); + return () => window.removeEventListener("resize", measure); + }, [measure]); + useEffect(() => () => finish(), [finish]); + + return ( +
setWidth(CHANNEL_SIDEBAR_DEFAULT_WIDTH)} + onKeyDown={(event) => { + const step = event.shiftKey ? 48 : 16; + const sidebar = event.currentTarget.previousElementSibling; + const currentWidth = + sidebar instanceof HTMLElement + ? sidebar.getBoundingClientRect().width + : renderedWidth; + if (event.key === "ArrowLeft") setWidth(currentWidth - step); + else if (event.key === "ArrowRight") setWidth(currentWidth + step); + else if (event.key === "Home") setWidth(CHANNEL_SIDEBAR_MIN_WIDTH); + else if (event.key === "End") setWidth(CHANNEL_SIDEBAR_MAX_WIDTH); + else return; + event.preventDefault(); + }} + onPointerDown={(event) => { + if (event.button !== 0) return; + event.preventDefault(); + const sidebar = event.currentTarget.previousElementSibling; + drag.current = { + pointerId: event.pointerId, + startX: event.clientX, + width: + sidebar instanceof HTMLElement + ? sidebar.getBoundingClientRect().width + : renderedWidth, + }; + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", finish, { once: true }); + window.addEventListener("pointercancel", finish, { once: true }); + document.documentElement.dataset.sidebarResizing = "true"; + document.documentElement.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + }} + /> + ); +} diff --git a/src/features/channel-navigation/routes.test.ts b/src/features/channel-navigation/routes.test.ts new file mode 100644 index 000000000..b41f2c866 --- /dev/null +++ b/src/features/channel-navigation/routes.test.ts @@ -0,0 +1,25 @@ +import { expect, it } from "vitest"; +import { isChannelRoute, newSessionParent } from "./routes"; + +it("accepts existing new-message and explicit parent-only new-session routes", () => { + expect(isChannelRoute("new-message")).toBe(true); + const params = { kind: "new-session", parentId: "parent" }; + expect(isChannelRoute(params)).toBe(true); + expect(newSessionParent(params)).toBe("parent"); +}); +it("rejects missing parents, foreign route kinds and draft payloads", () => { + for (const params of [ + null, + [], + "new-session", + {}, + { kind: "new-session" }, + { kind: "new-session", parentId: " " }, + { kind: "new-session", parentId: 1 }, + { kind: "other", parentId: "parent" }, + { kind: "new-session", parentId: "parent", text: "private draft" }, + ]) { + expect(isChannelRoute(params)).toBe(false); + expect(newSessionParent(params)).toBeUndefined(); + } +}); diff --git a/src/features/channel-navigation/routes.ts b/src/features/channel-navigation/routes.ts new file mode 100644 index 000000000..f76ec11ef --- /dev/null +++ b/src/features/channel-navigation/routes.ts @@ -0,0 +1,21 @@ +import type { JsonValue } from "../navigation/targets"; + +export function newSessionParent( + params: JsonValue | undefined, +): string | undefined { + if (!params || typeof params !== "object" || Array.isArray(params)) return; + const value = params as { readonly [key: string]: JsonValue }; + if ( + value.kind !== "new-session" || + typeof value.parentId !== "string" || + !value.parentId.trim() + ) + return; + if (Object.keys(params).some((key) => key !== "kind" && key !== "parentId")) + return; + return value.parentId; +} + +export function isChannelRoute(params: JsonValue) { + return params === "new-message" || newSessionParent(params) !== undefined; +} diff --git a/src/shared/design-system/icons/index.ts b/src/shared/design-system/icons/index.ts index 7713a5725..a1393eab7 100644 --- a/src/shared/design-system/icons/index.ts +++ b/src/shared/design-system/icons/index.ts @@ -103,6 +103,8 @@ import { HouseIcon as PhosphorHouseIcon } from "@phosphor-icons/react/dist/csr/H export const HouseIcon = defineIcon("phosphor", PhosphorHouseIcon); import { KeyboardIcon as PhosphorKeyboardIcon } from "@phosphor-icons/react/dist/csr/Keyboard"; export const KeyboardIcon = defineIcon("phosphor", PhosphorKeyboardIcon); +import { LightningIcon as PhosphorLightningIcon } from "@phosphor-icons/react/dist/csr/Lightning"; +export const LightningIcon = defineIcon("phosphor", PhosphorLightningIcon); import { LinkIcon as PhosphorLinkIcon } from "@phosphor-icons/react/dist/csr/Link"; export const LinkIcon = defineIcon("phosphor", PhosphorLinkIcon); import { LockIcon as PhosphorLockIcon } from "@phosphor-icons/react/dist/csr/Lock"; diff --git a/src/shared/styles/globals.css b/src/shared/styles/globals.css index 15b7e26a2..4e058e43f 100644 --- a/src/shared/styles/globals.css +++ b/src/shared/styles/globals.css @@ -184,8 +184,7 @@ } .shell-header { - @apply grid h-14 shrink-0 items-center gap-3 px-4; - grid-template-columns: minmax(0, 1fr) minmax(0, auto) minmax(0, 1fr); + @apply flex h-14 shrink-0 items-center justify-between gap-3 px-4; } .shell-communities { @apply flex min-w-0 items-center justify-start; @@ -197,22 +196,45 @@ padding-left: var(--space-16); } .shell-pages { - @apply glass-primary flex min-w-0 items-center gap-1 overflow-x-auto rounded-full p-1; - max-width: min(calc(100vw - 400px), 640px); + @apply flex min-h-0 min-w-0 flex-col gap-1 overflow-y-auto; + max-height: 40dvh; + } + .shell-page-navigation-slot { + @apply flex min-h-0 shrink-0 flex-col mb-4; + max-height: 40%; + } + .shell-body { + @apply flex min-h-0 min-w-0 flex-1 px-2 pb-2 sm:px-4 sm:pb-4; + } + .shell-sidebar, + .shell-sidebar-default { + @apply flex min-h-0 shrink-0; + width: 260px; + max-width: max(124px, calc(100% - 336px)); + } + .shell-sidebar-default { + margin-right: var(--space-panel-gap); + } + .shell-sidebar > *, + .shell-sidebar-default > * { + @apply min-w-0 flex-1; } .shell-actions { @apply flex items-center justify-end gap-2; } @media (max-width: 700px) { .shell-header { - @apply h-auto gap-x-2 gap-y-1 px-2 pb-2 pt-2; - grid-template-columns: minmax(0, 1fr) auto; + @apply gap-2 px-2; + } + } + @media (max-width: 650px) { + .shell-sidebar, + .shell-sidebar-default { + width: 124px; + max-width: 124px; } - .shell-pages { - grid-column: 1 / -1; - grid-row: 2; - justify-self: center; - max-width: 100%; + .shell-sidebar-default { + margin-right: var(--space-1); } } .muted { diff --git a/tests/browser/appearance.spec.mjs b/tests/browser/appearance.spec.mjs index fcbfd34d5..109a73955 100644 --- a/tests/browser/appearance.spec.mjs +++ b/tests/browser/appearance.spec.mjs @@ -27,8 +27,10 @@ async function expectMode(page, mode) { /linear-gradient/, ); await expect( - page.getByRole("navigation", { name: "Pages", exact: true }), - ).toHaveCSS("backdrop-filter", /blur\(/); + page + .getByRole("complementary", { name: "Channel sidebar", exact: true }) + .getByRole("navigation", { name: "Pages", exact: true }), + ).toHaveCSS("flex-direction", "column"); } test("Appearance changes and restores both modes, shared keyboard controls, dialogs and narrow layout", async ({ @@ -371,7 +373,7 @@ test("shared type and spacing reach the real message timeline", async ({ sidebar.getByRole("button", { name: "Alpha", exact: true }), ).toHaveCSS("font-size", "14px"); await expect( - sidebar.locator("..").getByRole("separator", { + page.getByRole("separator", { name: "Resize channel sidebar", }), ).toHaveCSS("width", "16px"); diff --git a/tests/browser/layout.spec.mjs b/tests/browser/layout.spec.mjs index 454d41893..d6e908d6e 100644 --- a/tests/browser/layout.spec.mjs +++ b/tests/browser/layout.spec.mjs @@ -68,10 +68,36 @@ async function link(page, app, target) { ).toBeVisible(); } async function shellFits(page, width) { - const tabs = await box( + const pages = await box( page.getByRole("navigation", { name: "Pages", exact: true }), ); - near(tabs.x + tabs.width / 2, width / 2); + const sidebar = await box( + page.getByRole("complementary", { name: "Channel sidebar" }), + ); + + expect(pages.x).toBeGreaterThanOrEqual(sidebar.x); + expect(pages.x + pages.width).toBeLessThanOrEqual(sidebar.x + sidebar.width); + const channels = page.getByRole("navigation", { + name: "Subscribed channels", + }); + if (await channels.count()) { + const channelBox = await box(channels); + expect(pages.y + pages.height).toBeLessThanOrEqual(channelBox.y); + expect(channelBox.height).toBeGreaterThan(40); + } + // At short heights the page list scrolls independently, not over the channels. + const pageList = page.getByRole("navigation", { name: "Pages", exact: true }); + const lastPage = pageList.getByRole("button").last(); + await lastPage.scrollIntoViewIfNeeded(); + await expect(lastPage).toBeInViewport(); + await pageList.evaluate((element) => { + element.scrollTop = 0; + }); + await expect( + page + .locator(".shell-header") + .getByRole("navigation", { name: "Pages", exact: true }), + ).toHaveCount(0); const actions = await box(page.locator(".shell-actions")); const communities = await box( page.getByRole("navigation", { name: "Communities", exact: true }), @@ -82,8 +108,7 @@ async function shellFits(page, width) { .poll(() => page.evaluate(() => document.documentElement.scrollWidth)) .toBe(width); if (width > 700) { - expect(communities.x + communities.width).toBeLessThan(tabs.x); - expect(tabs.x + tabs.width).toBeLessThan(actions.x); + expect(communities.x + communities.width).toBeLessThan(sidebar.x); } } @@ -131,7 +156,7 @@ scroll( }, ); -test("bento surfaces, centered tabs, real link panel and compact community navigation", async ({ +test("bento surfaces, sidebar pages, real link panel and compact community navigation", async ({ page, app, }, testInfo) => { @@ -252,8 +277,9 @@ test("bento surfaces, centered tabs, real link panel and compact community navig await link(page, app, "https://github.com/block/buzz/pull/3"); await expect(button(page, "Close channel panel")).toBeInViewport(); const narrow = await box(panel(page)); - near(narrow.x, 8 + rail.width); - near(narrow.width, 374 - rail.width); + const narrowConversation = await box(conversation); + near(narrow.x, narrowConversation.x); + near(narrow.width, narrowConversation.width); await button(page, "Close channel panel").click(); await expect(composer).toBeInViewport(); await button(page, "Search Buzz").click(); @@ -316,17 +342,18 @@ test("narrow link panels begin after the rendered sidebar", async ({ near(conversation.x - sidebar.x - sidebar.width, 8); near(dock.x, conversation.x); expect(dock.x).toBeGreaterThanOrEqual(sidebar.x + sidebar.width); + // Separate stacking contexts: assert actual hit testing, not unrelated z-index numbers. + const close = button(page, "Close channel panel"); expect( - await page - .getByRole("separator", { name: "Resize channel sidebar" }) - .evaluate((element) => Number(getComputedStyle(element).zIndex)), - ).toBeLessThan( - await panel(page).evaluate((element) => - Number( - getComputedStyle(element.closest('[class*="_panelStack_"]')).zIndex, - ), - ), - ); + await close.evaluate((element) => { + const r = element.getBoundingClientRect(); + return element.contains( + document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2), + ); + }), + ).toBe(true); + await close.click(); + await expect(panel(page)).toHaveCount(0); }); readingTest( @@ -761,3 +788,73 @@ test("Projects stays centered and page navigation survives plugin re-enable orde await search.getByRole("option", { name: "Projects", exact: true }).click(); await expect(title).toBeVisible(); }); + +// Real App navigation must retire page-local targets, without closing the +// independently owned companion intent. Each return stays in Channels. +const sidebarActions = test.extend({ + productionBroker: true, + readState: true, + threadUnread: true, + largeSidebar: true, + historyCounts: { alpha: 20, beta: 1 }, +}); +sidebarActions( + "sidebar activity and compose routes retire local link panels, not companion intent", + async ({ page, app }) => { + await open(page, app); + const bestie = page.getByRole("complementary", { + name: "Bestie", + exact: true, + }); + await button(page, "Bestie").click(); + await expect(bestie).toBeVisible(); + const alpha = page.locator('button[data-channel-id="alpha"]'); + for (const [index, action] of [ + "activity", + "message", + "session", + ].entries()) { + await link(page, app, `https://github.com/block/buzz/pull/${20 + index}`); + if (action === "activity") { + await alpha.hover(); + await page + .getByRole("dialog", { name: "Activity in Alpha" }) + .getByRole("button", { name: /Open unread thread from/ }) + .first() + .click(); + await expect( + page.getByRole("complementary", { name: "Thread", exact: true }), + ).toBeVisible(); + } else if (action === "message") { + await page.locator("summary", { hasText: /^DMs$/ }).hover(); + await button(page, "New message").click(); + await expect( + page.getByRole("region", { name: "New message", exact: true }), + ).toBeVisible(); + } else { + await alpha.hover(); + await alpha.click({ button: "right" }); + await page + .getByRole("menuitem", { name: "New session", exact: true }) + .click(); + await expect( + page.getByRole("region", { + name: "New session in Alpha", + exact: true, + }), + ).toBeVisible(); + } + await expect(panel(page)).toHaveCount(0); + await expect(button(page, "Bestie")).toHaveAttribute( + "aria-expanded", + "true", + ); + await button(page, "Go back").click(); + await expect( + page.getByRole("textbox", { name: "Message #Alpha", exact: true }), + ).toBeVisible(); + await expect(panel(page)).toHaveCount(0); + await expect(bestie).toBeVisible(); + } + }, +); diff --git a/tests/browser/navigation-boundaries.spec.mjs b/tests/browser/navigation-boundaries.spec.mjs index 2373398a5..c906ff4f6 100644 --- a/tests/browser/navigation-boundaries.spec.mjs +++ b/tests/browser/navigation-boundaries.spec.mjs @@ -23,7 +23,9 @@ test("cold destination waits for its enabled provider to activate", async ({ await expect .poll(() => page.evaluate(() => window.delayFixture.started)) .toBe(true); - await expect(page.getByRole("status")).toHaveText("Opening destination…"); + await expect(page.getByRole("main").getByRole("status")).toHaveText( + "Opening destination…", + ); await expect .poll(() => page.evaluate(() => window.fixtureNavigation?.snapshot().status), diff --git a/tests/browser/navigation-groups.spec.mjs b/tests/browser/navigation-groups.spec.mjs index 343958460..a083d168d 100644 --- a/tests/browser/navigation-groups.spec.mjs +++ b/tests/browser/navigation-groups.spec.mjs @@ -33,7 +33,7 @@ test("Projects → Messages keeps saved groups, selected channel, and scroll on .getByRole("button", { name: "Projects", exact: true }) .first() .click(); - await expect(sidebar).toHaveCount(0); + await expect(sidebar).toBeVisible(); let release; const held = new Promise((resolve) => { release = resolve; @@ -91,7 +91,7 @@ test("Projects → Messages keeps saved groups, selected channel, and scroll on ).toEqual([]); expect( decodes, - "Remount must reuse the engine snapshot, not fetch/decode again", + "Page return must reuse the engine snapshot, not fetch/decode again", ).toBe(0); } finally { release(); diff --git a/tests/browser/navigation-scroll-intent.spec.mjs b/tests/browser/navigation-scroll-intent.spec.mjs index 14626a16d..e891b7fea 100644 --- a/tests/browser/navigation-scroll-intent.spec.mjs +++ b/tests/browser/navigation-scroll-intent.spec.mjs @@ -28,8 +28,11 @@ for (const action of [ element.scrollTop = 900; element.dispatchEvent(new Event("scroll")); }); - await button(page, "Projects").first().click(); - await expect(sidebar).toHaveCount(0); + await button(page, "Personal space").click(); + await expect(button(page, "Personal space")).toHaveAttribute( + "aria-current", + "true", + ); let release; const held = new Promise((resolve) => { release = resolve; @@ -45,7 +48,7 @@ for (const action of [ ); try { await page.reload(); - await button(page, "Messages").first().click(); + await button(page, "Switch to Primary").click(); await expect .poll(() => sidebar.locator("[data-channel-id]").count()) .toBeGreaterThan(100); diff --git a/tests/browser/navigation-sidebar.spec.mjs b/tests/browser/navigation-sidebar.spec.mjs index 2b28d4b4d..2e0e7c21d 100644 --- a/tests/browser/navigation-sidebar.spec.mjs +++ b/tests/browser/navigation-sidebar.spec.mjs @@ -39,12 +39,19 @@ test("channel sidebar resizes from the full gutter and persists", async ({ if (!(content instanceof HTMLElement) || !(row instanceof HTMLElement)) throw new Error("Channel sidebar geometry is unavailable"); const panelStyle = getComputedStyle(panel); - const boardStyle = getComputedStyle(panel.parentElement); + const handle = document.querySelector( + '[aria-label="Resize channel sidebar"]', + ); + if (!(handle instanceof HTMLElement)) + throw new Error("Missing sidebar gutter"); const contentStyle = getComputedStyle(content); const rowStyle = getComputedStyle(row); return { panelRadius: Number.parseFloat(panelStyle.borderTopLeftRadius), - panelGap: Number.parseFloat(boardStyle.gridTemplateColumns.split(" ")[1]), + panelGap: + Number.parseFloat(getComputedStyle(handle).width) + + Number.parseFloat(getComputedStyle(handle).marginLeft) + + Number.parseFloat(getComputedStyle(handle).marginRight), padding: [ contentStyle.paddingTop, contentStyle.paddingRight, @@ -225,11 +232,26 @@ sessionSidebar( ), ).not.toBe("rgba(0, 0, 0, 0)"); + await button(page, "Projects").first().click(); await parent.click({ button: "right" }); await page.getByRole("menuitem", { name: "New session" }).click(); const draft = page.getByRole("button", { name: /New session draft in/ }); await expect(draft).toBeVisible(); expect(await x(label(draft))).toBeCloseTo(await x(label(regular)), 0); + const parentName = await label(parent).innerText(); + await expect( + page.getByRole("region", { + name: `New session in ${parentName}`, + exact: true, + }), + ).toBeVisible(); + await expect( + page.getByRole("textbox", { name: "Message this session", exact: true }), + ).toBeVisible(); + await button(page, "Go back").click(); + await expect( + page.getByRole("heading", { name: "Projects", exact: true }), + ).toBeVisible(); await child.click(); await expectPhosphor( @@ -251,6 +273,10 @@ sessionSidebar( await child.hover(); await expect(child).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)"); + await button(page, "Projects").first().click(); + await expect( + page.getByRole("heading", { name: "Projects", exact: true }), + ).toBeVisible(); const channels = page .getByRole("navigation", { name: "Subscribed channels" }) .locator("details") @@ -335,6 +361,10 @@ sessionSidebar( .click(); await expect(dialog).toHaveCount(0); await expect(create).toBeFocused(); + await expect( + page.getByRole("heading", { name: "Projects", exact: true }), + ).toBeVisible(); + await button(page, "Messages").first().click(); await page .getByRole("article", { name: "Conversation" }) .hover({ position: { x: 20, y: 20 } }); @@ -421,7 +451,13 @@ test("channel navigation preserves sidebar DOM, group state and scroll", async ( ); }); -for (const destination of ["Projects", "Settings", "Back/Forward"]) { +for (const destination of [ + "Projects", + "Agents", + "Workflows", + "Settings", + "Back/Forward", +]) { test(`sidebar state survives Messages → ${destination} → Messages`, async ({ page, app, @@ -433,6 +469,7 @@ for (const destination of ["Projects", "Settings", "Back/Forward"]) { const group = sidebar .locator("details") .filter({ has: page.locator("summary", { hasText: /^Channels$/ }) }); + const node = await sidebar.elementHandle(); const leave = async () => { if (destination === "Settings") { await button(page, "Your profile").click(); @@ -450,7 +487,11 @@ for (const destination of ["Projects", "Settings", "Back/Forward"]) { .first() .click(); } - await expect(sidebar).toHaveCount(0); + await expect(sidebar).toBeVisible(); + expect(await node.evaluate((element) => element.isConnected)).toBe(true); + await expect( + page.getByRole("region", { name: "Channel message history" }), + ).toHaveCount(0); await button( page, destination === "Back/Forward" ? "Go back" : "Messages", @@ -472,7 +513,11 @@ for (const destination of ["Projects", "Settings", "Back/Forward"]) { .toBeCloseTo(scroll, 0); if (destination === "Back/Forward") { await button(page, "Go forward").click(); - await expect(sidebar).toHaveCount(0); + await expect(sidebar).toBeVisible(); + expect(await node.evaluate((element) => element.isConnected)).toBe(true); + await expect( + page.getByRole("region", { name: "Channel message history" }), + ).toHaveCount(0); await button(page, "Go back").click(); await expect(group).not.toHaveAttribute("open"); await expect @@ -544,13 +589,17 @@ test("legacy filters are ignored and invalid saved sidebar fields fall back", as const sidebar = page.getByRole("navigation", { name: "Subscribed channels" }); const group = sidebar.locator("details").first(); await group.locator("summary").click(); - await button(page, "Projects").first().click(); + await button(page, "Switch to Secondary").click(); + await expect(button(page, "Switch to Secondary")).toHaveAttribute( + "aria-current", + "true", + ); await page.evaluate(() => { const key = Object.keys(localStorage).find((key) => key.includes('"channel-sidebar"'), ); if (!key) - throw new Error("Sidebar state was not saved on leaving Messages"); + throw new Error("Sidebar state was not saved on leaving its session"); localStorage.setItem( key, JSON.stringify({ @@ -561,7 +610,11 @@ test("legacy filters are ignored and invalid saved sidebar fields fall back", as }), ); }); - await button(page, "Messages").first().click(); + await button(page, "Switch to Primary").click(); + await expect(button(page, "Switch to Primary")).toHaveAttribute( + "aria-current", + "true", + ); await expect( page.getByRole("searchbox", { name: "Search channels" }), ).toHaveCount(0); @@ -605,3 +658,29 @@ test("rail loads relay-owned image icons for inactive communities without acquir expect(infoRequests).toHaveLength(2); expect(app.report.sessions).toEqual(["primary"]); }); + +// Same-page navigation must update the remembered selection without a remount. +test("Messages reselects the latest sidebar channel and keyboard page buttons focus main", async ({ + page, + app, +}) => { + await open(page, app); + await button(page, "Beta").click(); + const composer = page.getByRole("textbox", { + name: "Message #Beta", + exact: true, + }); + await expect(composer).toBeVisible(); + const messages = page + .getByRole("navigation", { name: "Pages" }) + .getByRole("button", { name: "Messages", exact: true }); + await messages.focus(); + await messages.press("Enter"); + await expect(composer).toBeVisible(); + await expect(messages).toHaveAttribute("aria-current", "page"); + await expect(page.locator('button[data-channel-id="beta"]')).toHaveAttribute( + "aria-current", + "page", + ); + await expect(page.locator("#main-content")).toBeFocused(); +}); diff --git a/tests/browser/new-message.spec.mjs b/tests/browser/new-message.spec.mjs index afc334102..7fc93aaca 100644 --- a/tests/browser/new-message.spec.mjs +++ b/tests/browser/new-message.spec.mjs @@ -247,7 +247,7 @@ async function open(page, app) { await page.goto(app.origin); await page .getByRole("navigation", { name: "Pages" }) - .getByRole("button", { name: "Messages", exact: true }) + .getByRole("button", { name: "Projects", exact: true }) .click(); const header = page.locator("summary", { hasText: "DMs" }); await header.hover(); diff --git a/tests/browser/thread-unread.spec.mjs b/tests/browser/thread-unread.spec.mjs index 2516d723c..49c1b0d3d 100644 --- a/tests/browser/thread-unread.spec.mjs +++ b/tests/browser/thread-unread.spec.mjs @@ -251,3 +251,76 @@ test("thread buttons show observed unread independently, clear only after readin ), ).toEqual([]); }); + +// Browser focus plus actual controller/React wiring: a same-target open must not +// leave the timeline's trigger installed merely because the history entry is reused. +test("same-thread sidebar activity replaces timeline focus return", async ({ + page, + app, +}) => { + await open(page, app); + const root = app.histories + .get("primary/alpha") + .find((row) => row.content === "Thread root 0"); + const alpha = page.locator('button[data-channel-id="alpha"]'); + await expect( + alpha.getByRole("img", { name: /unread threads?/ }), + ).toBeVisible(); + let release; + const held = new Promise((resolve) => { + release = resolve; + }); + let requested = false; + await page.route("**/api/relay/**/query", async (route) => { + if ( + route + .request() + .postDataJSON() + .some((filter) => filter.depth_limit) + ) { + requested = true; + await held; + } + await route.continue().catch(() => {}); + }); + try { + const trigger = page + .locator(`[data-channel-timeline] [data-message-id="${root.id}"]`) + .getByRole("button", { name: /^View thread:/ }); + await expect(trigger).toHaveCSS("pointer-events", "auto"); + await trigger.click(); + await expect.poll(() => requested).toBe(true); + const before = await page.evaluate(() => { + const { entry, attempt } = window.fixtureNavigation.snapshot(); + return { entry: entry.id, attempt: attempt.id }; + }); + await alpha.hover(); + const activity = page.getByRole("dialog", { name: "Activity in Alpha" }); + await activity + .getByRole("button", { name: /Open unread thread from.*Broadcast reply/ }) + .click(); + await expect + .poll(() => + page.evaluate((before) => { + const { entry, attempt } = window.fixtureNavigation.snapshot(); + return { + sameEntry: entry.id === before.entry, + freshAttempt: attempt.id !== before.attempt, + }; + }, before), + ) + .toEqual({ sameEntry: true, freshAttempt: true }); + release(); + await expect + .poll(() => + page.evaluate(() => window.fixtureNavigation.snapshot().status), + ) + .toBe("opened"); + await page + .getByRole("button", { name: "Close thread", exact: true }) + .click(); + await expect(alpha).toBeFocused(); + } finally { + release(); + } +}); diff --git a/tests/fixtures/channel-workspace.tsx b/tests/fixtures/channel-workspace.tsx new file mode 100644 index 000000000..b96fcaa82 --- /dev/null +++ b/tests/fixtures/channel-workspace.tsx @@ -0,0 +1,71 @@ +// Use the production sidebar/page ownership with real navigation in standalone fixtures. +import { useLayoutEffect, useState, useSyncExternalStore } from "react"; +import { ChannelNavigationProvider } from "../../src/features/channel-navigation/ChannelNavigationState"; +import { ChannelSidebar } from "../../src/features/channel-navigation/ChannelSidebar"; +import { ChannelsPage } from "../../src/bundled/channels/ChannelsPage"; +import type { RelayData } from "../../src/features/relay/service"; +import type { Panels } from "../../src/features/panels/service"; +import type { PagesReader } from "../../src/features/pages/service"; +import type { TemplateProviders } from "../../src/features/channel-templates/provider"; +import type { + PageNavigation, + provideNavigation, +} from "../../src/features/navigation/service"; + +export function ChannelWorkspaceFixture({ + relay, + panels, + pages, + providers, + host, +}: { + relay: RelayData; + panels: Panels; + pages: PagesReader; + providers: TemplateProviders; + host: ReturnType; +}) { + const state = useSyncExternalStore( + host.navigation.subscribe, + host.navigation.snapshot, + ); + const [presentation, setPresentation] = useState<{ + attempt: typeof state.attempt; + request: PageNavigation; + }>(); + useLayoutEffect(() => { + const { request, dispose } = host.request(state.attempt, { + valid: () => true, + subscribe: () => () => {}, + }); + setPresentation({ attempt: state.attempt, request }); + return dispose; + }, [host, state.attempt]); + const navigation = + presentation?.attempt === state.attempt ? presentation.request : undefined; + return ( + +
+ + {null} + +
+ +
+
+
+ ); +} diff --git a/tests/fixtures/profiles.tsx b/tests/fixtures/profiles.tsx index 10b8060f3..296ff8044 100644 --- a/tests/fixtures/profiles.tsx +++ b/tests/fixtures/profiles.tsx @@ -13,11 +13,10 @@ import { } from "../../src/features/panels/service"; import { PagesService } from "../../src/features/pages/service"; import { TemplateProvidersService } from "../../src/features/channel-templates/provider"; -import { ChannelsPage } from "../../src/bundled/channels/ChannelsPage"; +import { ChannelWorkspaceFixture } from "./channel-workspace"; import { createRelaySession } from "../../src/features/relay/session"; import { createAgentControl } from "../../src/features/agents/control"; -import { createNavigationController } from "../../src/features/navigation/controller"; -import { createMemoryHistory } from "../../src/features/navigation/history"; +import { provideNavigation } from "../../src/features/navigation/service"; import type { RelayData, RelaySnapshot, @@ -170,9 +169,7 @@ const relay: RelayData = { }; const context = new Context(); context.provide("relay", relay); -const navigationHost = createNavigationController(createMemoryHistory()); -context.provide("navigation", navigationHost.navigation); -context.effect(() => () => navigationHost.dispose()); +const navigationHost = provideNavigation(context, undefined); const native = controlFixture(); native.agent.pubkey = mic.pubkey; native.agent.status = "stopped"; @@ -289,7 +286,8 @@ function Fixture() { Toggle appearance
-
- Date: Thu, 24 Sep 2026 14:04:20 -0600 Subject: [PATCH 2/4] fix: address persistent sidebar review regressions Signed-off-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> --- docs/shell-design.md | 4 ++ src/app/shell/AppShell.tsx | 62 +++++++++++++++---- src/bundled/channels/ChannelsPage.tsx | 20 +----- .../channel-navigation/ChannelSidebar.tsx | 2 +- src/shared/styles/globals.css | 27 +++++++- tests/browser/layout.spec.mjs | 36 ++++++++--- tests/browser/new-message.spec.mjs | 3 +- tests/browser/settings.spec.mjs | 32 +++++++++- 8 files changed, 147 insertions(+), 39 deletions(-) diff --git a/docs/shell-design.md b/docs/shell-design.md index 0afbfbd95..5c0af9e09 100644 --- a/docs/shell-design.md +++ b/docs/shell-design.md @@ -38,6 +38,10 @@ semantic tokens, UI authoring rules and the local component reference. unrelated pages. Its own error boundary keeps page navigation and Settings usable. Page buttons use shared navigation rows and focus the main region on selection. A scrollable page list leaves room for channels at short heights. + At widths up to 650px, Settings collapses this navigation behind the header’s + Show navigation button to preserve readable content at 200% text size. The + disclosure overlays Settings, supports Escape, and keeps sidebar state mounted. + Other pages and desktop Settings retain the visible sidebar. The header keeps history and account/search actions, with no second navigation row. Full-height pages get a 16px outer gutter (8px on narrow screens) and own their card surfaces. The shell adds no white backing behind them. Document pages diff --git a/src/app/shell/AppShell.tsx b/src/app/shell/AppShell.tsx index b68c4f8f9..50407c193 100644 --- a/src/app/shell/AppShell.tsx +++ b/src/app/shell/AppShell.tsx @@ -1,5 +1,7 @@ import { NavigationItem } from "../../shared/design-system/ui/NavigationItem"; -import type { ReactNode } from "react"; +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { IconButton } from "../../shared/design-system/ui/IconButton"; +import { SidebarIcon } from "../../shared/design-system/icons"; import { Panel } from "../../shared/design-system/ui/Panel"; import { isTauri } from "@tauri-apps/api/core"; import type { RegisteredPage } from "../../features/pages/service"; @@ -44,6 +46,11 @@ export function AppShell({ children: ReactNode; }) { const fillsWorkspace = workspace || selected === "settings"; + const [navigationOpen, setNavigationOpen] = useState(false); + const navigationToggle = useRef(null); + useEffect(() => { + if (selected !== "settings") setNavigationOpen(false); + }, [selected]); const pageNavigation = (
-
- {sidebar ? ( - sidebar(pageNavigation) - ) : ( -
- -
{pageNavigation}
-
-
- )} +
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: Delegated Escape from descendant controls closes the disclosure; the layout wrapper is not itself interactive. */} +
{ + if ( + event.key === "Escape" && + navigationOpen && + !event.defaultPrevented + ) { + setNavigationOpen(false); + navigationToggle.current?.focus(); + } + }} + > + {sidebar ? ( + sidebar(pageNavigation) + ) : ( +
+ +
{pageNavigation}
+
+
+ )} +

Your channels, one conversation.

- {session.status === "connecting" - ? "Connecting to your relay…" - : (session.error ?? - "Use the left community rail to choose or add a community. Your profile and settings work without a community.")} + {session.status === "disconnected" + ? "Use the left community rail to choose or add a community. Your profile and settings work without a community." + : "Connection details and retry are in the sidebar. Your profile and settings work without a community."}

- {session.status === "error" && ( - <> - -

- For development, set BUZZ_DEV_VIEWER to your Buzz - public key in .env.local, then restart{" "} - just web or just desktop. See - README.md for requirements. -

- - )}
) : ( diff --git a/src/features/channel-navigation/ChannelSidebar.tsx b/src/features/channel-navigation/ChannelSidebar.tsx index dd2808f4d..968c64604 100644 --- a/src/features/channel-navigation/ChannelSidebar.tsx +++ b/src/features/channel-navigation/ChannelSidebar.tsx @@ -607,7 +607,7 @@ function ReadySidebar({ ) : preferences.status !== "ready" ? ( -

+

{preferences.status === "loading" ? "Loading saved groups and stars…" : "Saved groups and stars aren’t supported by this host yet."} diff --git a/src/shared/styles/globals.css b/src/shared/styles/globals.css index 4e058e43f..ffd1ebf46 100644 --- a/src/shared/styles/globals.css +++ b/src/shared/styles/globals.css @@ -203,8 +203,14 @@ @apply flex min-h-0 shrink-0 flex-col mb-4; max-height: 40%; } + .shell-navigation { + display: contents; + } + .shell-navigation-toggle { + display: none; + } .shell-body { - @apply flex min-h-0 min-w-0 flex-1 px-2 pb-2 sm:px-4 sm:pb-4; + @apply relative flex min-h-0 min-w-0 flex-1 px-2 pb-2 sm:px-4 sm:pb-4; } .shell-sidebar, .shell-sidebar-default { @@ -236,6 +242,25 @@ .shell-sidebar-default { margin-right: var(--space-1); } + .shell-navigation-toggle { + display: inline-flex; + } + .shell-body-settings .shell-navigation { + display: none; + } + .shell-body-settings .shell-navigation[data-expanded="true"] { + @apply absolute inset-y-0 left-2 z-20 flex pb-2; + max-width: calc(100% - 16px); + } + .shell-body-settings .shell-navigation .shell-sidebar, + .shell-body-settings .shell-navigation .shell-sidebar-default { + width: 260px; + max-width: 100%; + margin-right: 0; + } + .shell-body-settings .shell-navigation > hr { + display: none; + } } .muted { @apply text-body-sm text-muted; diff --git a/tests/browser/layout.spec.mjs b/tests/browser/layout.spec.mjs index d6e908d6e..bf0054ba3 100644 --- a/tests/browser/layout.spec.mjs +++ b/tests/browser/layout.spec.mjs @@ -68,6 +68,9 @@ async function link(page, app, target) { ).toBeVisible(); } async function shellFits(page, width) { + const disclosure = button(page, "Show navigation"); + const collapsed = await disclosure.isVisible(); + if (collapsed) await disclosure.click(); const pages = await box( page.getByRole("navigation", { name: "Pages", exact: true }), ); @@ -110,6 +113,7 @@ async function shellFits(page, width) { if (width > 700) { expect(communities.x + communities.width).toBeLessThan(sidebar.x); } + if (collapsed) await button(page, "Hide navigation").click(); } scroll( @@ -266,6 +270,7 @@ test("bento surfaces, sidebar pages, real link panel and compact community navig await expect( page.getByRole("heading", { name: "Settings", exact: true }), ).toBeVisible(); + if (width <= 650) await button(page, "Show navigation").click(); await page .getByRole("navigation", { name: "Pages", exact: true }) .getByRole("button", { name: "Messages" }) @@ -308,11 +313,16 @@ test("bento surfaces, sidebar pages, real link panel and compact community navig await expect(projects).toHaveAttribute("aria-checked", "false"); await expect( page - .getByRole("navigation", { name: "Pages", exact: true }) - .getByRole("button", { name: "Projects" }), + .getByRole("navigation", { + name: "Pages", + exact: true, + includeHidden: true, + }) + .getByRole("button", { name: "Projects", includeHidden: true }), ).toHaveCount(0); await projects.click(); await expect(projects).toHaveAttribute("aria-checked", "true"); + await button(page, "Show navigation").click(); await expect( page .getByRole("navigation", { name: "Pages", exact: true }) @@ -727,9 +737,16 @@ test("Projects stays centered and page navigation survives plugin re-enable orde }, testInfo) => { await page.setViewportSize({ width: 1280, height: 832 }); await page.goto(app.origin); - const nav = page.getByRole("navigation", { name: "Pages", exact: true }); + // Plugin ordering remains observable while narrow Settings collapses navigation. + const nav = page.getByRole("navigation", { + name: "Pages", + exact: true, + includeHidden: true, + }); const titles = ["Messages", "Projects", "Agents", "Sessions", "Workflows"]; - await expect(nav.getByRole("button")).toHaveText(titles); + await expect(nav.getByRole("button", { includeHidden: true })).toHaveText( + titles, + ); await nav.getByRole("button", { name: "Projects", exact: true }).click(); const surface = page.getByRole("region", { name: "Projects", exact: true }); const title = surface.getByRole("heading", { @@ -767,14 +784,16 @@ test("Projects stays centered and page navigation survives plugin re-enable orde exact: true, }); await projects.click(); - await expect(nav.getByRole("button")).toHaveText([ + await expect(nav.getByRole("button", { includeHidden: true })).toHaveText([ "Messages", "Agents", "Sessions", "Workflows", ]); await projects.click(); - await expect(nav.getByRole("button")).toHaveText(titles); + await expect(nav.getByRole("button", { includeHidden: true })).toHaveText( + titles, + ); // Re-enabled Projects registered last; navigation surfaces must still sort it. await button(page, "Search Buzz").click(); const search = page.getByRole("dialog", { name: "Search Buzz", exact: true }); @@ -795,6 +814,7 @@ const sidebarActions = test.extend({ productionBroker: true, readState: true, threadUnread: true, + threadUnreadMentions: true, largeSidebar: true, historyCounts: { alpha: 20, beta: 1 }, }); @@ -820,7 +840,9 @@ sidebarActions( await page .getByRole("dialog", { name: "Activity in Alpha" }) .getByRole("button", { name: /Open unread thread from/ }) - .first() + // Unlike the broadcast row, this reply cannot be marked read by the + // visible main timeline while Playwright is moving the pointer. + .filter({ hasText: "Unread reply 1" }) .click(); await expect( page.getByRole("complementary", { name: "Thread", exact: true }), diff --git a/tests/browser/new-message.spec.mjs b/tests/browser/new-message.spec.mjs index 7fc93aaca..dfc329641 100644 --- a/tests/browser/new-message.spec.mjs +++ b/tests/browser/new-message.spec.mjs @@ -60,7 +60,8 @@ const test = base.extend({ let backgroundReady = Promise.resolve(), releaseBackground = () => {}; const socket = brokerSocket(async (event) => { - if (hold) + // Presence/profile writes must not replace the pending message resolver. + if (hold && event.kind === 9) await new Promise((resolve) => { release = resolve; }); diff --git a/tests/browser/settings.spec.mjs b/tests/browser/settings.spec.mjs index 58c6357d6..801ba7d72 100644 --- a/tests/browser/settings.spec.mjs +++ b/tests/browser/settings.spec.mjs @@ -75,6 +75,32 @@ test("short narrow Settings keeps full plugin rows usable at 200% text size", as await expect(button(page, "Profile")).toBeInViewport({ ratio: 1 }); await button(page, "Profile").click(); await expect(button(page, "Profile")).toHaveAttribute("aria-current", "page"); + + // Narrow Settings has the full content width, but the same navigation remains + // reachable by disclosure and keyboard. Desktop keeps it permanently visible. + const pages = page.getByRole("navigation", { name: "Pages" }); + await expect(pages).toBeHidden(); + const showNavigation = button(page, "Show navigation"); + await expect(showNavigation).toHaveAttribute("aria-expanded", "false"); + await showNavigation.click(); + await expect(pages).toBeVisible(); + const messages = pages.getByRole("button", { name: "Messages", exact: true }); + await messages.focus(); + await page.keyboard.press("Escape"); + await expect(pages).toBeHidden(); + await expect(showNavigation).toBeFocused(); + await page.setViewportSize({ width: 1280, height: 900 }); + await expect(pages).toBeVisible(); + await expect(showNavigation).toBeHidden(); + await page.setViewportSize({ width: 480, height: 400 }); + await expect(pages).toBeHidden(); + await showNavigation.click(); + await messages.click(); + await expect(page.getByRole("main")).toBeFocused(); + await expect( + page.getByRole("region", { name: "Channels", exact: true }), + ).toBeVisible(); + await expect(pages).toBeVisible(); }); test("avatar Settings access dismisses cleanly and exposes Profile and Plugins", async ({ @@ -117,7 +143,11 @@ test("avatar Settings access dismisses cleanly and exposes Profile and Plugins", await avatar.click(); await expect(account).toBeHidden(); await avatar.click(); - await page.getByRole("main").click({ position: { x: 5, y: 5 } }); + // The account popup may cover main’s top-left on narrow layouts. + // Click the lower content area, genuinely outside the popup. + const main = page.getByRole("main"); + const bounds = await main.boundingBox(); + await main.click({ position: { x: 5, y: bounds.height - 5 } }); await expect(account).toBeHidden(); await avatar.focus(); await page.keyboard.press("Enter"); From 1a8ca21f166c25eeaec903b06a17bc96e17ba7e4 Mon Sep 17 00:00:00 2001 From: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> Date: Thu, 24 Sep 2026 15:17:18 -0600 Subject: [PATCH 3/4] test: provide layout observer for persistent-sidebar App fixture Signed-off-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> --- src/app/entity-navigation.test.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/app/entity-navigation.test.tsx b/src/app/entity-navigation.test.tsx index 034c60e6a..3296d5b59 100644 --- a/src/app/entity-navigation.test.tsx +++ b/src/app/entity-navigation.test.tsx @@ -47,6 +47,15 @@ afterEach(async () => { window.history.replaceState(null, "", "/"); }); it("keeps an OS entity intent through real community selection and Retry in App", async () => { + // jsdom has no layout observer; App now keeps its real sidebar mounted here. + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); vi.stubEnv("VITE_BUZZ_LIVE", "1"); localStorage.setItem( `buzz-client.v1:${viewer}`, From c341f37fba8a95fd637e966e6be59717ff166d6b Mon Sep 17 00:00:00 2001 From: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> Date: Thu, 24 Sep 2026 15:19:02 -0600 Subject: [PATCH 4/4] test: scope agent identity read assertion to profile metadata Signed-off-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> --- src/bundled/profiles/ProfileAgentIdentity.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bundled/profiles/ProfileAgentIdentity.test.tsx b/src/bundled/profiles/ProfileAgentIdentity.test.tsx index ef0ad1a7b..0eeafe5e8 100644 --- a/src/bundled/profiles/ProfileAgentIdentity.test.tsx +++ b/src/bundled/profiles/ProfileAgentIdentity.test.tsx @@ -199,7 +199,7 @@ it("adds no agent section or reads for a profile without an agent hint", async ( ); await screen.findByRole("heading", { name: "Person" }); expect(screen.queryByRole("region", { name: "Agent identity" })).toBeNull(); - expect(query).toHaveBeenCalledTimes(1); + expect(agentReads(query)).toBe(1); }); function timedProfile(