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..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, @@ -511,6 +512,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..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; @@ -45,6 +46,7 @@ type AppSidebarPrimaryMenuProps = { onSelectProjects: () => void; onSelectPulse: () => void; onSelectWorkflows: () => void; + onSelectWorkstreamBoard: () => void; projectsOverviewActive: boolean; selectedView: SidebarSelectedView; }; @@ -95,6 +97,7 @@ export function AppSidebarPrimaryMenu({ onSelectProjects, onSelectPulse, onSelectWorkflows, + onSelectWorkstreamBoard, projectsOverviewActive, selectedView, }: AppSidebarPrimaryMenuProps) { @@ -181,6 +184,20 @@ export function AppSidebarPrimaryMenu({ + + + + + Workstream Board + + + 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..e1ef2b59f06 --- /dev/null +++ b/desktop/src/features/workstream-board/lib/discoverWorkstreamChannels.test.mjs @@ -0,0 +1,147 @@ +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: overrides.archivedAt ?? null, + participants: [], + participantPubkeys: [], + isMember: overrides.isMember ?? true, + ttlSeconds: null, + ttlDeadline: null, + }; +} + +test("prefix constant matches the contract-specified prefix", () => { + assert.equal(WORKSTREAM_CHANNEL_PREFIX, "loganj-ws-"); +}); + +test("includes joined channels whose name starts exactly with the prefix", () => { + const channels = [ + buildChannel({ id: "1", name: "loganj-ws-canvas-cards", isMember: true }), + buildChannel({ id: "2", name: "general", isMember: true }), + ]; + + const result = filterWorkstreamChannels(channels); + assert.deepEqual( + result.map((c) => c.id), + ["1"], + ); +}); + +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" }), + 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("includes every joined matching channel without a creator filter", () => { + const channels = [ + // 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", + isMember: true, + memberPubkeys: ["aa"], + }), + buildChannel({ + id: "delegated", + name: "loganj-ws-delegated", + isMember: true, + memberPubkeys: ["bb", "cc"], + }), + buildChannel({ + id: "unjoined", + name: "loganj-ws-unjoined", + isMember: false, + memberPubkeys: ["dd"], + }), + ]; + + const result = filterWorkstreamChannels(channels); + assert.deepEqual(result.map((c) => c.id).sort(), ["delegated", "mine"]); +}); + +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" }), + 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..4488f2c0a75 --- /dev/null +++ b/desktop/src/features/workstream-board/lib/discoverWorkstreamChannels.ts @@ -0,0 +1,19 @@ +import type { Channel } from "@/shared/api/types"; + +/** + * Channels whose name starts with this prefix are discovered as workstream + * 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-"; + +export function filterWorkstreamChannels( + channels: readonly Channel[], +): Channel[] { + return channels.filter( + (channel) => + channel.isMember && + channel.archivedAt === null && + 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..d5b4d6c337c --- /dev/null +++ b/desktop/src/features/workstream-board/lib/workstreamCardParser.test.mjs @@ -0,0 +1,255 @@ +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: { pubkey: "loganj-pubkey", name: "Logan" }, + assignees: [ + { pubkey: "alice-pubkey", name: "Alice" }, + { pubkey: "bob-pubkey", name: "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: VALID_PAYLOAD.assignees, + 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, VALID_PAYLOAD.assignees); + 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 missing its identity fields", () => { + const content = withCardFence("Status:", { + ...VALID_PAYLOAD, + orchestrator: { name: "Logan" }, + }); + 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 an invalid identity", () => { + const content = withCardFence("Status:", { + ...VALID_PAYLOAD, + assignees: [{ pubkey: "alice-pubkey", name: "Alice" }, "bob"], + }); + 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("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), { + ok: false, + 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 new file mode 100644 index 00000000000..63083189597 --- /dev/null +++ b/desktop/src/features/workstream-board/lib/workstreamCardParser.ts @@ -0,0 +1,167 @@ +/** + * 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":{"pubkey":"…","name":"…"},"assignees":[{"pubkey":"…","name":"…"}]} + * ``` + * ``` + * + * 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 WorkstreamIdentity = { + pubkey: string; + name: string; +}; + +export type WorkstreamCardV1 = { + version: 1; + synopsis: string; + orchestrator: WorkstreamIdentity; + assignees: WorkstreamIdentity[]; + 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 lines = content.split(/\r?\n/); + const blocks: string[] = []; + + 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; +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim() !== ""; +} + +function isOneLineString(value: unknown): value is string { + return isNonEmptyString(value) && !/[\r\n]/.test(value); +} + +function isWorkstreamIdentity(value: unknown): value is WorkstreamIdentity { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return false; + } + + const raw = value as Record; + return isNonEmptyString(raw.pubkey) && isNonEmptyString(raw.name); +} + +function isWorkstreamIdentityArray( + value: unknown, +): value is WorkstreamIdentity[] { + return Array.isArray(value) && value.every(isWorkstreamIdentity); +} + +/** + * 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" }; + } + + const synopsis = raw.synopsis; + if (!isOneLineString(synopsis)) { + return { ok: false, reason: "invalid-fields" }; + } + + const orchestrator = raw.orchestrator; + if (!isWorkstreamIdentity(orchestrator)) { + return { ok: false, reason: "invalid-fields" }; + } + + const assignees = raw.assignees === undefined ? [] : raw.assignees; + if (!isWorkstreamIdentityArray(assignees)) { + return { ok: false, reason: "invalid-fields" }; + } + + const pullRequests = raw.pullRequests === undefined ? [] : raw.pullRequests; + if (!Array.isArray(pullRequests)) { + return { ok: false, reason: "invalid-fields" }; + } + + const waitingOn = raw.waitingOn === undefined ? [] : raw.waitingOn; + if (!Array.isArray(waitingOn)) { + return { ok: false, reason: "invalid-fields" }; + } + + return { + ok: true, + card: { + version: 1, + 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 new file mode 100644 index 00000000000..b2b9f37cfdd --- /dev/null +++ b/desktop/src/features/workstream-board/lib/workstreamCardViewModel.test.mjs @@ -0,0 +1,91 @@ +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: { pubkey: "loganj-pubkey", name: "Logan" }, + assignees: [{ pubkey: "alice-pubkey", name: "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.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", () => { + 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..6d07ba0d341 --- /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.name} + +

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

Loading…

+ ) : ( +
+

+ Card details unavailable +

+ {channel.description ? ( +

+ {channel.description} +

+ ) : null} +
+ )} +
+
+ ); +} 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", () => { 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"] } ] }