From 00acaf49480ea63ced86d9a658a4982f526b93b2 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 17 Aug 2026 17:47:47 +0000 Subject: [PATCH 1/5] feat(desktop): add off-by-default Workstream Board (bullet 1) Adds the workstreamBoard preview flag, a /workstreams route, and a gated sidebar destination that surface live loganj-ws-* channel canvases as minimal cards (channel name, synopsis, orchestrator, assignees). Card data comes from a single versioned buzz-workstream-card fenced JSON block per canvas; parse failures (missing block, invalid JSON, duplicate blocks, unknown version, invalid/missing fields) degrade only that card to channel metadata plus an inline unavailable state. Discovery reuses useChannelsQuery with a plain name-prefix filter and no creator/membership filter; per-card data reuses useCanvasQuery. Split AppSidebarProps out into the existing AppSidebar.types.ts companion file to keep AppSidebar.tsx under the repo's 1000-line file-size ratchet after adding the new sidebar wiring. Scope is limited to bullet 1 of the Workstream Board spike: no liveness, PR status, waiting-on resolution, or sorting. Signed-off-by: loganj Co-authored-by: Claude Code --- desktop/src/app/AppShell.helpers.ts | 10 +- desktop/src/app/AppShell.tsx | 2 + .../src/app/navigation/useAppNavigation.ts | 12 + desktop/src/app/routeTree.gen.ts | 21 ++ desktop/src/app/routes.ts | 1 + desktop/src/app/routes/workstreams.tsx | 25 +++ .../src/features/sidebar/ui/AppSidebar.tsx | 1 + .../features/sidebar/ui/AppSidebar.types.ts | 4 +- .../sidebar/ui/AppSidebarPinnedHeader.tsx | 2 + .../lib/discoverWorkstreamChannels.test.mjs | 117 ++++++++++ .../lib/discoverWorkstreamChannels.ts | 16 ++ .../lib/workstreamCardParser.test.mjs | 208 ++++++++++++++++++ .../lib/workstreamCardParser.ts | 138 ++++++++++++ .../lib/workstreamCardViewModel.test.mjs | 86 ++++++++ .../lib/workstreamCardViewModel.ts | 36 +++ .../ui/WorkstreamBoardScreen.tsx | 67 ++++++ .../workstream-board/ui/WorkstreamCard.tsx | 88 ++++++++ preview-features.json | 6 + 18 files changed, 838 insertions(+), 2 deletions(-) create mode 100644 desktop/src/app/routes/workstreams.tsx create mode 100644 desktop/src/features/workstream-board/lib/discoverWorkstreamChannels.test.mjs create mode 100644 desktop/src/features/workstream-board/lib/discoverWorkstreamChannels.ts create mode 100644 desktop/src/features/workstream-board/lib/workstreamCardParser.test.mjs create mode 100644 desktop/src/features/workstream-board/lib/workstreamCardParser.ts create mode 100644 desktop/src/features/workstream-board/lib/workstreamCardViewModel.test.mjs create mode 100644 desktop/src/features/workstream-board/lib/workstreamCardViewModel.ts create mode 100644 desktop/src/features/workstream-board/ui/WorkstreamBoardScreen.tsx create mode 100644 desktop/src/features/workstream-board/ui/WorkstreamCard.tsx diff --git a/desktop/src/app/AppShell.helpers.ts b/desktop/src/app/AppShell.helpers.ts index dd6b9195e82..448f3533cc9 100644 --- a/desktop/src/app/AppShell.helpers.ts +++ b/desktop/src/app/AppShell.helpers.ts @@ -9,7 +9,8 @@ export type AppView = | "agents" | "workflows" | "pulse" - | "projects"; + | "projects" + | "workstreamBoard"; const WINDOW_DRAG_HANDLE_HEIGHT = 44; const TAURI_DRAG_REGION_ATTR = "data-tauri-drag-region"; @@ -181,6 +182,13 @@ export function deriveShellRoute(pathname: string): { }; } + if (pathname === "/workstreams" || pathname.startsWith("/workstreams/")) { + return { + selectedChannelId: null, + selectedView: "workstreamBoard", + }; + } + if (pathname === "/pulse") { return { selectedChannelId: null, diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 39b5a5a148e..b3a82fc5ae5 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -149,6 +149,7 @@ export function AppShell() { goPulse, goSettings, goWorkflows, + goWorkstreams, closeSettings, openSearchHit, } = useAppNavigation(); @@ -878,6 +879,7 @@ export function AppShell() { onSelectPulse={() => void goPulse()} onSelectSettings={handleOpenSettings} onSelectWorkflows={() => void goWorkflows()} + onSelectWorkstreamBoard={() => void goWorkstreams()} onSetPresenceStatus={(status) => presenceSession.setStatus(status) } diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index 2203aa03a6a..3c388c8f12e 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -161,6 +161,17 @@ export function useAppNavigation() { [commitNavigation], ); + const goWorkstreams = React.useCallback( + (behavior?: NavigationBehavior) => + commitNavigation( + { + to: "/workstreams", + }, + behavior, + ), + [commitNavigation], + ); + const goWorkflow = React.useCallback( (workflowId: string, behavior?: NavigationBehavior) => commitNavigation( @@ -340,6 +351,7 @@ export function useAppNavigation() { goSettings, goWorkflow, goWorkflows, + goWorkstreams, openSearchHit, }; } diff --git a/desktop/src/app/routeTree.gen.ts b/desktop/src/app/routeTree.gen.ts index 2bc2c8ddb6d..367c2cc972b 100644 --- a/desktop/src/app/routeTree.gen.ts +++ b/desktop/src/app/routeTree.gen.ts @@ -5,6 +5,7 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from "./routes/root"; +import { Route as workstreamsRouteImport } from "./routes/workstreams"; import { Route as workflowsRouteImport } from "./routes/workflows"; import { Route as settingsRouteImport } from "./routes/settings"; import { Route as remindersRouteImport } from "./routes/reminders"; @@ -18,6 +19,11 @@ import { Route as messagesDotnewRouteImport } from "./routes/messages.new"; import { Route as channelsDotchannelIdRouteImport } from "./routes/channels.$channelId"; import { Route as channelsDotchannelIdDotpostsDotpostIdRouteImport } from "./routes/channels.$channelId.posts.$postId"; +const workstreamsRoute = workstreamsRouteImport.update({ + id: "/workstreams", + path: "/workstreams", + getParentRoute: () => rootRouteImport, +} as any); const workflowsRoute = workflowsRouteImport.update({ id: "/workflows", path: "/workflows", @@ -88,6 +94,7 @@ export interface FileRoutesByFullPath { "/reminders": typeof remindersRoute; "/settings": typeof settingsRoute; "/workflows": typeof workflowsRoute; + "/workstreams": typeof workstreamsRoute; "/channels/$channelId": typeof channelsDotchannelIdRoute; "/messages/new": typeof messagesDotnewRoute; "/projects/$projectId": typeof projectsDotprojectIdRoute; @@ -102,6 +109,7 @@ export interface FileRoutesByTo { "/reminders": typeof remindersRoute; "/settings": typeof settingsRoute; "/workflows": typeof workflowsRoute; + "/workstreams": typeof workstreamsRoute; "/channels/$channelId": typeof channelsDotchannelIdRoute; "/messages/new": typeof messagesDotnewRoute; "/projects/$projectId": typeof projectsDotprojectIdRoute; @@ -117,6 +125,7 @@ export interface FileRoutesById { "/reminders": typeof remindersRoute; "/settings": typeof settingsRoute; "/workflows": typeof workflowsRoute; + "/workstreams": typeof workstreamsRoute; "/channels/$channelId": typeof channelsDotchannelIdRoute; "/messages/new": typeof messagesDotnewRoute; "/projects/$projectId": typeof projectsDotprojectIdRoute; @@ -133,6 +142,7 @@ export interface FileRouteTypes { | "/reminders" | "/settings" | "/workflows" + | "/workstreams" | "/channels/$channelId" | "/messages/new" | "/projects/$projectId" @@ -147,6 +157,7 @@ export interface FileRouteTypes { | "/reminders" | "/settings" | "/workflows" + | "/workstreams" | "/channels/$channelId" | "/messages/new" | "/projects/$projectId" @@ -161,6 +172,7 @@ export interface FileRouteTypes { | "/reminders" | "/settings" | "/workflows" + | "/workstreams" | "/channels/$channelId" | "/messages/new" | "/projects/$projectId" @@ -176,6 +188,7 @@ export interface RootRouteChildren { remindersRoute: typeof remindersRoute; settingsRoute: typeof settingsRoute; workflowsRoute: typeof workflowsRoute; + workstreamsRoute: typeof workstreamsRoute; channelsDotchannelIdRoute: typeof channelsDotchannelIdRoute; messagesDotnewRoute: typeof messagesDotnewRoute; projectsDotprojectIdRoute: typeof projectsDotprojectIdRoute; @@ -185,6 +198,13 @@ export interface RootRouteChildren { declare module "@tanstack/react-router" { interface FileRoutesByPath { + "/workstreams": { + id: "/workstreams"; + path: "/workstreams"; + fullPath: "/workstreams"; + preLoaderRoute: typeof workstreamsRouteImport; + parentRoute: typeof rootRouteImport; + }; "/workflows": { id: "/workflows"; path: "/workflows"; @@ -280,6 +300,7 @@ const rootRouteChildren: RootRouteChildren = { remindersRoute: remindersRoute, settingsRoute: settingsRoute, workflowsRoute: workflowsRoute, + workstreamsRoute: workstreamsRoute, channelsDotchannelIdRoute: channelsDotchannelIdRoute, messagesDotnewRoute: messagesDotnewRoute, projectsDotprojectIdRoute: projectsDotprojectIdRoute, diff --git a/desktop/src/app/routes.ts b/desktop/src/app/routes.ts index f5c6938e11a..5d6692f8eff 100644 --- a/desktop/src/app/routes.ts +++ b/desktop/src/app/routes.ts @@ -10,6 +10,7 @@ export const routes = rootRoute("root.tsx", [ route("/workflows/$workflowId", "workflows.$workflowId.tsx"), route("/projects", "projects.tsx"), route("/projects/$projectId", "projects.$projectId.tsx"), + route("/workstreams", "workstreams.tsx"), route("/messages/new", "messages.new.tsx"), route("/channels/$channelId", "channels.$channelId.tsx"), route( diff --git a/desktop/src/app/routes/workstreams.tsx b/desktop/src/app/routes/workstreams.tsx new file mode 100644 index 00000000000..7be9b947a68 --- /dev/null +++ b/desktop/src/app/routes/workstreams.tsx @@ -0,0 +1,25 @@ +import * as React from "react"; +import { createFileRoute } from "@tanstack/react-router"; + +import { usePreviewFeatureWarning } from "@/shared/features"; +import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; + +const WorkstreamBoardScreen = React.lazy(async () => { + const module = await import( + "@/features/workstream-board/ui/WorkstreamBoardScreen" + ); + return { default: module.WorkstreamBoardScreen }; +}); + +export const Route = createFileRoute("/workstreams")({ + component: WorkstreamsRouteComponent, +}); + +function WorkstreamsRouteComponent() { + usePreviewFeatureWarning("workstreamBoard"); + return ( + }> + + + ); +} diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 6ca36bdc84c..6e2443dcc3a 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -511,6 +511,7 @@ export function AppSidebar({ onSelectProjects={onSelectProjects} onSelectPulse={onSelectPulse} onSelectWorkflows={onSelectWorkflows} + onSelectWorkstreamBoard={onSelectWorkstreamBoard} projectsOverviewActive={projectsOverviewActive} selectedView={selectedView} /> diff --git a/desktop/src/features/sidebar/ui/AppSidebar.types.ts b/desktop/src/features/sidebar/ui/AppSidebar.types.ts index 8d626c45938..602de9e217c 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.types.ts +++ b/desktop/src/features/sidebar/ui/AppSidebar.types.ts @@ -44,7 +44,8 @@ export type AppSidebarProps = { | "agents" | "workflows" | "pulse" - | "projects"; + | "projects" + | "workstreamBoard"; unreadChannelCounts: ReadonlyMap; unreadChannelIds: ReadonlySet; previewActivityChannelIds: ReadonlySet; @@ -86,6 +87,7 @@ export type AppSidebarProps = { onSelectProjects: () => void; onSelectPulse: () => void; onSelectWorkflows: () => void; + onSelectWorkstreamBoard: () => void; onSelectHome: () => void; onSelectChannel: (channelId: string) => void; onOpenSearchResult: (hit: SearchHit) => void; diff --git a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx index 1e0db29cac1..f0f25614eac 100644 --- a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx @@ -45,6 +45,7 @@ type AppSidebarPrimaryMenuProps = { onSelectProjects: () => void; onSelectPulse: () => void; onSelectWorkflows: () => void; + onSelectWorkstreamBoard: () => void; projectsOverviewActive: boolean; selectedView: SidebarSelectedView; }; @@ -95,6 +96,7 @@ export function AppSidebarPrimaryMenu({ onSelectProjects, onSelectPulse, onSelectWorkflows, + onSelectWorkstreamBoard, projectsOverviewActive, selectedView, }: AppSidebarPrimaryMenuProps) { diff --git a/desktop/src/features/workstream-board/lib/discoverWorkstreamChannels.test.mjs b/desktop/src/features/workstream-board/lib/discoverWorkstreamChannels.test.mjs new file mode 100644 index 00000000000..b2ea2f89c4c --- /dev/null +++ b/desktop/src/features/workstream-board/lib/discoverWorkstreamChannels.test.mjs @@ -0,0 +1,117 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + filterWorkstreamChannels, + WORKSTREAM_CHANNEL_PREFIX, +} from "./discoverWorkstreamChannels.ts"; + +function buildChannel(overrides) { + return { + id: overrides.id ?? "channel-id", + name: overrides.name, + channelType: "stream", + visibility: "open", + description: "", + topic: null, + purpose: null, + memberCount: overrides.memberPubkeys?.length ?? 0, + memberPubkeys: overrides.memberPubkeys ?? [], + lastMessageAt: null, + archivedAt: null, + participants: [], + participantPubkeys: [], + isMember: overrides.isMember ?? false, + ttlSeconds: null, + ttlDeadline: null, + }; +} + +test("prefix constant matches the contract-specified prefix", () => { + assert.equal(WORKSTREAM_CHANNEL_PREFIX, "loganj-ws-"); +}); + +test("includes channels whose name starts exactly with the prefix", () => { + const channels = [ + buildChannel({ id: "1", name: "loganj-ws-canvas-cards" }), + buildChannel({ id: "2", name: "general" }), + ]; + + const result = filterWorkstreamChannels(channels); + assert.deepEqual( + result.map((c) => c.id), + ["1"], + ); +}); + +test("excludes channels that merely contain the prefix mid-name", () => { + const channels = [ + buildChannel({ id: "1", name: "not-loganj-ws-canvas-cards" }), + buildChannel({ id: "2", name: "loganj-ws-canvas-cards" }), + ]; + + const result = filterWorkstreamChannels(channels); + assert.deepEqual( + result.map((c) => c.id), + ["2"], + ); +}); + +test("excludes a near-miss name missing the trailing hyphen", () => { + const channels = [ + buildChannel({ id: "1", name: "loganj-ws" }), + buildChannel({ id: "2", name: "loganj-ws-" }), + ]; + + const result = filterWorkstreamChannels(channels); + assert.deepEqual( + result.map((c) => c.id), + ["2"], + ); +}); + +test("applies no creator/membership filter — every matching name is included regardless of who created or joined it", () => { + const channels = [ + // Different member sets stand in for "different creators" — the Channel + // type carries no creator field on the list endpoint, so membership + // overlap is the only axis available to prove no ownership filtering. + buildChannel({ + id: "mine", + name: "loganj-ws-mine", + isMember: true, + memberPubkeys: ["aa"], + }), + buildChannel({ + id: "someone-elses", + name: "loganj-ws-someone-elses", + isMember: false, + memberPubkeys: ["bb", "cc"], + }), + buildChannel({ + id: "no-members", + name: "loganj-ws-empty", + isMember: false, + memberPubkeys: [], + }), + ]; + + const result = filterWorkstreamChannels(channels); + assert.deepEqual(result.map((c) => c.id).sort(), [ + "mine", + "no-members", + "someone-elses", + ]); +}); + +test("returns an empty array when nothing matches", () => { + const channels = [ + buildChannel({ id: "1", name: "general" }), + buildChannel({ id: "2", name: "random" }), + ]; + + assert.deepEqual(filterWorkstreamChannels(channels), []); +}); + +test("returns an empty array for an empty channel list", () => { + assert.deepEqual(filterWorkstreamChannels([]), []); +}); diff --git a/desktop/src/features/workstream-board/lib/discoverWorkstreamChannels.ts b/desktop/src/features/workstream-board/lib/discoverWorkstreamChannels.ts new file mode 100644 index 00000000000..f083a731938 --- /dev/null +++ b/desktop/src/features/workstream-board/lib/discoverWorkstreamChannels.ts @@ -0,0 +1,16 @@ +import type { Channel } from "@/shared/api/types"; + +/** + * Channels whose name starts with this prefix are discovered as workstream + * board entries. There is no creator/ownership filter — any visible channel + * matching the prefix is included, regardless of who created or joined it. + */ +export const WORKSTREAM_CHANNEL_PREFIX = "loganj-ws-"; + +export function filterWorkstreamChannels( + channels: readonly Channel[], +): Channel[] { + return channels.filter((channel) => + channel.name.startsWith(WORKSTREAM_CHANNEL_PREFIX), + ); +} diff --git a/desktop/src/features/workstream-board/lib/workstreamCardParser.test.mjs b/desktop/src/features/workstream-board/lib/workstreamCardParser.test.mjs new file mode 100644 index 00000000000..d9be71d422d --- /dev/null +++ b/desktop/src/features/workstream-board/lib/workstreamCardParser.test.mjs @@ -0,0 +1,208 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { parseWorkstreamCard } from "./workstreamCardParser.ts"; + +// Helper: build a fenced card body from a JSON-serializable payload (or raw +// string, to construct intentionally-invalid-JSON fixtures). +function withCardFence(prose, rawPayload) { + const body = + typeof rawPayload === "string" ? rawPayload : JSON.stringify(rawPayload); + return `${prose}\n\n\`\`\`buzz-workstream-card\n${body}\n\`\`\``; +} + +const VALID_PAYLOAD = { + version: 1, + synopsis: "Implementing the canvas card slice.", + orchestrator: "loganj", + assignees: ["alice", "bob"], +}; + +// ── Happy path ──────────────────────────────────────────────────────────────── + +test("parses a valid v1 card with explicit optional arrays", () => { + const result = parseWorkstreamCard( + withCardFence("Status update:", { + ...VALID_PAYLOAD, + pullRequests: ["https://github.com/block/buzz/pull/1"], + waitingOn: ["review"], + }), + ); + + assert.equal(result.ok, true); + assert.deepEqual(result.card, { + version: 1, + synopsis: VALID_PAYLOAD.synopsis, + orchestrator: VALID_PAYLOAD.orchestrator, + assignees: ["alice", "bob"], + pullRequests: ["https://github.com/block/buzz/pull/1"], + waitingOn: ["review"], + }); +}); + +test("defaults assignees/pullRequests/waitingOn to empty arrays when omitted", () => { + const result = parseWorkstreamCard( + withCardFence("Status update:", VALID_PAYLOAD), + ); + + assert.equal(result.ok, true); + assert.deepEqual(result.card.assignees, ["alice", "bob"]); + assert.deepEqual(result.card.pullRequests, []); + assert.deepEqual(result.card.waitingOn, []); +}); + +test("ignores prose surrounding the fence", () => { + const content = [ + "# Workstream", + "", + "Some human-authored notes above the card.", + "", + "```buzz-workstream-card", + JSON.stringify(VALID_PAYLOAD), + "```", + "", + "Notes below the card too.", + ].join("\n"); + + const result = parseWorkstreamCard(content); + assert.equal(result.ok, true); + assert.equal(result.card.synopsis, VALID_PAYLOAD.synopsis); +}); + +// ── Missing block ───────────────────────────────────────────────────────────── + +test("returns not-found for null content", () => { + assert.deepEqual(parseWorkstreamCard(null), { + ok: false, + reason: "not-found", + }); +}); + +test("returns not-found for empty content", () => { + assert.deepEqual(parseWorkstreamCard(""), { ok: false, reason: "not-found" }); +}); + +test("returns not-found when canvas has prose but no fence", () => { + assert.deepEqual(parseWorkstreamCard("Just some notes, no card here."), { + ok: false, + reason: "not-found", + }); +}); + +// ── Invalid JSON ────────────────────────────────────────────────────────────── + +test("returns invalid-json for malformed JSON inside the fence", () => { + const content = withCardFence("Status:", "{not valid json"); + assert.deepEqual(parseWorkstreamCard(content), { + ok: false, + reason: "invalid-json", + }); +}); + +// ── Duplicate blocks ────────────────────────────────────────────────────────── + +test("returns duplicate-block when the canvas has two card fences", () => { + const content = [ + withCardFence("First:", VALID_PAYLOAD), + "", + withCardFence("Second:", VALID_PAYLOAD), + ].join("\n"); + + assert.deepEqual(parseWorkstreamCard(content), { + ok: false, + reason: "duplicate-block", + }); +}); + +// ── Unknown version ─────────────────────────────────────────────────────────── + +test("returns unknown-version for version 2", () => { + const content = withCardFence("Status:", { ...VALID_PAYLOAD, version: 2 }); + assert.deepEqual(parseWorkstreamCard(content), { + ok: false, + reason: "unknown-version", + }); +}); + +test("returns unknown-version when version is missing", () => { + const { version: _version, ...withoutVersion } = VALID_PAYLOAD; + const content = withCardFence("Status:", withoutVersion); + assert.deepEqual(parseWorkstreamCard(content), { + ok: false, + reason: "unknown-version", + }); +}); + +// ── Missing / invalid required fields ──────────────────────────────────────── + +test("returns invalid-fields when synopsis is missing", () => { + const { synopsis: _synopsis, ...withoutSynopsis } = VALID_PAYLOAD; + const content = withCardFence("Status:", withoutSynopsis); + assert.deepEqual(parseWorkstreamCard(content), { + ok: false, + reason: "invalid-fields", + }); +}); + +test("returns invalid-fields when orchestrator is an empty string", () => { + const content = withCardFence("Status:", { + ...VALID_PAYLOAD, + orchestrator: "", + }); + assert.deepEqual(parseWorkstreamCard(content), { + ok: false, + reason: "invalid-fields", + }); +}); + +test("returns invalid-fields when assignees is not an array", () => { + const content = withCardFence("Status:", { + ...VALID_PAYLOAD, + assignees: "alice", + }); + assert.deepEqual(parseWorkstreamCard(content), { + ok: false, + reason: "invalid-fields", + }); +}); + +test("returns invalid-fields when assignees contains a non-string", () => { + const content = withCardFence("Status:", { + ...VALID_PAYLOAD, + assignees: ["alice", 2], + }); + assert.deepEqual(parseWorkstreamCard(content), { + ok: false, + reason: "invalid-fields", + }); +}); + +test("returns invalid-fields when pullRequests is not an array", () => { + const content = withCardFence("Status:", { + ...VALID_PAYLOAD, + pullRequests: "pr-1", + }); + assert.deepEqual(parseWorkstreamCard(content), { + ok: false, + reason: "invalid-fields", + }); +}); + +test("returns invalid-fields when waitingOn is not an array", () => { + const content = withCardFence("Status:", { + ...VALID_PAYLOAD, + waitingOn: "review", + }); + assert.deepEqual(parseWorkstreamCard(content), { + ok: false, + reason: "invalid-fields", + }); +}); + +test("returns invalid-fields when the payload is a JSON array, not an object", () => { + const content = withCardFence("Status:", [VALID_PAYLOAD]); + assert.deepEqual(parseWorkstreamCard(content), { + ok: false, + reason: "invalid-fields", + }); +}); diff --git a/desktop/src/features/workstream-board/lib/workstreamCardParser.ts b/desktop/src/features/workstream-board/lib/workstreamCardParser.ts new file mode 100644 index 00000000000..f77d3d9101e --- /dev/null +++ b/desktop/src/features/workstream-board/lib/workstreamCardParser.ts @@ -0,0 +1,138 @@ +/** + * Parses the `buzz-workstream-card` sentinel that a channel canvas may embed + * to describe the workstream running in that channel. + * + * Wire format (authored by hand or by an orchestrating agent): + * + * ``` + * ```buzz-workstream-card + * {"version":1,"synopsis":"…","orchestrator":"…","assignees":[…]} + * ``` + * ``` + * + * Only one block per canvas is supported. A missing block, malformed JSON, + * an unrecognized version, or missing/invalid required fields are all + * card-local parse failures — the caller degrades just that card, it never + * throws. + */ + +const FENCE_OPEN = "```buzz-workstream-card"; +const FENCE_CLOSE = "```"; + +export type WorkstreamCardV1 = { + version: 1; + synopsis: string; + orchestrator: string; + assignees: string[]; + pullRequests: unknown[]; + waitingOn: unknown[]; +}; + +export type WorkstreamCardParseFailureReason = + | "not-found" + | "invalid-json" + | "duplicate-block" + | "unknown-version" + | "invalid-fields"; + +export type WorkstreamCardParseResult = + | { ok: true; card: WorkstreamCardV1 } + | { ok: false; reason: WorkstreamCardParseFailureReason }; + +function findFencedBlocks(content: string): string[] { + const blocks: string[] = []; + let cursor = 0; + + while (true) { + const openIdx = content.indexOf(FENCE_OPEN, cursor); + if (openIdx === -1) break; + + const jsonStart = content.indexOf("\n", openIdx); + if (jsonStart === -1) break; + + const closeIdx = content.indexOf(`\n${FENCE_CLOSE}`, jsonStart); + if (closeIdx === -1) break; + + blocks.push(content.slice(jsonStart + 1, closeIdx).trim()); + cursor = closeIdx + `\n${FENCE_CLOSE}`.length; + } + + return blocks; +} + +function isStringArray(value: unknown): value is string[] { + return ( + Array.isArray(value) && value.every((item) => typeof item === "string") + ); +} + +/** + * Parse the single `buzz-workstream-card` block out of a channel canvas. + * Never throws — every failure mode maps to a `WorkstreamCardParseFailureReason`. + */ +export function parseWorkstreamCard( + content: string | null | undefined, +): WorkstreamCardParseResult { + if (!content) { + return { ok: false, reason: "not-found" }; + } + + const blocks = findFencedBlocks(content); + if (blocks.length === 0) { + return { ok: false, reason: "not-found" }; + } + if (blocks.length > 1) { + return { ok: false, reason: "duplicate-block" }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(blocks[0]); + } catch { + return { ok: false, reason: "invalid-json" }; + } + + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return { ok: false, reason: "invalid-fields" }; + } + + const raw = parsed as Record; + + if (raw.version !== 1) { + return { ok: false, reason: "unknown-version" }; + } + + if (typeof raw.synopsis !== "string" || raw.synopsis.trim() === "") { + return { ok: false, reason: "invalid-fields" }; + } + if (typeof raw.orchestrator !== "string" || raw.orchestrator.trim() === "") { + return { ok: false, reason: "invalid-fields" }; + } + + const assignees = raw.assignees ?? []; + if (!isStringArray(assignees)) { + return { ok: false, reason: "invalid-fields" }; + } + + const pullRequests = raw.pullRequests ?? []; + if (!Array.isArray(pullRequests)) { + return { ok: false, reason: "invalid-fields" }; + } + + const waitingOn = raw.waitingOn ?? []; + if (!Array.isArray(waitingOn)) { + return { ok: false, reason: "invalid-fields" }; + } + + return { + ok: true, + card: { + version: 1, + synopsis: raw.synopsis, + orchestrator: raw.orchestrator, + assignees, + pullRequests, + waitingOn, + }, + }; +} diff --git a/desktop/src/features/workstream-board/lib/workstreamCardViewModel.test.mjs b/desktop/src/features/workstream-board/lib/workstreamCardViewModel.test.mjs new file mode 100644 index 00000000000..41ac9e14f43 --- /dev/null +++ b/desktop/src/features/workstream-board/lib/workstreamCardViewModel.test.mjs @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildWorkstreamCardViewModel } from "./workstreamCardViewModel.ts"; + +const VALID_CARD_CONTENT = [ + "```buzz-workstream-card", + JSON.stringify({ + version: 1, + synopsis: "Shipping the canvas card slice.", + orchestrator: "loganj", + assignees: ["alice"], + }), + "```", +].join("\n"); + +test("reports loading while the canvas query is in flight", () => { + const viewModel = buildWorkstreamCardViewModel({ + canvasContent: undefined, + isLoading: true, + isError: false, + }); + assert.deepEqual(viewModel, { status: "loading" }); +}); + +test("degrades to unavailable when the canvas fetch errors", () => { + const viewModel = buildWorkstreamCardViewModel({ + canvasContent: undefined, + isLoading: false, + isError: true, + }); + assert.deepEqual(viewModel, { status: "unavailable" }); +}); + +test("degrades to unavailable when the canvas has no content", () => { + const viewModel = buildWorkstreamCardViewModel({ + canvasContent: null, + isLoading: false, + isError: false, + }); + assert.deepEqual(viewModel, { status: "unavailable" }); +}); + +test("degrades to unavailable when the canvas content fails to parse (card-local failure)", () => { + const viewModel = buildWorkstreamCardViewModel({ + canvasContent: "```buzz-workstream-card\nnot valid json\n```", + isLoading: false, + isError: false, + }); + assert.deepEqual(viewModel, { status: "unavailable" }); +}); + +test("degrades to unavailable for an unknown-version card without surfacing a global error", () => { + const viewModel = buildWorkstreamCardViewModel({ + canvasContent: [ + "```buzz-workstream-card", + JSON.stringify({ version: 2, synopsis: "x", orchestrator: "y" }), + "```", + ].join("\n"), + isLoading: false, + isError: false, + }); + assert.deepEqual(viewModel, { status: "unavailable" }); +}); + +test("returns a ready card when the canvas parses successfully", () => { + const viewModel = buildWorkstreamCardViewModel({ + canvasContent: VALID_CARD_CONTENT, + isLoading: false, + isError: false, + }); + + assert.equal(viewModel.status, "ready"); + assert.equal(viewModel.card.synopsis, "Shipping the canvas card slice."); + assert.equal(viewModel.card.orchestrator, "loganj"); + assert.deepEqual(viewModel.card.assignees, ["alice"]); +}); + +test("loading takes priority over content even if content happens to be malformed", () => { + const viewModel = buildWorkstreamCardViewModel({ + canvasContent: "garbage", + isLoading: true, + isError: false, + }); + assert.deepEqual(viewModel, { status: "loading" }); +}); diff --git a/desktop/src/features/workstream-board/lib/workstreamCardViewModel.ts b/desktop/src/features/workstream-board/lib/workstreamCardViewModel.ts new file mode 100644 index 00000000000..cda900ab2eb --- /dev/null +++ b/desktop/src/features/workstream-board/lib/workstreamCardViewModel.ts @@ -0,0 +1,36 @@ +import { + parseWorkstreamCard, + type WorkstreamCardV1, +} from "@/features/workstream-board/lib/workstreamCardParser"; + +export type WorkstreamCardViewModel = + | { status: "loading" } + | { status: "ready"; card: WorkstreamCardV1 } + /** Canvas fetch failed, canvas is empty, or the card fence is missing/malformed. */ + | { status: "unavailable" }; + +/** + * Bridges the per-channel canvas query state to a render-ready view model. + * A card-local parse failure degrades to "unavailable" the same way a + * failed/missing canvas fetch does — the caller renders channel metadata + * plus an inline unavailable state either way, never a global error. + */ +export function buildWorkstreamCardViewModel(input: { + canvasContent: string | null | undefined; + isLoading: boolean; + isError: boolean; +}): WorkstreamCardViewModel { + if (input.isLoading) { + return { status: "loading" }; + } + if (input.isError) { + return { status: "unavailable" }; + } + + const result = parseWorkstreamCard(input.canvasContent); + if (!result.ok) { + return { status: "unavailable" }; + } + + return { status: "ready", card: result.card }; +} diff --git a/desktop/src/features/workstream-board/ui/WorkstreamBoardScreen.tsx b/desktop/src/features/workstream-board/ui/WorkstreamBoardScreen.tsx new file mode 100644 index 00000000000..3e4b06bb011 --- /dev/null +++ b/desktop/src/features/workstream-board/ui/WorkstreamBoardScreen.tsx @@ -0,0 +1,67 @@ +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { useChannelsQuery } from "@/features/channels/hooks"; +import { filterWorkstreamChannels } from "@/features/workstream-board/lib/discoverWorkstreamChannels"; +import { WorkstreamCard } from "@/features/workstream-board/ui/WorkstreamCard"; +import { Button } from "@/shared/ui/button"; +import { PageHeader } from "@/shared/ui/PageHeader"; + +const WORKSTREAM_CARD_GRID_CLASS = + "grid grid-cols-1 gap-3 [@container(min-width:38rem)]:grid-cols-2 [@container(min-width:54rem)]:grid-cols-3"; + +export function WorkstreamBoardScreen() { + const { goChannel } = useAppNavigation(); + const channelsQuery = useChannelsQuery(); + const channels = channelsQuery.data ?? []; + const workstreamChannels = filterWorkstreamChannels(channels); + + return ( +
+
+
+ + + {channelsQuery.isLoading ? ( +

+ Loading workstreams… +

+ ) : channelsQuery.isError ? ( +
+

Failed to load channels

+ +
+ ) : workstreamChannels.length === 0 ? ( +

+ No workstream channels found. Channels named "loganj-ws-…" will + appear here. +

+ ) : ( +
+ {workstreamChannels.map((channel) => ( + void goChannel(channelId)} + /> + ))} +
+ )} +
+
+
+ ); +} diff --git a/desktop/src/features/workstream-board/ui/WorkstreamCard.tsx b/desktop/src/features/workstream-board/ui/WorkstreamCard.tsx new file mode 100644 index 00000000000..111f8d88e54 --- /dev/null +++ b/desktop/src/features/workstream-board/ui/WorkstreamCard.tsx @@ -0,0 +1,88 @@ +import { Hash } from "lucide-react"; + +import { useCanvasQuery } from "@/features/channels/hooks"; +import { buildWorkstreamCardViewModel } from "@/features/workstream-board/lib/workstreamCardViewModel"; +import type { Channel } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; + +type WorkstreamCardProps = { + channel: Channel; + onSelect: (channelId: string) => void; +}; + +export function WorkstreamCard({ channel, onSelect }: WorkstreamCardProps) { + const canvasQuery = useCanvasQuery(channel.id); + const viewModel = buildWorkstreamCardViewModel({ + canvasContent: canvasQuery.data?.content, + isLoading: canvasQuery.isLoading, + isError: canvasQuery.isError, + }); + + return ( +
+ + +
+
+ + {channel.name} +
+ + {viewModel.status === "ready" ? ( + <> +

+ {viewModel.card.synopsis} +

+
+

+ Orchestrator:{" "} + + {viewModel.card.orchestrator} + +

+ {viewModel.card.assignees.length > 0 ? ( +
+ {viewModel.card.assignees.map((assignee) => ( + + {assignee} + + ))} +
+ ) : null} +
+ + ) : viewModel.status === "loading" ? ( +

Loading…

+ ) : ( +
+

+ Card details unavailable +

+ {channel.description ? ( +

+ {channel.description} +

+ ) : null} +
+ )} +
+
+ ); +} diff --git a/preview-features.json b/preview-features.json index 388f1c39b04..f99d0144295 100644 --- a/preview-features.json +++ b/preview-features.json @@ -30,6 +30,12 @@ "name": "Agent-managed profiles", "description": "Let agents manage their own relay name and avatar instead of restoring the desktop copy", "platforms": ["desktop"] + }, + { + "id": "workstreamBoard", + "name": "Workstream Board", + "description": "Live board of workstream channel canvases", + "platforms": ["desktop"] } ] } From ce9adfe554ad64c7eceaee6e9c3d001793dd6003 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 17 Aug 2026 17:55:13 +0000 Subject: [PATCH 2/5] fix(desktop): exclude archived channels from workstream board discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sq agents review flagged that the prefix-only discovery filter let an archived loganj-ws-* channel keep appearing as an active board card. Every other active-channel surface in this codebase (sidebar, search, channel browser, agent autocomplete) excludes archivedAt !== null, so apply the same rule here. The review's second finding — one relay canvas query per rendered card via useCanvasQuery — is the contract's specified design ("per matching channel canvas fetch via existing canvas query/API paths") for this bullet; batching/limiting canvas fetches is left for a later slice rather than implemented as an unscoped addition here. Signed-off-by: loganj Co-authored-by: Claude Code --- .../lib/discoverWorkstreamChannels.test.mjs | 19 ++++++++++++++++++- .../lib/discoverWorkstreamChannels.ts | 11 +++++++---- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/desktop/src/features/workstream-board/lib/discoverWorkstreamChannels.test.mjs b/desktop/src/features/workstream-board/lib/discoverWorkstreamChannels.test.mjs index b2ea2f89c4c..e24ccaba963 100644 --- a/desktop/src/features/workstream-board/lib/discoverWorkstreamChannels.test.mjs +++ b/desktop/src/features/workstream-board/lib/discoverWorkstreamChannels.test.mjs @@ -18,7 +18,7 @@ function buildChannel(overrides) { memberCount: overrides.memberPubkeys?.length ?? 0, memberPubkeys: overrides.memberPubkeys ?? [], lastMessageAt: null, - archivedAt: null, + archivedAt: overrides.archivedAt ?? null, participants: [], participantPubkeys: [], isMember: overrides.isMember ?? false, @@ -103,6 +103,23 @@ test("applies no creator/membership filter — every matching name is included r ]); }); +test("excludes an archived channel even when its name matches the prefix", () => { + const channels = [ + buildChannel({ + id: "1", + name: "loganj-ws-done", + archivedAt: "2026-01-01T00:00:00Z", + }), + buildChannel({ id: "2", name: "loganj-ws-active" }), + ]; + + const result = filterWorkstreamChannels(channels); + assert.deepEqual( + result.map((c) => c.id), + ["2"], + ); +}); + test("returns an empty array when nothing matches", () => { const channels = [ buildChannel({ id: "1", name: "general" }), diff --git a/desktop/src/features/workstream-board/lib/discoverWorkstreamChannels.ts b/desktop/src/features/workstream-board/lib/discoverWorkstreamChannels.ts index f083a731938..11f3dd0daea 100644 --- a/desktop/src/features/workstream-board/lib/discoverWorkstreamChannels.ts +++ b/desktop/src/features/workstream-board/lib/discoverWorkstreamChannels.ts @@ -2,15 +2,18 @@ import type { Channel } from "@/shared/api/types"; /** * Channels whose name starts with this prefix are discovered as workstream - * board entries. There is no creator/ownership filter — any visible channel - * matching the prefix is included, regardless of who created or joined it. + * board entries. There is no creator/ownership filter — any visible, + * non-archived channel matching the prefix is included, regardless of who + * created or joined it. */ export const WORKSTREAM_CHANNEL_PREFIX = "loganj-ws-"; export function filterWorkstreamChannels( channels: readonly Channel[], ): Channel[] { - return channels.filter((channel) => - channel.name.startsWith(WORKSTREAM_CHANNEL_PREFIX), + return channels.filter( + (channel) => + channel.name.startsWith(WORKSTREAM_CHANNEL_PREFIX) && + channel.archivedAt === null, ); } From ebed129e44fc1c96e18d74f8eab6d95208fd4e34 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 17 Aug 2026 18:48:40 +0000 Subject: [PATCH 3/5] fix(desktop): align workstream card identity schema Signed-off-by: loganj Co-authored-by: Codex --- .../lib/workstreamCardParser.test.mjs | 63 +++++++++++++-- .../lib/workstreamCardParser.ts | 81 +++++++++++++------ .../lib/workstreamCardViewModel.test.mjs | 13 ++- .../workstream-board/ui/WorkstreamCard.tsx | 6 +- 4 files changed, 122 insertions(+), 41 deletions(-) diff --git a/desktop/src/features/workstream-board/lib/workstreamCardParser.test.mjs b/desktop/src/features/workstream-board/lib/workstreamCardParser.test.mjs index d9be71d422d..d5b4d6c337c 100644 --- a/desktop/src/features/workstream-board/lib/workstreamCardParser.test.mjs +++ b/desktop/src/features/workstream-board/lib/workstreamCardParser.test.mjs @@ -14,8 +14,11 @@ function withCardFence(prose, rawPayload) { const VALID_PAYLOAD = { version: 1, synopsis: "Implementing the canvas card slice.", - orchestrator: "loganj", - assignees: ["alice", "bob"], + orchestrator: { pubkey: "loganj-pubkey", name: "Logan" }, + assignees: [ + { pubkey: "alice-pubkey", name: "Alice" }, + { pubkey: "bob-pubkey", name: "Bob" }, + ], }; // ── Happy path ──────────────────────────────────────────────────────────────── @@ -34,7 +37,7 @@ test("parses a valid v1 card with explicit optional arrays", () => { version: 1, synopsis: VALID_PAYLOAD.synopsis, orchestrator: VALID_PAYLOAD.orchestrator, - assignees: ["alice", "bob"], + assignees: VALID_PAYLOAD.assignees, pullRequests: ["https://github.com/block/buzz/pull/1"], waitingOn: ["review"], }); @@ -46,7 +49,7 @@ test("defaults assignees/pullRequests/waitingOn to empty arrays when omitted", ( ); assert.equal(result.ok, true); - assert.deepEqual(result.card.assignees, ["alice", "bob"]); + assert.deepEqual(result.card.assignees, VALID_PAYLOAD.assignees); assert.deepEqual(result.card.pullRequests, []); assert.deepEqual(result.card.waitingOn, []); }); @@ -144,10 +147,10 @@ test("returns invalid-fields when synopsis is missing", () => { }); }); -test("returns invalid-fields when orchestrator is an empty string", () => { +test("returns invalid-fields when orchestrator is missing its identity fields", () => { const content = withCardFence("Status:", { ...VALID_PAYLOAD, - orchestrator: "", + orchestrator: { name: "Logan" }, }); assert.deepEqual(parseWorkstreamCard(content), { ok: false, @@ -166,10 +169,10 @@ test("returns invalid-fields when assignees is not an array", () => { }); }); -test("returns invalid-fields when assignees contains a non-string", () => { +test("returns invalid-fields when assignees contains an invalid identity", () => { const content = withCardFence("Status:", { ...VALID_PAYLOAD, - assignees: ["alice", 2], + assignees: [{ pubkey: "alice-pubkey", name: "Alice" }, "bob"], }); assert.deepEqual(parseWorkstreamCard(content), { ok: false, @@ -199,6 +202,28 @@ test("returns invalid-fields when waitingOn is not an array", () => { }); }); +test("rejects the former string identity schema for orchestrator", () => { + const content = withCardFence("Status:", { + ...VALID_PAYLOAD, + orchestrator: "loganj", + }); + assert.deepEqual(parseWorkstreamCard(content), { + ok: false, + reason: "invalid-fields", + }); +}); + +test("rejects the former string identity schema for assignees", () => { + const content = withCardFence("Status:", { + ...VALID_PAYLOAD, + assignees: ["alice"], + }); + assert.deepEqual(parseWorkstreamCard(content), { + ok: false, + reason: "invalid-fields", + }); +}); + test("returns invalid-fields when the payload is a JSON array, not an object", () => { const content = withCardFence("Status:", [VALID_PAYLOAD]); assert.deepEqual(parseWorkstreamCard(content), { @@ -206,3 +231,25 @@ test("returns invalid-fields when the payload is a JSON array, not an object", ( reason: "invalid-fields", }); }); + +test("returns invalid-fields when synopsis spans multiple lines", () => { + const content = withCardFence("Status:", { + ...VALID_PAYLOAD, + synopsis: "line one\nline two", + }); + assert.deepEqual(parseWorkstreamCard(content), { + ok: false, + reason: "invalid-fields", + }); +}); + +test("returns invalid-fields when optional arrays are explicitly null", () => { + const content = withCardFence("Status:", { + ...VALID_PAYLOAD, + pullRequests: null, + }); + assert.deepEqual(parseWorkstreamCard(content), { + ok: false, + reason: "invalid-fields", + }); +}); diff --git a/desktop/src/features/workstream-board/lib/workstreamCardParser.ts b/desktop/src/features/workstream-board/lib/workstreamCardParser.ts index f77d3d9101e..63083189597 100644 --- a/desktop/src/features/workstream-board/lib/workstreamCardParser.ts +++ b/desktop/src/features/workstream-board/lib/workstreamCardParser.ts @@ -6,7 +6,7 @@ * * ``` * ```buzz-workstream-card - * {"version":1,"synopsis":"…","orchestrator":"…","assignees":[…]} + * {"version":1,"synopsis":"…","orchestrator":{"pubkey":"…","name":"…"},"assignees":[{"pubkey":"…","name":"…"}]} * ``` * ``` * @@ -19,11 +19,16 @@ const FENCE_OPEN = "```buzz-workstream-card"; const FENCE_CLOSE = "```"; +export type WorkstreamIdentity = { + pubkey: string; + name: string; +}; + export type WorkstreamCardV1 = { version: 1; synopsis: string; - orchestrator: string; - assignees: string[]; + orchestrator: WorkstreamIdentity; + assignees: WorkstreamIdentity[]; pullRequests: unknown[]; waitingOn: unknown[]; }; @@ -40,30 +45,51 @@ export type WorkstreamCardParseResult = | { ok: false; reason: WorkstreamCardParseFailureReason }; function findFencedBlocks(content: string): string[] { + const lines = content.split(/\r?\n/); const blocks: string[] = []; - let cursor = 0; - while (true) { - const openIdx = content.indexOf(FENCE_OPEN, cursor); - if (openIdx === -1) break; + for (let index = 0; index < lines.length; index += 1) { + if (lines[index].trimEnd() !== FENCE_OPEN) continue; + + const body: string[] = []; + let closeIndex = index + 1; + while ( + closeIndex < lines.length && + lines[closeIndex].trim() !== FENCE_CLOSE + ) { + body.push(lines[closeIndex]); + closeIndex += 1; + } + if (closeIndex === lines.length) break; + + blocks.push(body.join("\n").trim()); + index = closeIndex; + } + + return blocks; +} - const jsonStart = content.indexOf("\n", openIdx); - if (jsonStart === -1) break; +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim() !== ""; +} - const closeIdx = content.indexOf(`\n${FENCE_CLOSE}`, jsonStart); - if (closeIdx === -1) break; +function isOneLineString(value: unknown): value is string { + return isNonEmptyString(value) && !/[\r\n]/.test(value); +} - blocks.push(content.slice(jsonStart + 1, closeIdx).trim()); - cursor = closeIdx + `\n${FENCE_CLOSE}`.length; +function isWorkstreamIdentity(value: unknown): value is WorkstreamIdentity { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return false; } - return blocks; + const raw = value as Record; + return isNonEmptyString(raw.pubkey) && isNonEmptyString(raw.name); } -function isStringArray(value: unknown): value is string[] { - return ( - Array.isArray(value) && value.every((item) => typeof item === "string") - ); +function isWorkstreamIdentityArray( + value: unknown, +): value is WorkstreamIdentity[] { + return Array.isArray(value) && value.every(isWorkstreamIdentity); } /** @@ -102,24 +128,27 @@ export function parseWorkstreamCard( return { ok: false, reason: "unknown-version" }; } - if (typeof raw.synopsis !== "string" || raw.synopsis.trim() === "") { + const synopsis = raw.synopsis; + if (!isOneLineString(synopsis)) { return { ok: false, reason: "invalid-fields" }; } - if (typeof raw.orchestrator !== "string" || raw.orchestrator.trim() === "") { + + const orchestrator = raw.orchestrator; + if (!isWorkstreamIdentity(orchestrator)) { return { ok: false, reason: "invalid-fields" }; } - const assignees = raw.assignees ?? []; - if (!isStringArray(assignees)) { + const assignees = raw.assignees === undefined ? [] : raw.assignees; + if (!isWorkstreamIdentityArray(assignees)) { return { ok: false, reason: "invalid-fields" }; } - const pullRequests = raw.pullRequests ?? []; + const pullRequests = raw.pullRequests === undefined ? [] : raw.pullRequests; if (!Array.isArray(pullRequests)) { return { ok: false, reason: "invalid-fields" }; } - const waitingOn = raw.waitingOn ?? []; + const waitingOn = raw.waitingOn === undefined ? [] : raw.waitingOn; if (!Array.isArray(waitingOn)) { return { ok: false, reason: "invalid-fields" }; } @@ -128,8 +157,8 @@ export function parseWorkstreamCard( ok: true, card: { version: 1, - synopsis: raw.synopsis, - orchestrator: raw.orchestrator, + synopsis, + orchestrator, assignees, pullRequests, waitingOn, diff --git a/desktop/src/features/workstream-board/lib/workstreamCardViewModel.test.mjs b/desktop/src/features/workstream-board/lib/workstreamCardViewModel.test.mjs index 41ac9e14f43..b2b9f37cfdd 100644 --- a/desktop/src/features/workstream-board/lib/workstreamCardViewModel.test.mjs +++ b/desktop/src/features/workstream-board/lib/workstreamCardViewModel.test.mjs @@ -8,8 +8,8 @@ const VALID_CARD_CONTENT = [ JSON.stringify({ version: 1, synopsis: "Shipping the canvas card slice.", - orchestrator: "loganj", - assignees: ["alice"], + orchestrator: { pubkey: "loganj-pubkey", name: "Logan" }, + assignees: [{ pubkey: "alice-pubkey", name: "Alice" }], }), "```", ].join("\n"); @@ -72,8 +72,13 @@ test("returns a ready card when the canvas parses successfully", () => { assert.equal(viewModel.status, "ready"); assert.equal(viewModel.card.synopsis, "Shipping the canvas card slice."); - assert.equal(viewModel.card.orchestrator, "loganj"); - assert.deepEqual(viewModel.card.assignees, ["alice"]); + assert.deepEqual(viewModel.card.orchestrator, { + pubkey: "loganj-pubkey", + name: "Logan", + }); + assert.deepEqual(viewModel.card.assignees, [ + { pubkey: "alice-pubkey", name: "Alice" }, + ]); }); test("loading takes priority over content even if content happens to be malformed", () => { diff --git a/desktop/src/features/workstream-board/ui/WorkstreamCard.tsx b/desktop/src/features/workstream-board/ui/WorkstreamCard.tsx index 111f8d88e54..6d07ba0d341 100644 --- a/desktop/src/features/workstream-board/ui/WorkstreamCard.tsx +++ b/desktop/src/features/workstream-board/ui/WorkstreamCard.tsx @@ -48,7 +48,7 @@ export function WorkstreamCard({ channel, onSelect }: WorkstreamCardProps) {

Orchestrator:{" "} - {viewModel.card.orchestrator} + {viewModel.card.orchestrator.name}

{viewModel.card.assignees.length > 0 ? ( @@ -56,9 +56,9 @@ export function WorkstreamCard({ channel, onSelect }: WorkstreamCardProps) { {viewModel.card.assignees.map((assignee) => ( - {assignee} + {assignee.name} ))} From 0caaa62104381df949cd9ec29b77d02cfa05df6e Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 17 Aug 2026 20:11:28 +0000 Subject: [PATCH 4/5] fix(desktop): restrict workstream discovery to members Signed-off-by: loganj --- .../lib/discoverWorkstreamChannels.test.mjs | 51 ++++++++++++------- .../lib/discoverWorkstreamChannels.ts | 10 ++-- 2 files changed, 37 insertions(+), 24 deletions(-) diff --git a/desktop/src/features/workstream-board/lib/discoverWorkstreamChannels.test.mjs b/desktop/src/features/workstream-board/lib/discoverWorkstreamChannels.test.mjs index e24ccaba963..e1ef2b59f06 100644 --- a/desktop/src/features/workstream-board/lib/discoverWorkstreamChannels.test.mjs +++ b/desktop/src/features/workstream-board/lib/discoverWorkstreamChannels.test.mjs @@ -21,7 +21,7 @@ function buildChannel(overrides) { archivedAt: overrides.archivedAt ?? null, participants: [], participantPubkeys: [], - isMember: overrides.isMember ?? false, + isMember: overrides.isMember ?? true, ttlSeconds: null, ttlDeadline: null, }; @@ -31,10 +31,10 @@ test("prefix constant matches the contract-specified prefix", () => { assert.equal(WORKSTREAM_CHANNEL_PREFIX, "loganj-ws-"); }); -test("includes channels whose name starts exactly with the prefix", () => { +test("includes joined channels whose name starts exactly with the prefix", () => { const channels = [ - buildChannel({ id: "1", name: "loganj-ws-canvas-cards" }), - buildChannel({ id: "2", name: "general" }), + buildChannel({ id: "1", name: "loganj-ws-canvas-cards", isMember: true }), + buildChannel({ id: "2", name: "general", isMember: true }), ]; const result = filterWorkstreamChannels(channels); @@ -44,6 +44,23 @@ test("includes channels whose name starts exactly with the prefix", () => { ); }); +test("excludes prefixed channels the current user has not joined", () => { + const channels = [ + buildChannel({ id: "joined", name: "loganj-ws-joined", isMember: true }), + buildChannel({ + id: "unjoined", + name: "loganj-ws-unjoined", + isMember: false, + }), + ]; + + const result = filterWorkstreamChannels(channels); + assert.deepEqual( + result.map((c) => c.id), + ["joined"], + ); +}); + test("excludes channels that merely contain the prefix mid-name", () => { const channels = [ buildChannel({ id: "1", name: "not-loganj-ws-canvas-cards" }), @@ -70,11 +87,11 @@ test("excludes a near-miss name missing the trailing hyphen", () => { ); }); -test("applies no creator/membership filter — every matching name is included regardless of who created or joined it", () => { +test("includes every joined matching channel without a creator filter", () => { const channels = [ - // Different member sets stand in for "different creators" — the Channel - // type carries no creator field on the list endpoint, so membership - // overlap is the only axis available to prove no ownership filtering. + // The list response has no creator field. Distinct membership sets prove + // that matching joined channels are included without creator/ownership + // filtering, while the unjoined control remains excluded. buildChannel({ id: "mine", name: "loganj-ws-mine", @@ -82,25 +99,21 @@ test("applies no creator/membership filter — every matching name is included r memberPubkeys: ["aa"], }), buildChannel({ - id: "someone-elses", - name: "loganj-ws-someone-elses", - isMember: false, + id: "delegated", + name: "loganj-ws-delegated", + isMember: true, memberPubkeys: ["bb", "cc"], }), buildChannel({ - id: "no-members", - name: "loganj-ws-empty", + id: "unjoined", + name: "loganj-ws-unjoined", isMember: false, - memberPubkeys: [], + memberPubkeys: ["dd"], }), ]; const result = filterWorkstreamChannels(channels); - assert.deepEqual(result.map((c) => c.id).sort(), [ - "mine", - "no-members", - "someone-elses", - ]); + assert.deepEqual(result.map((c) => c.id).sort(), ["delegated", "mine"]); }); test("excludes an archived channel even when its name matches the prefix", () => { diff --git a/desktop/src/features/workstream-board/lib/discoverWorkstreamChannels.ts b/desktop/src/features/workstream-board/lib/discoverWorkstreamChannels.ts index 11f3dd0daea..4488f2c0a75 100644 --- a/desktop/src/features/workstream-board/lib/discoverWorkstreamChannels.ts +++ b/desktop/src/features/workstream-board/lib/discoverWorkstreamChannels.ts @@ -2,9 +2,8 @@ import type { Channel } from "@/shared/api/types"; /** * Channels whose name starts with this prefix are discovered as workstream - * board entries. There is no creator/ownership filter — any visible, - * non-archived channel matching the prefix is included, regardless of who - * created or joined it. + * board entries. Discovery is limited to visible channels the current user + * has joined; there is no creator/ownership filter. */ export const WORKSTREAM_CHANNEL_PREFIX = "loganj-ws-"; @@ -13,7 +12,8 @@ export function filterWorkstreamChannels( ): Channel[] { return channels.filter( (channel) => - channel.name.startsWith(WORKSTREAM_CHANNEL_PREFIX) && - channel.archivedAt === null, + channel.isMember && + channel.archivedAt === null && + channel.name.startsWith(WORKSTREAM_CHANNEL_PREFIX), ); } From ab58a51819d856be2061393480230892abd6ea65 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 18 Aug 2026 13:48:39 +0000 Subject: [PATCH 5/5] test(desktop): scroll sidebar drag source into view Signed-off-by: loganj --- .../src/features/sidebar/ui/AppSidebar.tsx | 1 + .../sidebar/ui/AppSidebarPinnedHeader.tsx | 19 +++++++++++++++++-- desktop/tests/e2e/virtualization.spec.ts | 4 ++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 6e2443dcc3a..a4b6805fc01 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -111,6 +111,7 @@ export function AppSidebar({ onSelectProjects, onSelectPulse, onSelectWorkflows, + onSelectWorkstreamBoard, onSelectHome, onSelectChannel, onOpenSearchResult, diff --git a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx index f0f25614eac..d9a909fa5c3 100644 --- a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx @@ -1,4 +1,4 @@ -import { Activity, Bot, Folders, Inbox, Zap } from "lucide-react"; +import { Activity, Bot, Folders, Inbox, Kanban, Zap } from "lucide-react"; import { TopbarSearch } from "@/features/search/ui/TopbarSearch"; import { SidebarProjectsSection } from "@/features/sidebar/ui/SidebarProjectsSection"; @@ -20,7 +20,8 @@ type SidebarSelectedView = | "agents" | "workflows" | "pulse" - | "projects"; + | "projects" + | "workstreamBoard"; type AppSidebarPinnedHeaderProps = { channelLabels: Record; @@ -183,6 +184,20 @@ export function AppSidebarPrimaryMenu({ + + + + + Workstream Board + + + diff --git a/desktop/tests/e2e/virtualization.spec.ts b/desktop/tests/e2e/virtualization.spec.ts index f7a44f05c72..3419a86119f 100644 --- a/desktop/tests/e2e/virtualization.spec.ts +++ b/desktop/tests/e2e/virtualization.spec.ts @@ -31,6 +31,9 @@ async function seedChannelSections(page: Page) { // pointer down, past the activation threshold, onto the target, then releases — // the sequence dnd-kit needs to fire onDragEnd and commit the reorder. async function dragOver(page: Page, source: Locator, target: Locator) { + await source.evaluate((element) => + element.scrollIntoView({ block: "center" }), + ); const from = await source.boundingBox(); if (!from) throw new Error("drag source not laid out"); const pointer = { @@ -95,6 +98,7 @@ async function dragOver(page: Page, source: Locator, target: Locator) { }), ); }, destination); + await expect(page.getByTestId("sidebar-section-drag-overlay")).toBeHidden(); } test.describe("list virtualization", () => {