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:
loganj
2026-08-17 17:47:47 +00:00
co-authored by Claude Code
parent 5b3f0375a2
commit f1941ad912
18 changed files with 963 additions and 108 deletions
+9 -1
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,
+2
View File
@@ -144,6 +144,7 @@ export function AppShell() {
goPulse,
goSettings,
goWorkflows,
goWorkstreams,
closeSettings,
openSearchHit,
} = useAppNavigation();
@@ -860,6 +861,7 @@ export function AppShell() {
onSelectPulse={() => void goPulse()}
onSelectSettings={handleOpenSettings}
onSelectWorkflows={() => void goWorkflows()}
onSelectWorkstreamBoard={() => void goWorkstreams()}
onSetPresenceStatus={(status) =>
presenceSession.setStatus(status)
}
@@ -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,
};
}
+21
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,
+1
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(
+25
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>
);
}
+4 -105
View File
@@ -3,10 +3,7 @@ import * as React from "react";
import { FeatureGate } from "@/shared/features";
import { SidebarDndContext } from "@/features/sidebar/ui/SidebarDnd";
import type { LeaveCommunityResult } from "@/features/communities/leaveCommunity";
import type { Community } from "@/features/communities/types";
import { AddCommunityDialog } from "@/features/communities/ui/AddCommunityDialog";
import type { AddCommunityPrefillRequest } from "@/features/communities/addCommunityPrefill";
import { useIsMobile } from "@/shared/hooks/use-mobile";
import { useDeferredLoad } from "@/shared/hooks/useDeferredStartup";
import {
@@ -48,11 +45,11 @@ import { CreateChannelDialog } from "@/features/sidebar/ui/CreateChannelDialog";
import { SidebarProfileCard } from "@/features/sidebar/ui/SidebarProfileCard";
import { HuddleProfileControl } from "@/features/huddle";
import type {
AppSidebarProps,
CollapsibleSidebarGroup,
CreateChannelKind,
} from "@/features/sidebar/ui/AppSidebar.types";
import { SidebarRelayConnectionCard } from "@/features/sidebar/ui/SidebarRelayConnectionCard";
import type { useSidebarRelayConnectionCard } from "@/features/sidebar/ui/useSidebarRelayConnectionCard";
import {
SidebarLoadingContent,
useSidebarLoadingShape,
@@ -61,15 +58,7 @@ import { useDeferredModalOpen } from "@/shared/ui/deferredModalOpen";
import { SidebarUpdateCard } from "@/features/settings/SidebarUpdateCard";
import { useUpdaterContext } from "@/features/settings/hooks/UpdaterProvider";
import { shouldShowSidebarUpdateCard } from "@/features/settings/sidebarUpdateCardVisibility";
import type { SettingsSection } from "@/features/settings/ui/SettingsPanels";
import type {
Channel,
ChannelVisibility,
PresenceStatus,
Profile,
SearchHit,
UserStatus,
} from "@/shared/api/types";
import type { Channel, ChannelVisibility } from "@/shared/api/types";
import {
Sidebar,
SidebarContent,
@@ -80,98 +69,6 @@ import {
useSidebar,
} from "@/shared/ui/sidebar";
type AppSidebarProps = {
addCommunityPrefill?: AddCommunityPrefillRequest | null;
activeCommunity: Community | null;
channels: Channel[];
currentPubkey?: string;
fallbackDisplayName?: string;
homeBadgeCount: number;
isAddCommunityOpen?: boolean;
isLoading: boolean;
isCreatingChannel: boolean;
isCreatingForum: boolean;
profile?: Profile;
relayConnectionCard: ReturnType<typeof useSidebarRelayConnectionCard>;
selfPresenceStatus: PresenceStatus;
errorMessage?: string;
selectedChannelId: string | null;
selectedView:
| "home"
| "channel"
| "messages"
| "agents"
| "workflows"
| "pulse"
| "projects";
unreadChannelCounts: ReadonlyMap<string, number>;
unreadChannelIds: ReadonlySet<string>;
previewActivityChannelIds: ReadonlySet<string>;
communities: Community[];
onAddCommunity: (community: Community) => void;
onAddCommunityOpenChange?: (open: boolean) => void;
onCreateChannel: (input: {
name: string;
description?: string;
visibility: ChannelVisibility;
ttlSeconds?: number;
templateId?: string;
}) => Promise<void>;
onCreateForum: (input: {
name: string;
description?: string;
visibility: ChannelVisibility;
ttlSeconds?: number;
templateId?: string;
}) => Promise<void>;
onOpenAddCommunity: () => void;
onSendFeedback?: () => void;
onHideDm: (channelId: string) => void;
onMarkChannelUnread: (channelId: string) => void;
onMarkChannelRead: (
channelId: string,
lastMessageAt: string | null | undefined,
) => void;
onMarkAllChannelsRead: () => void;
onBrowseChannels?: (onCreated?: (channelId: string) => void) => void;
onOpenDm: (input: { pubkeys: string[] }) => Promise<void>;
onUpdateCommunity: (
id: string,
updates: Partial<Pick<Community, "name" | "relayUrl" | "token">>,
) => void;
onRemoveCommunity: (id: string) => Promise<LeaveCommunityResult | undefined>;
onCreateAgent: () => void;
onSelectAgents: () => void;
onSelectProjects: () => void;
onSelectPulse: () => void;
onSelectWorkflows: () => void;
onSelectHome: () => void;
onSelectChannel: (channelId: string) => void;
onOpenSearchResult: (hit: SearchHit) => void;
/** Full channel set for global search, including channels outside the joined sidebar list. */
searchChannels: Channel[];
searchFocusRequests: readonly [global: number, channel: number];
onSelectSettings: (section?: SettingsSection) => void;
onSetPresenceStatus?: (status: "online" | "away" | "offline") => void;
onSetUserStatus: (text: string, emoji: string) => void;
onClearUserStatus: () => void;
onSwitchCommunity: (id: string) => void;
selfUserStatus?: UserStatus;
isPresencePending?: boolean;
onNewMessage: () => void;
onBackgroundClick?: () => void;
isCreateChannelOpen?: boolean;
isHuddleCompanionOpen?: boolean;
onHuddleEnded?: (ephemeralChannelId: string | null) => void;
onCreateChannelOpenChange?: (open: boolean) => void;
mutedChannelIds?: ReadonlySet<string>;
onMuteChannel?: (channelId: string) => void;
onUnmuteChannel?: (channelId: string) => void;
starredChannelIds?: ReadonlySet<string>;
onStarChannel?: (channelId: string) => void;
onUnstarChannel?: (channelId: string) => void;
};
export function AppSidebar({
addCommunityPrefill,
activeCommunity,
@@ -213,6 +110,7 @@ export function AppSidebar({
onSelectProjects,
onSelectPulse,
onSelectWorkflows,
onSelectWorkstreamBoard,
onSelectHome,
onSelectChannel,
onOpenSearchResult,
@@ -613,6 +511,7 @@ export function AppSidebar({
onSelectProjects={onSelectProjects}
onSelectPulse={onSelectPulse}
onSelectWorkflows={onSelectWorkflows}
onSelectWorkstreamBoard={onSelectWorkstreamBoard}
selectedView={selectedView}
/>
@@ -1,3 +1,17 @@
import type { AddCommunityPrefillRequest } from "@/features/communities/addCommunityPrefill";
import type { LeaveCommunityResult } from "@/features/communities/leaveCommunity";
import type { Community } from "@/features/communities/types";
import type { SettingsSection } from "@/features/settings/ui/SettingsPanels";
import type { useSidebarRelayConnectionCard } from "@/features/sidebar/ui/useSidebarRelayConnectionCard";
import type {
Channel,
ChannelVisibility,
PresenceStatus,
Profile,
SearchHit,
UserStatus,
} from "@/shared/api/types";
export type CollapsibleSidebarGroup =
| "starred"
| "channels"
@@ -5,3 +19,97 @@ export type CollapsibleSidebarGroup =
| "directMessages";
export type CreateChannelKind = "stream" | "forum";
export type AppSidebarProps = {
addCommunityPrefill?: AddCommunityPrefillRequest | null;
activeCommunity: Community | null;
channels: Channel[];
currentPubkey?: string;
fallbackDisplayName?: string;
homeBadgeCount: number;
isAddCommunityOpen?: boolean;
isLoading: boolean;
isCreatingChannel: boolean;
isCreatingForum: boolean;
profile?: Profile;
relayConnectionCard: ReturnType<typeof useSidebarRelayConnectionCard>;
selfPresenceStatus: PresenceStatus;
errorMessage?: string;
selectedChannelId: string | null;
selectedView:
| "home"
| "channel"
| "messages"
| "agents"
| "workflows"
| "pulse"
| "projects"
| "workstreamBoard";
unreadChannelCounts: ReadonlyMap<string, number>;
unreadChannelIds: ReadonlySet<string>;
previewActivityChannelIds: ReadonlySet<string>;
communities: Community[];
onAddCommunity: (community: Community) => void;
onAddCommunityOpenChange?: (open: boolean) => void;
onCreateChannel: (input: {
name: string;
description?: string;
visibility: ChannelVisibility;
ttlSeconds?: number;
templateId?: string;
}) => Promise<void>;
onCreateForum: (input: {
name: string;
description?: string;
visibility: ChannelVisibility;
ttlSeconds?: number;
templateId?: string;
}) => Promise<void>;
onOpenAddCommunity: () => void;
onSendFeedback?: () => void;
onHideDm: (channelId: string) => void;
onMarkChannelUnread: (channelId: string) => void;
onMarkChannelRead: (
channelId: string,
lastMessageAt: string | null | undefined,
) => void;
onMarkAllChannelsRead: () => void;
onBrowseChannels?: (onCreated?: (channelId: string) => void) => void;
onOpenDm: (input: { pubkeys: string[] }) => Promise<void>;
onUpdateCommunity: (
id: string,
updates: Partial<Pick<Community, "name" | "relayUrl" | "token">>,
) => void;
onRemoveCommunity: (id: string) => Promise<LeaveCommunityResult | undefined>;
onCreateAgent: () => void;
onSelectAgents: () => void;
onSelectProjects: () => void;
onSelectPulse: () => void;
onSelectWorkflows: () => void;
onSelectWorkstreamBoard: () => void;
onSelectHome: () => void;
onSelectChannel: (channelId: string) => void;
onOpenSearchResult: (hit: SearchHit) => void;
/** Full channel set for global search, including channels outside the joined sidebar list. */
searchChannels: Channel[];
searchFocusRequests: readonly [global: number, channel: number];
onSelectSettings: (section?: SettingsSection) => void;
onSetPresenceStatus?: (status: "online" | "away" | "offline") => void;
onSetUserStatus: (text: string, emoji: string) => void;
onClearUserStatus: () => void;
onSwitchCommunity: (id: string) => void;
selfUserStatus?: UserStatus;
isPresencePending?: boolean;
onNewMessage: () => void;
onBackgroundClick?: () => void;
isCreateChannelOpen?: boolean;
isHuddleCompanionOpen?: boolean;
onHuddleEnded?: (ephemeralChannelId: string | null) => void;
onCreateChannelOpenChange?: (open: boolean) => void;
mutedChannelIds?: ReadonlySet<string>;
onMuteChannel?: (channelId: string) => void;
onUnmuteChannel?: (channelId: string) => void;
starredChannelIds?: ReadonlySet<string>;
onStarChannel?: (channelId: string) => void;
onUnstarChannel?: (channelId: string) => void;
};
@@ -1,4 +1,4 @@
import { Activity, Bot, FolderGit2, Inbox, Zap } from "lucide-react";
import { Activity, Bot, FolderGit2, Inbox, Kanban, Zap } from "lucide-react";
import { TopbarSearch } from "@/features/search/ui/TopbarSearch";
import { FeatureGate } from "@/shared/features";
@@ -19,7 +19,8 @@ type SidebarSelectedView =
| "agents"
| "workflows"
| "pulse"
| "projects";
| "projects"
| "workstreamBoard";
type AppSidebarPinnedHeaderProps = {
channelLabels: Record<string, string>;
@@ -44,6 +45,7 @@ type AppSidebarPrimaryMenuProps = {
onSelectProjects: () => void;
onSelectPulse: () => void;
onSelectWorkflows: () => void;
onSelectWorkstreamBoard: () => void;
selectedView: SidebarSelectedView;
};
@@ -93,6 +95,7 @@ export function AppSidebarPrimaryMenu({
onSelectProjects,
onSelectPulse,
onSelectWorkflows,
onSelectWorkstreamBoard,
selectedView,
}: AppSidebarPrimaryMenuProps) {
return (
@@ -193,6 +196,20 @@ export function AppSidebarPrimaryMenu({
</SidebarMenuButton>
</SidebarMenuItem>
</FeatureGate>
<FeatureGate feature="workstreamBoard">
<SidebarMenuItem>
<SidebarMenuButton
data-testid="open-workstream-board-view"
isActive={selectedView === "workstreamBoard"}
onClick={onSelectWorkstreamBoard}
tooltip="Workstream Board"
type="button"
>
<Kanban className="h-4 w-4" />
<SidebarMenuLabel>Workstream Board</SidebarMenuLabel>
</SidebarMenuButton>
</SidebarMenuItem>
</FeatureGate>
</SidebarMenu>
</SidebarHeader>
);
@@ -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([]), []);
});
@@ -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),
);
}
@@ -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",
});
});
@@ -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,
},
};
}
@@ -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" });
});
@@ -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 };
}
@@ -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>
);
}
@@ -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>
);
}
+6
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"]
}
]
}