Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion apps/web/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
46 changes: 46 additions & 0 deletions apps/web/src/shell/context-menu/items.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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 unknown 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();
Expand Down
14 changes: 12 additions & 2 deletions apps/web/src/shell/context-menu/items.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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"),
);
},
Expand Down
31 changes: 25 additions & 6 deletions apps/web/src/shell/workbench-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,13 @@ 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";

Expand Down Expand Up @@ -247,14 +253,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);
Expand Down Expand Up @@ -304,16 +316,17 @@ function WorkbenchRow({

async function togglePinned() {
const next = !pinned;
setPinned(next);
try {
await patchWorkbenchSettings(tenantId, workbench.id, {
"chat/pinned": next,
});
void queryClient.invalidateQueries({
queryKey: workbenchesQueryKeyPrefix(tenantId),
});
setPinned(next);
toast(CHAT_STRINGS.workbenchPinnedToast(next, title));
} catch {
setPinned(!next);
toast(CHAT_STRINGS.workbenchPinToggleError(next));
}
}
Expand Down Expand Up @@ -343,7 +356,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"}
>
<button
type="button"
Expand All @@ -361,6 +374,12 @@ function WorkbenchRow({
<span className="shell-ch-meta">
<span className="shell-ch-name-row">
<span className="shell-ch-name">{displayTitle}</span>
{pinned ? (
<PushPin
className="shell-ch-pin"
aria-label={CHAT_STRINGS.rowMenuPin}
/>
) : null}
{sharedLabel !== undefined && sharedLabel !== "" ? (
<Badge tone="shared" title={sharedLabel}>
shared
Expand Down
94 changes: 92 additions & 2 deletions apps/web/test/workbench-list.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,13 @@ function json(body: unknown): Response {
});
}

function stubFetch(data: { readonly needsYou?: readonly unknown[] }): void {
function stubFetch(
data: {
readonly needsYou?: readonly unknown[];
readonly workbenches?: readonly unknown[];
readonly chats?: readonly unknown[];
} = {},
): void {
globalThis.fetch = ((input: RequestInfo | URL) => {
const path = typeof input === "string" ? input : String(input);
if (path.includes("/api/me/principals"))
Expand All @@ -57,6 +63,10 @@ function stubFetch(data: { readonly needsYou?: readonly unknown[] }): void {
return Promise.resolve(json({ items: data.needsYou ?? [] }));
if (path.includes("/agent-definitions/visible"))
return Promise.resolve(json({ definitions: [] }));
if (path.includes("/chat/workbenches?kind=workbench"))
return Promise.resolve(json({ items: data.workbenches ?? [] }));
if (path.includes("/chat/workbenches?kind=chat"))
return Promise.resolve(json({ items: data.chats ?? [] }));
return Promise.resolve(json({ items: [] }));
}) as typeof fetch;
}
Expand Down Expand Up @@ -87,10 +97,28 @@ async function mount(onNavigate: (to: string) => void = () => undefined) {
</TestQueryProvider>,
);
});
for (let i = 0; i < 20; i++) {
for (let i = 0; i < 40; i++) {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
// Prefer `shell-ch-row-wrap` over bare `shell-ch-row`: the minting stub
// ("New Workbench") uses the row class alone and paints before the
// parallel needs-you query settles — breaking on it races the chip.
if (
container.innerHTML.includes("shell-ch-row-wrap") ||
container.innerHTML.includes("waiting on you")
) {
break;
}
// Stub-only list: keep spinning until needs-you resolves (chip text) or
// enough ticks have passed for an empty needs-you to settle as null.
if (
container.innerHTML.includes("New Workbench") &&
!container.innerHTML.includes("shell-activity-skeleton") &&
i >= 15
) {
break;
}
}
return container;
}
Expand All @@ -113,3 +141,65 @@ describe("WorkbenchList — needs-you signal", () => {
expect(chip?.textContent).toBe("Needs you");
});
});

describe("WorkbenchList — pin visibility and order (CL-6657)", () => {
test("pin glyph floats within Agents and within Channels, never across", async () => {
stubFetch({
chats: [
{
id: "ch_recent_dm",
title: "Recent agent",
kind: "chat",
pinned: false,
participants: [],
lastActivityAt: "2026-08-10T00:00:00.000Z",
},
{
id: "ch_pinned_dm",
title: "Pinned agent",
kind: "chat",
pinned: true,
participants: [],
lastActivityAt: "2026-08-01T00:00:00.000Z",
},
],
workbenches: [
{
id: "ch_recent_room",
title: "Recent channel",
kind: "workbench",
pinned: false,
participants: [],
lastActivityAt: "2026-08-10T00:00:00.000Z",
},
{
id: "ch_pinned_room",
title: "Pinned channel",
kind: "workbench",
pinned: true,
participants: [],
lastActivityAt: "2026-08-01T00:00:00.000Z",
},
],
});
const el = await mount();
expect(
[...el.querySelectorAll(".shell-panel-list-label")].map(
(heading) => heading.textContent,
),
).toEqual(["Agents", "Channels"]);
expect(el.textContent).not.toContain("Workbenches");
const wraps = [...el.querySelectorAll(".shell-ch-row-wrap")];
expect(wraps.map((row) => row.getAttribute("data-ctx-workbench"))).toEqual([
"ch_pinned_dm",
"ch_recent_dm",
"ch_pinned_room",
"ch_recent_room",
]);
expect(wraps[0]?.querySelector(".shell-ch-pin")).not.toBeNull();
expect(wraps[0]?.getAttribute("data-ctx-workbench-pinned")).toBe("true");
expect(wraps[1]?.querySelector(".shell-ch-pin")).toBeNull();
expect(wraps[2]?.querySelector(".shell-ch-pin")).not.toBeNull();
expect(wraps[3]?.querySelector(".shell-ch-pin")).toBeNull();
});
});
Loading