diff --git a/desktop/src/app/AppShell.helpers.ts b/desktop/src/app/AppShell.helpers.ts index dd6b9195e..448f3533c 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 6257a75b7..6fda82616 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -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) } diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index 2203aa03a..3c388c8f1 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 2bc2c8ddb..367c2cc97 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 f5c6938e1..5d6692f8e 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 000000000..7be9b947a --- /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 227572feb..32d3cf4aa 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -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; - selfPresenceStatus: PresenceStatus; - errorMessage?: string; - selectedChannelId: string | null; - selectedView: - | "home" - | "channel" - | "messages" - | "agents" - | "workflows" - | "pulse" - | "projects"; - unreadChannelCounts: ReadonlyMap; - unreadChannelIds: ReadonlySet; - previewActivityChannelIds: ReadonlySet; - communities: Community[]; - onAddCommunity: (community: Community) => void; - onAddCommunityOpenChange?: (open: boolean) => void; - onCreateChannel: (input: { - name: string; - description?: string; - visibility: ChannelVisibility; - ttlSeconds?: number; - templateId?: string; - }) => Promise; - onCreateForum: (input: { - name: string; - description?: string; - visibility: ChannelVisibility; - ttlSeconds?: number; - templateId?: string; - }) => Promise; - 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; - onUpdateCommunity: ( - id: string, - updates: Partial>, - ) => void; - onRemoveCommunity: (id: string) => Promise; - 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; - onMuteChannel?: (channelId: string) => void; - onUnmuteChannel?: (channelId: string) => void; - starredChannelIds?: ReadonlySet; - 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} /> diff --git a/desktop/src/features/sidebar/ui/AppSidebar.types.ts b/desktop/src/features/sidebar/ui/AppSidebar.types.ts index 48a19711a..fb83c615f 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.types.ts +++ b/desktop/src/features/sidebar/ui/AppSidebar.types.ts @@ -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; + selfPresenceStatus: PresenceStatus; + errorMessage?: string; + selectedChannelId: string | null; + selectedView: + | "home" + | "channel" + | "messages" + | "agents" + | "workflows" + | "pulse" + | "projects" + | "workstreamBoard"; + unreadChannelCounts: ReadonlyMap; + unreadChannelIds: ReadonlySet; + previewActivityChannelIds: ReadonlySet; + communities: Community[]; + onAddCommunity: (community: Community) => void; + onAddCommunityOpenChange?: (open: boolean) => void; + onCreateChannel: (input: { + name: string; + description?: string; + visibility: ChannelVisibility; + ttlSeconds?: number; + templateId?: string; + }) => Promise; + onCreateForum: (input: { + name: string; + description?: string; + visibility: ChannelVisibility; + ttlSeconds?: number; + templateId?: string; + }) => Promise; + 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; + onUpdateCommunity: ( + id: string, + updates: Partial>, + ) => void; + onRemoveCommunity: (id: string) => Promise; + 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; + onMuteChannel?: (channelId: string) => void; + onUnmuteChannel?: (channelId: string) => void; + starredChannelIds?: ReadonlySet; + onStarChannel?: (channelId: string) => void; + onUnstarChannel?: (channelId: string) => void; +}; diff --git a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx index 4a618fcf0..2b03aef8d 100644 --- a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx @@ -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; @@ -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({ + + + + + 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 000000000..b2ea2f89c --- /dev/null +++ b/desktop/src/features/workstream-board/lib/discoverWorkstreamChannels.test.mjs @@ -0,0 +1,117 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + filterWorkstreamChannels, + WORKSTREAM_CHANNEL_PREFIX, +} from "./discoverWorkstreamChannels.ts"; + +function buildChannel(overrides) { + return { + id: overrides.id ?? "channel-id", + name: overrides.name, + channelType: "stream", + visibility: "open", + description: "", + topic: null, + purpose: null, + memberCount: overrides.memberPubkeys?.length ?? 0, + memberPubkeys: overrides.memberPubkeys ?? [], + lastMessageAt: null, + archivedAt: null, + participants: [], + participantPubkeys: [], + isMember: overrides.isMember ?? false, + ttlSeconds: null, + ttlDeadline: null, + }; +} + +test("prefix constant matches the contract-specified prefix", () => { + assert.equal(WORKSTREAM_CHANNEL_PREFIX, "loganj-ws-"); +}); + +test("includes channels whose name starts exactly with the prefix", () => { + const channels = [ + buildChannel({ id: "1", name: "loganj-ws-canvas-cards" }), + buildChannel({ id: "2", name: "general" }), + ]; + + const result = filterWorkstreamChannels(channels); + assert.deepEqual( + result.map((c) => c.id), + ["1"], + ); +}); + +test("excludes channels that merely contain the prefix mid-name", () => { + const channels = [ + buildChannel({ id: "1", name: "not-loganj-ws-canvas-cards" }), + buildChannel({ id: "2", name: "loganj-ws-canvas-cards" }), + ]; + + const result = filterWorkstreamChannels(channels); + assert.deepEqual( + result.map((c) => c.id), + ["2"], + ); +}); + +test("excludes a near-miss name missing the trailing hyphen", () => { + const channels = [ + buildChannel({ id: "1", name: "loganj-ws" }), + buildChannel({ id: "2", name: "loganj-ws-" }), + ]; + + const result = filterWorkstreamChannels(channels); + assert.deepEqual( + result.map((c) => c.id), + ["2"], + ); +}); + +test("applies no creator/membership filter — every matching name is included regardless of who created or joined it", () => { + const channels = [ + // Different member sets stand in for "different creators" — the Channel + // type carries no creator field on the list endpoint, so membership + // overlap is the only axis available to prove no ownership filtering. + buildChannel({ + id: "mine", + name: "loganj-ws-mine", + isMember: true, + memberPubkeys: ["aa"], + }), + buildChannel({ + id: "someone-elses", + name: "loganj-ws-someone-elses", + isMember: false, + memberPubkeys: ["bb", "cc"], + }), + buildChannel({ + id: "no-members", + name: "loganj-ws-empty", + isMember: false, + memberPubkeys: [], + }), + ]; + + const result = filterWorkstreamChannels(channels); + assert.deepEqual(result.map((c) => c.id).sort(), [ + "mine", + "no-members", + "someone-elses", + ]); +}); + +test("returns an empty array when nothing matches", () => { + const channels = [ + buildChannel({ id: "1", name: "general" }), + buildChannel({ id: "2", name: "random" }), + ]; + + assert.deepEqual(filterWorkstreamChannels(channels), []); +}); + +test("returns an empty array for an empty channel list", () => { + assert.deepEqual(filterWorkstreamChannels([]), []); +}); diff --git a/desktop/src/features/workstream-board/lib/discoverWorkstreamChannels.ts b/desktop/src/features/workstream-board/lib/discoverWorkstreamChannels.ts new file mode 100644 index 000000000..f083a7319 --- /dev/null +++ b/desktop/src/features/workstream-board/lib/discoverWorkstreamChannels.ts @@ -0,0 +1,16 @@ +import type { Channel } from "@/shared/api/types"; + +/** + * Channels whose name starts with this prefix are discovered as workstream + * board entries. There is no creator/ownership filter — any visible channel + * matching the prefix is included, regardless of who created or joined it. + */ +export const WORKSTREAM_CHANNEL_PREFIX = "loganj-ws-"; + +export function filterWorkstreamChannels( + channels: readonly Channel[], +): Channel[] { + return channels.filter((channel) => + channel.name.startsWith(WORKSTREAM_CHANNEL_PREFIX), + ); +} diff --git a/desktop/src/features/workstream-board/lib/workstreamCardParser.test.mjs b/desktop/src/features/workstream-board/lib/workstreamCardParser.test.mjs new file mode 100644 index 000000000..d9be71d42 --- /dev/null +++ b/desktop/src/features/workstream-board/lib/workstreamCardParser.test.mjs @@ -0,0 +1,208 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { parseWorkstreamCard } from "./workstreamCardParser.ts"; + +// Helper: build a fenced card body from a JSON-serializable payload (or raw +// string, to construct intentionally-invalid-JSON fixtures). +function withCardFence(prose, rawPayload) { + const body = + typeof rawPayload === "string" ? rawPayload : JSON.stringify(rawPayload); + return `${prose}\n\n\`\`\`buzz-workstream-card\n${body}\n\`\`\``; +} + +const VALID_PAYLOAD = { + version: 1, + synopsis: "Implementing the canvas card slice.", + orchestrator: "loganj", + assignees: ["alice", "bob"], +}; + +// ── Happy path ──────────────────────────────────────────────────────────────── + +test("parses a valid v1 card with explicit optional arrays", () => { + const result = parseWorkstreamCard( + withCardFence("Status update:", { + ...VALID_PAYLOAD, + pullRequests: ["https://github.com/block/buzz/pull/1"], + waitingOn: ["review"], + }), + ); + + assert.equal(result.ok, true); + assert.deepEqual(result.card, { + version: 1, + synopsis: VALID_PAYLOAD.synopsis, + orchestrator: VALID_PAYLOAD.orchestrator, + assignees: ["alice", "bob"], + pullRequests: ["https://github.com/block/buzz/pull/1"], + waitingOn: ["review"], + }); +}); + +test("defaults assignees/pullRequests/waitingOn to empty arrays when omitted", () => { + const result = parseWorkstreamCard( + withCardFence("Status update:", VALID_PAYLOAD), + ); + + assert.equal(result.ok, true); + assert.deepEqual(result.card.assignees, ["alice", "bob"]); + assert.deepEqual(result.card.pullRequests, []); + assert.deepEqual(result.card.waitingOn, []); +}); + +test("ignores prose surrounding the fence", () => { + const content = [ + "# Workstream", + "", + "Some human-authored notes above the card.", + "", + "```buzz-workstream-card", + JSON.stringify(VALID_PAYLOAD), + "```", + "", + "Notes below the card too.", + ].join("\n"); + + const result = parseWorkstreamCard(content); + assert.equal(result.ok, true); + assert.equal(result.card.synopsis, VALID_PAYLOAD.synopsis); +}); + +// ── Missing block ───────────────────────────────────────────────────────────── + +test("returns not-found for null content", () => { + assert.deepEqual(parseWorkstreamCard(null), { + ok: false, + reason: "not-found", + }); +}); + +test("returns not-found for empty content", () => { + assert.deepEqual(parseWorkstreamCard(""), { ok: false, reason: "not-found" }); +}); + +test("returns not-found when canvas has prose but no fence", () => { + assert.deepEqual(parseWorkstreamCard("Just some notes, no card here."), { + ok: false, + reason: "not-found", + }); +}); + +// ── Invalid JSON ────────────────────────────────────────────────────────────── + +test("returns invalid-json for malformed JSON inside the fence", () => { + const content = withCardFence("Status:", "{not valid json"); + assert.deepEqual(parseWorkstreamCard(content), { + ok: false, + reason: "invalid-json", + }); +}); + +// ── Duplicate blocks ────────────────────────────────────────────────────────── + +test("returns duplicate-block when the canvas has two card fences", () => { + const content = [ + withCardFence("First:", VALID_PAYLOAD), + "", + withCardFence("Second:", VALID_PAYLOAD), + ].join("\n"); + + assert.deepEqual(parseWorkstreamCard(content), { + ok: false, + reason: "duplicate-block", + }); +}); + +// ── Unknown version ─────────────────────────────────────────────────────────── + +test("returns unknown-version for version 2", () => { + const content = withCardFence("Status:", { ...VALID_PAYLOAD, version: 2 }); + assert.deepEqual(parseWorkstreamCard(content), { + ok: false, + reason: "unknown-version", + }); +}); + +test("returns unknown-version when version is missing", () => { + const { version: _version, ...withoutVersion } = VALID_PAYLOAD; + const content = withCardFence("Status:", withoutVersion); + assert.deepEqual(parseWorkstreamCard(content), { + ok: false, + reason: "unknown-version", + }); +}); + +// ── Missing / invalid required fields ──────────────────────────────────────── + +test("returns invalid-fields when synopsis is missing", () => { + const { synopsis: _synopsis, ...withoutSynopsis } = VALID_PAYLOAD; + const content = withCardFence("Status:", withoutSynopsis); + assert.deepEqual(parseWorkstreamCard(content), { + ok: false, + reason: "invalid-fields", + }); +}); + +test("returns invalid-fields when orchestrator is an empty string", () => { + const content = withCardFence("Status:", { + ...VALID_PAYLOAD, + orchestrator: "", + }); + assert.deepEqual(parseWorkstreamCard(content), { + ok: false, + reason: "invalid-fields", + }); +}); + +test("returns invalid-fields when assignees is not an array", () => { + const content = withCardFence("Status:", { + ...VALID_PAYLOAD, + assignees: "alice", + }); + assert.deepEqual(parseWorkstreamCard(content), { + ok: false, + reason: "invalid-fields", + }); +}); + +test("returns invalid-fields when assignees contains a non-string", () => { + const content = withCardFence("Status:", { + ...VALID_PAYLOAD, + assignees: ["alice", 2], + }); + assert.deepEqual(parseWorkstreamCard(content), { + ok: false, + reason: "invalid-fields", + }); +}); + +test("returns invalid-fields when pullRequests is not an array", () => { + const content = withCardFence("Status:", { + ...VALID_PAYLOAD, + pullRequests: "pr-1", + }); + assert.deepEqual(parseWorkstreamCard(content), { + ok: false, + reason: "invalid-fields", + }); +}); + +test("returns invalid-fields when waitingOn is not an array", () => { + const content = withCardFence("Status:", { + ...VALID_PAYLOAD, + waitingOn: "review", + }); + assert.deepEqual(parseWorkstreamCard(content), { + ok: false, + reason: "invalid-fields", + }); +}); + +test("returns invalid-fields when the payload is a JSON array, not an object", () => { + const content = withCardFence("Status:", [VALID_PAYLOAD]); + assert.deepEqual(parseWorkstreamCard(content), { + ok: false, + reason: "invalid-fields", + }); +}); diff --git a/desktop/src/features/workstream-board/lib/workstreamCardParser.ts b/desktop/src/features/workstream-board/lib/workstreamCardParser.ts new file mode 100644 index 000000000..f77d3d910 --- /dev/null +++ b/desktop/src/features/workstream-board/lib/workstreamCardParser.ts @@ -0,0 +1,138 @@ +/** + * Parses the `buzz-workstream-card` sentinel that a channel canvas may embed + * to describe the workstream running in that channel. + * + * Wire format (authored by hand or by an orchestrating agent): + * + * ``` + * ```buzz-workstream-card + * {"version":1,"synopsis":"…","orchestrator":"…","assignees":[…]} + * ``` + * ``` + * + * Only one block per canvas is supported. A missing block, malformed JSON, + * an unrecognized version, or missing/invalid required fields are all + * card-local parse failures — the caller degrades just that card, it never + * throws. + */ + +const FENCE_OPEN = "```buzz-workstream-card"; +const FENCE_CLOSE = "```"; + +export type WorkstreamCardV1 = { + version: 1; + synopsis: string; + orchestrator: string; + assignees: string[]; + pullRequests: unknown[]; + waitingOn: unknown[]; +}; + +export type WorkstreamCardParseFailureReason = + | "not-found" + | "invalid-json" + | "duplicate-block" + | "unknown-version" + | "invalid-fields"; + +export type WorkstreamCardParseResult = + | { ok: true; card: WorkstreamCardV1 } + | { ok: false; reason: WorkstreamCardParseFailureReason }; + +function findFencedBlocks(content: string): string[] { + const blocks: string[] = []; + let cursor = 0; + + while (true) { + const openIdx = content.indexOf(FENCE_OPEN, cursor); + if (openIdx === -1) break; + + const jsonStart = content.indexOf("\n", openIdx); + if (jsonStart === -1) break; + + const closeIdx = content.indexOf(`\n${FENCE_CLOSE}`, jsonStart); + if (closeIdx === -1) break; + + blocks.push(content.slice(jsonStart + 1, closeIdx).trim()); + cursor = closeIdx + `\n${FENCE_CLOSE}`.length; + } + + return blocks; +} + +function isStringArray(value: unknown): value is string[] { + return ( + Array.isArray(value) && value.every((item) => typeof item === "string") + ); +} + +/** + * Parse the single `buzz-workstream-card` block out of a channel canvas. + * Never throws — every failure mode maps to a `WorkstreamCardParseFailureReason`. + */ +export function parseWorkstreamCard( + content: string | null | undefined, +): WorkstreamCardParseResult { + if (!content) { + return { ok: false, reason: "not-found" }; + } + + const blocks = findFencedBlocks(content); + if (blocks.length === 0) { + return { ok: false, reason: "not-found" }; + } + if (blocks.length > 1) { + return { ok: false, reason: "duplicate-block" }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(blocks[0]); + } catch { + return { ok: false, reason: "invalid-json" }; + } + + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return { ok: false, reason: "invalid-fields" }; + } + + const raw = parsed as Record; + + if (raw.version !== 1) { + return { ok: false, reason: "unknown-version" }; + } + + if (typeof raw.synopsis !== "string" || raw.synopsis.trim() === "") { + return { ok: false, reason: "invalid-fields" }; + } + if (typeof raw.orchestrator !== "string" || raw.orchestrator.trim() === "") { + return { ok: false, reason: "invalid-fields" }; + } + + const assignees = raw.assignees ?? []; + if (!isStringArray(assignees)) { + return { ok: false, reason: "invalid-fields" }; + } + + const pullRequests = raw.pullRequests ?? []; + if (!Array.isArray(pullRequests)) { + return { ok: false, reason: "invalid-fields" }; + } + + const waitingOn = raw.waitingOn ?? []; + if (!Array.isArray(waitingOn)) { + return { ok: false, reason: "invalid-fields" }; + } + + return { + ok: true, + card: { + version: 1, + synopsis: raw.synopsis, + orchestrator: raw.orchestrator, + assignees, + pullRequests, + waitingOn, + }, + }; +} diff --git a/desktop/src/features/workstream-board/lib/workstreamCardViewModel.test.mjs b/desktop/src/features/workstream-board/lib/workstreamCardViewModel.test.mjs new file mode 100644 index 000000000..41ac9e14f --- /dev/null +++ b/desktop/src/features/workstream-board/lib/workstreamCardViewModel.test.mjs @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildWorkstreamCardViewModel } from "./workstreamCardViewModel.ts"; + +const VALID_CARD_CONTENT = [ + "```buzz-workstream-card", + JSON.stringify({ + version: 1, + synopsis: "Shipping the canvas card slice.", + orchestrator: "loganj", + assignees: ["alice"], + }), + "```", +].join("\n"); + +test("reports loading while the canvas query is in flight", () => { + const viewModel = buildWorkstreamCardViewModel({ + canvasContent: undefined, + isLoading: true, + isError: false, + }); + assert.deepEqual(viewModel, { status: "loading" }); +}); + +test("degrades to unavailable when the canvas fetch errors", () => { + const viewModel = buildWorkstreamCardViewModel({ + canvasContent: undefined, + isLoading: false, + isError: true, + }); + assert.deepEqual(viewModel, { status: "unavailable" }); +}); + +test("degrades to unavailable when the canvas has no content", () => { + const viewModel = buildWorkstreamCardViewModel({ + canvasContent: null, + isLoading: false, + isError: false, + }); + assert.deepEqual(viewModel, { status: "unavailable" }); +}); + +test("degrades to unavailable when the canvas content fails to parse (card-local failure)", () => { + const viewModel = buildWorkstreamCardViewModel({ + canvasContent: "```buzz-workstream-card\nnot valid json\n```", + isLoading: false, + isError: false, + }); + assert.deepEqual(viewModel, { status: "unavailable" }); +}); + +test("degrades to unavailable for an unknown-version card without surfacing a global error", () => { + const viewModel = buildWorkstreamCardViewModel({ + canvasContent: [ + "```buzz-workstream-card", + JSON.stringify({ version: 2, synopsis: "x", orchestrator: "y" }), + "```", + ].join("\n"), + isLoading: false, + isError: false, + }); + assert.deepEqual(viewModel, { status: "unavailable" }); +}); + +test("returns a ready card when the canvas parses successfully", () => { + const viewModel = buildWorkstreamCardViewModel({ + canvasContent: VALID_CARD_CONTENT, + isLoading: false, + isError: false, + }); + + assert.equal(viewModel.status, "ready"); + assert.equal(viewModel.card.synopsis, "Shipping the canvas card slice."); + assert.equal(viewModel.card.orchestrator, "loganj"); + assert.deepEqual(viewModel.card.assignees, ["alice"]); +}); + +test("loading takes priority over content even if content happens to be malformed", () => { + const viewModel = buildWorkstreamCardViewModel({ + canvasContent: "garbage", + isLoading: true, + isError: false, + }); + assert.deepEqual(viewModel, { status: "loading" }); +}); diff --git a/desktop/src/features/workstream-board/lib/workstreamCardViewModel.ts b/desktop/src/features/workstream-board/lib/workstreamCardViewModel.ts new file mode 100644 index 000000000..cda900ab2 --- /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 000000000..3e4b06bb0 --- /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 000000000..111f8d88e --- /dev/null +++ b/desktop/src/features/workstream-board/ui/WorkstreamCard.tsx @@ -0,0 +1,88 @@ +import { Hash } from "lucide-react"; + +import { useCanvasQuery } from "@/features/channels/hooks"; +import { buildWorkstreamCardViewModel } from "@/features/workstream-board/lib/workstreamCardViewModel"; +import type { Channel } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; + +type WorkstreamCardProps = { + channel: Channel; + onSelect: (channelId: string) => void; +}; + +export function WorkstreamCard({ channel, onSelect }: WorkstreamCardProps) { + const canvasQuery = useCanvasQuery(channel.id); + const viewModel = buildWorkstreamCardViewModel({ + canvasContent: canvasQuery.data?.content, + isLoading: canvasQuery.isLoading, + isError: canvasQuery.isError, + }); + + return ( +
+ + +
+
+ + {channel.name} +
+ + {viewModel.status === "ready" ? ( + <> +

+ {viewModel.card.synopsis} +

+
+

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

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

Loading…

+ ) : ( +
+

+ Card details unavailable +

+ {channel.description ? ( +

+ {channel.description} +

+ ) : null} +
+ )} +
+
+ ); +} diff --git a/preview-features.json b/preview-features.json index 388f1c39b..f99d01442 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"] } ] }