From 0da202e6b0e5ba040ded11a77aecab1007c6c3ff Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 16:44:01 -0700 Subject: [PATCH 1/2] CL-5863: Tests for channel row ellipsis menu + details panel helpers --- .../web/src/shell/panel-contributions.test.ts | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 apps/web/src/shell/panel-contributions.test.ts diff --git a/apps/web/src/shell/panel-contributions.test.ts b/apps/web/src/shell/panel-contributions.test.ts new file mode 100644 index 000000000..7d6bfca92 --- /dev/null +++ b/apps/web/src/shell/panel-contributions.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test"; + +import type { Channel } from "@corbits/chat-ui"; + +import { + channelDetails, + panelRenamePayload, + panelRowMenuLabels, +} from "./panel-contributions"; + +const baseChannel: Channel = { + id: "ch_1", + title: "Launch planning", + kind: "channel", + pinned: false, + participants: [], +}; + +describe("panel channel row ellipsis menu", () => { + test("offers Rename + Pin for an unpinned channel", () => { + expect(panelRowMenuLabels({ pinned: false })).toEqual(["Rename", "Pin"]); + }); + + test("offers Rename + Unpin for a pinned channel", () => { + expect(panelRowMenuLabels({ pinned: true })).toEqual(["Rename", "Unpin"]); + }); +}); + +describe("panel rename payload", () => { + test("returns the trimmed name when it differs", () => { + expect(panelRenamePayload(" New name ", "Old")).toBe("New name"); + }); + + test("returns undefined when blank", () => { + expect(panelRenamePayload(" ", "Old")).toBeUndefined(); + }); + + test("returns undefined when unchanged", () => { + expect(panelRenamePayload("Old", "Old")).toBeUndefined(); + }); +}); + +describe("channel details panel contribution", () => { + test("maps the channel's title, kind, and pinned state", () => { + expect(channelDetails(baseChannel)).toEqual({ + title: "Launch planning", + kind: "channel", + pinned: false, + }); + }); + + test("falls back to the unnamed label for a blank title", () => { + expect(channelDetails({ ...baseChannel, title: "" }).title).toBe( + "Untitled channel", + ); + }); + + test("reflects a pinned channel", () => { + expect(channelDetails({ ...baseChannel, pinned: true }).pinned).toBe(true); + }); +}); From 67ed60c219da54af35c73440db36d5c0c0e7516d Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 16:44:07 -0700 Subject: [PATCH 2/2] CL-5863: Channel row ellipsis menu (rename/archive) + details panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Channel rows in the contextual panel (ChannelsBand and LiveActivityBand) now carry a hover-revealed ellipsis menu with Rename (inline) and a Pin/Unpin archive toggle. Both go through the existing settings PATCH via patchChannelSettings (chat/name, chat/pinned) — no new endpoint. Also adds a channel-details panel contribution: when a specific channel is selected, the panel prints its name, kind, and pinned state above the list, using the activity data already in flight (no extra fetch). Pure helpers (panelRowMenuLabels, panelRenamePayload, channelDetails) are exported for unit tests. --- apps/web/src/shell/panel-contributions.tsx | 252 +++++++++++++++++++-- 1 file changed, 238 insertions(+), 14 deletions(-) diff --git a/apps/web/src/shell/panel-contributions.tsx b/apps/web/src/shell/panel-contributions.tsx index 5b48941d1..cb4614ed0 100644 --- a/apps/web/src/shell/panel-contributions.tsx +++ b/apps/web/src/shell/panel-contributions.tsx @@ -1,8 +1,27 @@ // Registers each page's contextual-panel contribution. Imported once from // the shell so matchers are on the registry before first render. -import { EmptyState, SidebarItemRow, Skeleton } from "@corbits/react-ui"; -import { Hash, MessageSquare, Workflow, Bell } from "lucide-react"; +import { + EmptyState, + Input, + Menu, + MenuContent, + MenuItem, + MenuTrigger, + SidebarItemRow, + Skeleton, +} from "@corbits/react-ui"; +import { CHAT_STRINGS, patchChannelSettings } from "@corbits/chat-ui"; +import type { Channel } from "@corbits/chat-ui"; +import { + Bell, + Hash, + MessageSquare, + MoreHorizontal, + Workflow, +} from "lucide-react"; +import { useState } from "react"; +import type { KeyboardEvent } from "react"; import { useBench } from "../bench-context"; import { channelIdFromPath, channelPath, isChannelPath } from "../channel-path"; @@ -17,6 +36,198 @@ function pathMatches(prefix: string, path: string): boolean { return path === prefix || path.startsWith(`${prefix}/`); } +/** + * The ellipsis-menu item labels for a panel channel row. The panel's row menu + * carries only the two affordances that don't need the full settings dialog — + * rename and the pin/unpin "archive" toggle — so this is a strict subset of + * the chat sidebar's three-item menu. Pure so the pinned-state wording + * ("Pin" vs "Unpin") is testable without opening the (portaled, Radix) + * menu. Pinning is the closest the settings PATCH (`chat/pinned`) gets to an + * archive affordance; there is no separate archive endpoint. + */ +export function panelRowMenuLabels( + channel: Pick, +): readonly [rename: string, archive: string] { + return [ + CHAT_STRINGS.rowMenuRename, + channel.pinned ? CHAT_STRINGS.rowMenuUnpin : CHAT_STRINGS.rowMenuPin, + ]; +} + +/** + * What a rename submission should send: `undefined` for input that resolves + * to nothing worth saving (blank, or unchanged from the channel's current + * title) — the caller's cue to treat the rename as a no-op cancel rather + * than firing an empty-name PATCH. Mirrors the chat sidebar's helper. + */ +export function panelRenamePayload( + input: string, + currentTitle: string, +): string | undefined { + const trimmed = input.trim(); + if (trimmed.length === 0 || trimmed === currentTitle) return undefined; + return trimmed; +} + +/** + * The read-only detail lines a "channel details" panel contribution prints + * for the selected channel — name, kind, and pinned state. Pure so the + * mapping is testable without rendering. + */ +export function channelDetails( + channel: Pick, +): { + readonly title: string; + readonly kind: string; + readonly pinned: boolean; +} { + return { + title: channel.title || CHAT_STRINGS.unnamedChannel, + kind: channel.kind, + pinned: channel.pinned, + }; +} + +/** + * One channel row in the panel list, with a hover-revealed ellipsis menu for + * rename (inline) and the pin/unpin archive toggle. Both go through the + * single `PATCH /channels/:id/settings` route via `patchChannelSettings`. + * The rename keeps a local display title so the row updates the moment the + * PATCH resolves, without waiting for the band's activity refetch. + */ +function ChannelPanelRow({ + channel, + active, + tenantId, + onSelect, +}: { + readonly channel: Channel; + readonly active: boolean; + readonly tenantId: string; + readonly onSelect: () => void; +}) { + const [title, setTitle] = useState(channel.title); + const [renaming, setRenaming] = useState(false); + const [renameValue, setRenameValue] = useState(channel.title); + const [renameLabel, archiveLabel] = panelRowMenuLabels(channel); + + function startRename() { + setRenameValue(title); + setRenaming(true); + } + + async function commitRename() { + const payload = panelRenamePayload(renameValue, title); + setRenaming(false); + if (payload === undefined) return; + setTitle(payload); + try { + await patchChannelSettings(tenantId, channel.id, { + "chat/name": payload, + }); + } catch { + // Revert the optimistic title on failure; the band will refetch on the + // next bench selection and reconcile either way. + setTitle(channel.title); + } + } + + function handleRenameKeyDown(event: KeyboardEvent) { + if (event.key === "Enter") { + event.preventDefault(); + void commitRename(); + } else if (event.key === "Escape") { + event.preventDefault(); + setRenaming(false); + } + } + + async function togglePinned() { + try { + await patchChannelSettings(tenantId, channel.id, { + "chat/pinned": !channel.pinned, + }); + } catch { + // Best-effort: the band refetches on bench change and reconciles. + } + } + + if (renaming) { + return ( + + setRenameValue((event.target as HTMLInputElement).value) + } + onKeyDown={handleRenameKeyDown} + onBlur={() => void commitRename()} + /> + ); + } + + return ( +
+ + + + + + + {renameLabel} + void togglePinned()}> + {archiveLabel} + + + +
+ ); +} + +/** + * The channel details panel contribution: when a specific channel is open in + * the canvas, print its name, kind, and pinned state above the channel list + * so the panel doubles as a details surface without a second fetch. Falls + * back to nothing when no channel is selected. + */ +function ChannelDetails({ channel }: { readonly channel: Channel }) { + const details = channelDetails(channel); + return ( +
+

Details

+
+
+
Name
+
{details.title}
+
+
+
Type
+
{details.kind}
+
+
+
Pinned
+
{details.pinned ? "Yes" : "No"}
+
+
+
+ ); +} + function ChannelsBand({ path, onOpenInCanvas, @@ -62,16 +273,24 @@ function ChannelsBand({ ); } + const selected = + activeId === null + ? undefined + : [...channels, ...chats].find((channel) => channel.id === activeId); + const tenantId = selectedTenantId ?? ""; + return (
+ {selected !== undefined ? : null} {channels.length > 0 ? (

Channels

{channels.map((channel) => ( - onOpenInCanvas(channel.id)} /> ))} @@ -81,10 +300,11 @@ function ChannelsBand({

Chats

{chats.map((channel) => ( - onOpenInCanvas(channel.id)} /> ))} @@ -195,6 +415,8 @@ function LiveActivityBand({ ); } + const tenantId = selectedTenantId ?? ""; + return (
{activity.routines.length > 0 ? ( @@ -214,10 +436,11 @@ function LiveActivityBand({

Channels

{activity.channels.map((channel) => ( - onNavigate(`${channelPath(channel.id)}`)} /> ))} @@ -227,10 +450,11 @@ function LiveActivityBand({

Chats

{activity.chats.map((channel) => ( - onNavigate(`${channelPath(channel.id)}`)} /> ))}