From 754ebe35ae2833048d480ca99eb2ff928dc443ac Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 15:46:25 -0700 Subject: [PATCH 1/4] Add tests for channel page title resolution --- apps/web/src/shell/channel-context.test.ts | 48 ++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 apps/web/src/shell/channel-context.test.ts diff --git a/apps/web/src/shell/channel-context.test.ts b/apps/web/src/shell/channel-context.test.ts new file mode 100644 index 000000000..43163893f --- /dev/null +++ b/apps/web/src/shell/channel-context.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "bun:test"; + +import type { Channel } from "@corbits/chat-ui"; + +import { resolveChannelTitle } from "./channel-context"; +import type { BenchActivityQuery } from "./bench-activity"; + +const channel = (partial: { + id: string; + title: string; + kind?: string; +}): Channel => + ({ + id: partial.id, + title: partial.title, + kind: partial.kind ?? "channel", + pinned: false, + participants: [], + }) as Channel; + +const ready: BenchActivityQuery = { + kind: "ready", + channels: [channel({ id: "ch_1", title: "Myra" })], + chats: [channel({ id: "ch_2", title: " ", kind: "chat" })], + routines: [], +}; + +describe("resolveChannelTitle", () => { + test("returns null without a channel id", () => { + expect(resolveChannelTitle(ready, null)).toBeNull(); + }); + + test("returns null while activity is loading", () => { + expect(resolveChannelTitle({ kind: "loading" }, "ch_1")).toBeNull(); + }); + + test("returns the channel title when found", () => { + expect(resolveChannelTitle(ready, "ch_1")).toBe("Myra"); + }); + + test("falls back to Untitled channel for blank titles", () => { + expect(resolveChannelTitle(ready, "ch_2")).toBe("Untitled channel"); + }); + + test("returns null when the channel is not in the list", () => { + expect(resolveChannelTitle(ready, "ch_missing")).toBeNull(); + }); +}); From b708970db9cfa20cd9e3603acbdbfe9f093d98b2 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 15:46:25 -0700 Subject: [PATCH 2/4] CL-5653: Show live channel name in the page band --- apps/web/src/shell/channel-context.ts | 31 ++++++++++++++ apps/web/src/shell/panel-contribution.ts | 3 +- apps/web/src/shell/panel-contributions.tsx | 50 ++++++++++++++++------ 3 files changed, 70 insertions(+), 14 deletions(-) create mode 100644 apps/web/src/shell/channel-context.ts diff --git a/apps/web/src/shell/channel-context.ts b/apps/web/src/shell/channel-context.ts new file mode 100644 index 000000000..b7a517d78 --- /dev/null +++ b/apps/web/src/shell/channel-context.ts @@ -0,0 +1,31 @@ +// Resolve the active channel's display title for the contextual page band. +// Reuses the same channel/chat lists the Channels band already loads so the +// page title does not invent a second fetch. + +import type { Channel } from "@corbits/chat-ui"; + +import type { BenchActivityQuery } from "./bench-activity"; + +/** Prefer channel title; fall back to untitled label; null when still loading. */ +export function resolveChannelTitle( + activity: BenchActivityQuery, + channelId: string | null, +): string | null { + if (channelId === null) return null; + if (activity.kind !== "ready") return null; + const match = findChannel(activity.channels, activity.chats, channelId); + if (match === undefined) return null; + const title = match.title?.trim(); + return title && title.length > 0 ? title : "Untitled channel"; +} + +function findChannel( + channels: readonly Channel[], + chats: readonly Channel[], + channelId: string, +): Channel | undefined { + return ( + channels.find((c) => c.id === channelId) ?? + chats.find((c) => c.id === channelId) + ); +} diff --git a/apps/web/src/shell/panel-contribution.ts b/apps/web/src/shell/panel-contribution.ts index 531bc02f3..6ffee4fc0 100644 --- a/apps/web/src/shell/panel-contribution.ts +++ b/apps/web/src/shell/panel-contribution.ts @@ -12,7 +12,8 @@ export type PanelAction = { }; export type PageBand = { - readonly title: string; + /** String or live component (e.g. resolved channel name). */ + readonly title: ReactNode; readonly subtitle?: string; readonly settingsPath?: string; readonly actions?: readonly PanelAction[]; diff --git a/apps/web/src/shell/panel-contributions.tsx b/apps/web/src/shell/panel-contributions.tsx index c2ece67e7..fd63c4a11 100644 --- a/apps/web/src/shell/panel-contributions.tsx +++ b/apps/web/src/shell/panel-contributions.tsx @@ -26,6 +26,7 @@ import type { KeyboardEvent } from "react"; import { useBench } from "../bench-context"; import { channelIdFromPath, channelPath, isChannelPath } from "../channel-path"; import { useBenchActivity } from "./bench-activity"; +import { resolveChannelTitle } from "./channel-context"; import { registerPanelContribution, type PanelRenderContext, @@ -228,6 +229,21 @@ function ChannelDetails({ channel }: { readonly channel: Channel }) { ); } +/** Live page-band title for an open channel — falls back while loading. */ +function ChannelPageTitle({ + channelId, + fallback, +}: { + readonly channelId: string | null; + readonly fallback: string; +}) { + const { selectedTenantId } = useBench(); + const activity = useBenchActivity(selectedTenantId); + const title = resolveChannelTitle(activity, channelId); + if (title !== null) return title; + return fallback; +} + function ChannelsBand({ path, onOpenInCanvas, @@ -491,20 +507,28 @@ export function ensurePanelContributions(): void { registerPanelContribution({ id: "channels", match: (path) => isChannelPath(path), - pageBand: (ctx) => ({ - title: "Channels", - subtitle: "Open a conversation in the canvas", - actions: [ - { - id: "new-channel", - label: "New channel", - onSelect: () => { - window.dispatchEvent(new CustomEvent("workbench:chat:new-channel")); - if (!isChannelPath(ctx.path)) ctx.onNavigate(channelPath(null)); + pageBand: (ctx) => { + const channelId = channelIdFromPath(ctx.path); + return { + title: , + subtitle: + channelId === null + ? "Open a conversation in the canvas" + : "Channel open in the canvas", + actions: [ + { + id: "new-channel", + label: "New channel", + onSelect: () => { + window.dispatchEvent( + new CustomEvent("workbench:chat:new-channel"), + ); + if (!isChannelPath(ctx.path)) ctx.onNavigate(channelPath(null)); + }, }, - }, - ], - }), + ], + }; + }, pageSpecific: (ctx) => ( ), From f75f93bd54dbbc82bf7bd954ed4995bbe4be83ae Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 18:04:19 -0700 Subject: [PATCH 3/4] Use a stable aria-label for page-specific band Page-band titles may be React nodes (live channel name), so interpolating them into aria-label is invalid. --- apps/web/src/shell/contextual-panel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/shell/contextual-panel.tsx b/apps/web/src/shell/contextual-panel.tsx index 8e219f2a5..8e8a48dff 100644 --- a/apps/web/src/shell/contextual-panel.tsx +++ b/apps/web/src/shell/contextual-panel.tsx @@ -118,7 +118,7 @@ export function ContextualPanel({ {pageSpecific !== null ? (

{pageBand.title}

{pageSpecific} From d905a83b9f8c0c2fbbcb6f3cd046164cce616831 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 18:08:59 -0700 Subject: [PATCH 4/4] Pass onOpenInCanvas into the narrow drawer panel The responsive drawer path rendered ContextualPanel without the canvas open handler, which fails typecheck after that prop became required. --- apps/web/src/shell/app-shell.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/shell/app-shell.tsx b/apps/web/src/shell/app-shell.tsx index 505dcdf93..ae64a5e23 100644 --- a/apps/web/src/shell/app-shell.tsx +++ b/apps/web/src/shell/app-shell.tsx @@ -143,6 +143,7 @@ export function AppShell({ canvasOpen={canvasState.open} onToggleCanvas={() => setCanvasState(toggleCanvasColumn)} canvasAllowed={canvasAllowed} + onOpenInCanvas={handleOpenInCanvas} />