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); + }); +}); 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)}`)} /> ))}