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 <loganj@squareup.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
This commit is contained in:
Logan Johnson
2026-08-17 17:47:47 +00:00
parent 2b7be9340d
commit 00acaf4948
18 changed files with 838 additions and 2 deletions

View File

@@ -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,

View File

@@ -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)
}

View File

@@ -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,
};
}

View File

@@ -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,

View File

@@ -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(

View File

@@ -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 (
<React.Suspense fallback={<ViewLoadingFallback kind="projects" />}>
<WorkstreamBoardScreen />
</React.Suspense>
);
}

View File

@@ -511,6 +511,7 @@ export function AppSidebar({
onSelectProjects={onSelectProjects}
onSelectPulse={onSelectPulse}
onSelectWorkflows={onSelectWorkflows}
onSelectWorkstreamBoard={onSelectWorkstreamBoard}
projectsOverviewActive={projectsOverviewActive}
selectedView={selectedView}
/>

View File

@@ -44,7 +44,8 @@ export type AppSidebarProps = {
| "agents"
| "workflows"
| "pulse"
| "projects";
| "projects"
| "workstreamBoard";
unreadChannelCounts: ReadonlyMap<string, number>;
unreadChannelIds: ReadonlySet<string>;
previewActivityChannelIds: ReadonlySet<string>;
@@ -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;

View File

@@ -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) {

View File

@@ -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([]), []);
});

View File

@@ -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),
);
}

View File

@@ -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",
});
});

View File

@@ -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<string, unknown>;
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,
},
};
}

View File

@@ -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" });
});

View File

@@ -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 };
}

View File

@@ -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 (
<div
className="relative flex min-h-0 flex-1 overflow-hidden"
data-testid="workstream-board-view"
>
<div
className="flex min-h-0 flex-1 flex-col overflow-y-auto overflow-x-hidden overscroll-contain px-4 py-7 sm:px-6 sm:py-8"
data-scroll-restoration-id="workstream-board-list"
>
<div className="mx-auto w-full max-w-6xl space-y-8 [container-type:inline-size]">
<PageHeader
description="Live canvases for active workstream channels."
title="Workstream Board"
/>
{channelsQuery.isLoading ? (
<p className="text-sm text-muted-foreground">
Loading workstreams…
</p>
) : channelsQuery.isError ? (
<div className="flex flex-col items-center justify-center gap-2 py-16 text-muted-foreground">
<p className="text-sm text-red-400">Failed to load channels</p>
<Button
onClick={() => void channelsQuery.refetch()}
size="sm"
variant="outline"
>
Retry
</Button>
</div>
) : workstreamChannels.length === 0 ? (
<p className="text-sm text-muted-foreground">
No workstream channels found. Channels named "loganj-ws-…" will
appear here.
</p>
) : (
<div className={WORKSTREAM_CARD_GRID_CLASS}>
{workstreamChannels.map((channel) => (
<WorkstreamCard
channel={channel}
key={channel.id}
onSelect={(channelId) => void goChannel(channelId)}
/>
))}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -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 (
<div
className={cn(
"group relative min-h-48 w-full overflow-hidden rounded-2xl border border-border/70 bg-muted/50 p-5 text-left text-foreground shadow-xs transition-all hover:-translate-y-0.5 hover:border-border hover:bg-muted/65 hover:shadow-md",
)}
data-testid={`workstream-card-${channel.id}`}
>
<button
className="absolute inset-0 z-0 rounded-2xl focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
onClick={() => onSelect(channel.id)}
type="button"
>
<span className="sr-only">Open #{channel.name}</span>
</button>
<div className="pointer-events-none relative z-10 flex h-full min-h-40 flex-col">
<div className="flex items-center gap-1.5 text-xs font-semibold text-muted-foreground">
<Hash className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{channel.name}</span>
</div>
{viewModel.status === "ready" ? (
<>
<p className="mt-3 line-clamp-3 text-sm leading-relaxed text-foreground">
{viewModel.card.synopsis}
</p>
<div className="mt-auto flex flex-col gap-2 pt-4">
<p className="truncate text-2xs text-muted-foreground">
Orchestrator:{" "}
<span className="text-foreground">
{viewModel.card.orchestrator}
</span>
</p>
{viewModel.card.assignees.length > 0 ? (
<div className="flex flex-wrap gap-1">
{viewModel.card.assignees.map((assignee) => (
<span
className="rounded-full border border-border/65 bg-background/80 px-2 py-0.5 text-2xs text-muted-foreground"
key={assignee}
>
{assignee}
</span>
))}
</div>
) : null}
</div>
</>
) : viewModel.status === "loading" ? (
<p className="mt-3 text-sm text-muted-foreground">Loading…</p>
) : (
<div
className="mt-3 flex flex-1 flex-col justify-center gap-1"
data-testid="card-details-unavailable"
>
<p className="text-sm text-muted-foreground">
Card details unavailable
</p>
{channel.description ? (
<p className="line-clamp-2 text-xs text-muted-foreground/70">
{channel.description}
</p>
) : null}
</div>
)}
</div>
</div>
);
}

View File

@@ -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"]
}
]
}