From 228cba0ababcd04400102ff2d70c0b9f59cf3808 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 24 Aug 2026 08:21:05 -0700 Subject: [PATCH 1/4] Show pinned workbenches in the sidebar Dispatch WORKBENCHES_MUTATED_EVENT on pin so the list refetches, render a pin glyph on pinned rows, and sync local pinned state from props. Closes CL-6657 --- apps/web/src/app.css | 10 ++- .../web/src/shell/context-menu/items.test.tsx | 46 +++++++++++++ apps/web/src/shell/context-menu/items.tsx | 14 +++- apps/web/src/shell/workbench-list.tsx | 25 +++++-- apps/web/test/workbench-list.test.tsx | 69 ++++++++++++++++++- 5 files changed, 153 insertions(+), 11 deletions(-) diff --git a/apps/web/src/app.css b/apps/web/src/app.css index 2a2e64eb5..f07767a1e 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -1566,7 +1566,15 @@ select:disabled, white-space: nowrap; } -/* Mock .live — activity pulse; only when live signal exists and no unread. */ +/* Persistent pin glyph on pinned sidebar rows (CL-6657) — pinning must be + visible in the list itself, not only as a toast / settings bit. */ +.shell-ch-pin { + flex-shrink: 0; + width: 0.75rem; + height: 0.75rem; + color: var(--muted-foreground, var(--ui-fg-muted)); +} + .shell-ch-live { width: 0.4rem; height: 0.4rem; diff --git a/apps/web/src/shell/context-menu/items.test.tsx b/apps/web/src/shell/context-menu/items.test.tsx index 462b53cf8..73730ed66 100644 --- a/apps/web/src/shell/context-menu/items.test.tsx +++ b/apps/web/src/shell/context-menu/items.test.tsx @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, mock, test } from "bun:test"; import { spyOnReactUiToast } from "../../../test/react-ui-toast-mock"; import type { ContextMenuEntry } from "@corbits/context-menu"; +import { WORKBENCHES_MUTATED_EVENT } from "@corbits/chat-ui"; const toastMock = spyOnReactUiToast(); @@ -74,6 +75,51 @@ describe("shellContextMenuFor: workbench", () => { expect(itemIds(menu.entries)).toEqual(["rename", "copy-link"]); }); + test("pin dispatches WORKBENCHES_MUTATED_EVENT so the sidebar list refetches (CL-6657)", async () => { + const realFetch = globalThis.fetch; + // `patchWorkbenchSettings` validates against WorkbenchSettingsResponse — + // a partial body rejects and the success path (event + toast) never runs. + globalThis.fetch = mock(() => + Promise.resolve( + new Response( + JSON.stringify({ + id: "ch-1", + title: "Launch Planning", + kind: "workbench", + pinned: true, + participants: [], + settings: { "chat/pinned": true }, + contextWindow: { value: 0, source: "inherit" }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ), + ) as typeof fetch; + const events: Event[] = []; + const listener = (event: Event) => events.push(event); + window.addEventListener(WORKBENCHES_MUTATED_EVENT, listener); + try { + const menu = shellContextMenuFor(target, actions()); + findItem(menu.entries, "pin").onSelect(); + // request() awaits fetch then response.json() before the success + // .then (event + toast) runs — a few microtasks alone fall short. + for (let i = 0; i < 10 && events.length === 0; i++) { + await Promise.resolve(); + } + if (events.length === 0) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + expect(events).toHaveLength(1); + expect((events[0] as CustomEvent<{ tenantId: string }>).detail).toEqual({ + tenantId: "tenant-1", + }); + expect(toastMock).toHaveBeenCalledWith("Workbench pinned"); + } finally { + window.removeEventListener(WORKBENCHES_MUTATED_EVENT, listener); + globalThis.fetch = realFetch; + } + }); + test("copy-link writes the workbench's canonical URL to the clipboard", async () => { const menu = shellContextMenuFor(target, actions()); findItem(menu.entries, "copy-link").onSelect(); diff --git a/apps/web/src/shell/context-menu/items.tsx b/apps/web/src/shell/context-menu/items.tsx index 8cb515192..8d4a6ac3d 100644 --- a/apps/web/src/shell/context-menu/items.tsx +++ b/apps/web/src/shell/context-menu/items.tsx @@ -6,6 +6,7 @@ import { patchWorkbenchSettings, profileSubjectFromParticipant, + WORKBENCHES_MUTATED_EVENT, } from "@corbits/chat-ui"; import type { ProfileSubject } from "@corbits/chat-ui"; import { contextMenuItem, contextMenuSeparator } from "@corbits/context-menu"; @@ -79,8 +80,17 @@ function workbenchMenu( void patchWorkbenchSettings(tenantId, target.id, { "chat/pinned": !target.pinned, }).then( - () => - toast(target.pinned ? "Workbench unpinned" : "Workbench pinned"), + () => { + // Settings persist without this, but the sidebar list caches + // its workbench fetch — without a mutation signal the pin + // has zero visible effect (no reorder, no glyph refresh). + window.dispatchEvent( + new CustomEvent(WORKBENCHES_MUTATED_EVENT, { + detail: { tenantId }, + }), + ); + toast(target.pinned ? "Workbench unpinned" : "Workbench pinned"); + }, () => toast("Couldn't update the workbench"), ); }, diff --git a/apps/web/src/shell/workbench-list.tsx b/apps/web/src/shell/workbench-list.tsx index 8916f5fe2..f7d2c3c9a 100644 --- a/apps/web/src/shell/workbench-list.tsx +++ b/apps/web/src/shell/workbench-list.tsx @@ -22,7 +22,7 @@ import { } from "@corbits/chat-ui"; import type { Workbench } from "@corbits/chat-ui"; import { useQueryClient } from "@tanstack/react-query"; -import { ChatCircle, DotsThree, Hash, MagnifyingGlass } from "@corbits/icons"; +import { ChatCircle, DotsThree, Hash, MagnifyingGlass, PushPin } from "@corbits/icons"; import { useEffect, useState } from "react"; import type { KeyboardEvent, ReactNode } from "react"; @@ -247,14 +247,20 @@ function WorkbenchRow({ }) { const queryClient = useQueryClient(); const [title, setTitle] = useState(workbench.title); - // The workbench prop only reconciles on a scope change, so the effective - // pinned state lives here: without it a second toggle would re-send and - // re-announce the first one's transition. + // The workbench prop only reconciles on a scope change / list refetch, so + // the effective pinned state lives here: without it a second toggle would + // re-send and re-announce the first one's transition. Prop sync keeps the + // glyph + context-menu attr honest after an external pin (row menu's + // invalidate, or WORKBENCHES_MUTATED_EVENT from the shell context menu). const [pinned, setPinned] = useState(workbench.pinned); const [renaming, setRenaming] = useState(false); const [renameValue, setRenameValue] = useState(workbench.title); const [renameLabel, pinToggleLabel] = rowMenuLabels({ pinned }); + useEffect(() => { + setPinned(workbench.pinned); + }, [workbench.pinned]); + function startRename() { setRenameValue(title); setRenaming(true); @@ -304,6 +310,7 @@ function WorkbenchRow({ async function togglePinned() { const next = !pinned; + setPinned(next); try { await patchWorkbenchSettings(tenantId, workbench.id, { "chat/pinned": next, @@ -311,9 +318,9 @@ function WorkbenchRow({ void queryClient.invalidateQueries({ queryKey: workbenchesQueryKeyPrefix(tenantId), }); - setPinned(next); toast(CHAT_STRINGS.workbenchPinnedToast(next, title)); } catch { + setPinned(!next); toast(CHAT_STRINGS.workbenchPinToggleError(next)); } } @@ -343,7 +350,7 @@ function WorkbenchRow({ className="shell-ch-row-wrap" data-ctx-workbench={workbench.id} data-ctx-workbench-title={displayTitle} - data-ctx-workbench-pinned={workbench.pinned ? "true" : "false"} + data-ctx-workbench-pinned={pinned ? "true" : "false"} >