mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user