diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index fd5be7d9a8..bb612d2ffc 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -59,6 +59,8 @@ import { SentFromThreadLine } from "./SentFromThreadLine"; import { WaveMessageAttachment } from "./WaveMessageAttachment"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; +import { BoardReferenceSet } from "@/features/workstream-board/ui/BoardReferenceSet"; + const DiffMessage = React.lazy(() => import("./DiffMessage")); const DiffMessageExpanded = React.lazy(() => import("./DiffMessageExpanded")); @@ -892,6 +894,7 @@ export const MessageRow = React.memo( {headerNode}
{messageBodyNode} +
@@ -902,6 +905,7 @@ export const MessageRow = React.memo( {headerNode}
{messageBodyNode} +
diff --git a/desktop/src/features/workstream-board/lib/boardReferences.ts b/desktop/src/features/workstream-board/lib/boardReferences.ts index 2c3facb1e0..ccaefdeb44 100644 --- a/desktop/src/features/workstream-board/lib/boardReferences.ts +++ b/desktop/src/features/workstream-board/lib/boardReferences.ts @@ -155,3 +155,9 @@ export function resolveBoardReferences( }; }); } + +export function boardReferenceKey( + reference: Pick, +): string { + return `${reference.kind}:${reference.identity}`; +} diff --git a/desktop/src/features/workstream-board/ui/BoardReferenceSet.tsx b/desktop/src/features/workstream-board/ui/BoardReferenceSet.tsx new file mode 100644 index 0000000000..81a5d4d14c --- /dev/null +++ b/desktop/src/features/workstream-board/ui/BoardReferenceSet.tsx @@ -0,0 +1,37 @@ +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { parseBoardReferences } from "@/features/workstream-board/lib/boardReferences"; + +export const BOARD_REPLAY_STORAGE_KEY = "buzz:board-replay:v1"; + +export function BoardReferenceSet({ + tags, +}: { + tags?: readonly (readonly string[])[]; +}) { + const references = parseBoardReferences(tags); + const { goWorkstreams } = useAppNavigation(); + if (references.length === 0) return null; + return ( + + ); +} diff --git a/desktop/src/features/workstream-board/ui/WorkstreamBoardScreen.tsx b/desktop/src/features/workstream-board/ui/WorkstreamBoardScreen.tsx index a470819a7e..3012be2725 100644 --- a/desktop/src/features/workstream-board/ui/WorkstreamBoardScreen.tsx +++ b/desktop/src/features/workstream-board/ui/WorkstreamBoardScreen.tsx @@ -3,6 +3,16 @@ import * as React from "react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useActiveAgentTurnsByChannel } from "@/features/agents/activeAgentTurnsStore"; import { useChannelsQuery } from "@/features/channels/hooks"; +import { + useChannelMessagesQuery, + useSendMessageMutation, +} from "@/features/messages/hooks"; +import { + parseBoardReferences, + boardReferenceKey, + resolveBoardReferences, + type BoardReference, +} from "@/features/workstream-board/lib/boardReferences"; import { useUsersBatchQuery } from "@/features/profile/hooks"; import { filterWorkstreamChannels } from "@/features/workstream-board/lib/discoverWorkstreamChannels"; import { getActiveWorkstreamTurns } from "@/features/workstream-board/lib/activeWorkstreamTurns"; @@ -11,6 +21,7 @@ import { type WorkstreamWait, } from "@/features/workstream-board/lib/workstreamWaits"; import { WorkstreamCard } from "@/features/workstream-board/ui/WorkstreamCard"; +import { BOARD_REPLAY_STORAGE_KEY } from "@/features/workstream-board/ui/BoardReferenceSet"; import { useIdentityQuery } from "@/shared/api/hooks"; import { Button } from "@/shared/ui/button"; import { PageHeader } from "@/shared/ui/PageHeader"; @@ -36,7 +47,95 @@ export function WorkstreamBoardScreen() { const { goChannel } = useAppNavigation(); const identityQuery = useIdentityQuery(); const channelsQuery = useChannelsQuery(); + const [discussionMode, setDiscussionMode] = React.useState(false); + const [selectedReferences, setSelectedReferences] = React.useState< + BoardReference[] + >([]); + const [discussionChannelId, setDiscussionChannelId] = React.useState< + string | null + >(null); + const [draft, setDraft] = React.useState(""); + const [replayReferences, setReplayReferences] = React.useState< + BoardReference[] + >([]); + const [liveReferencesByChannel, setLiveReferencesByChannel] = React.useState< + ReadonlyMap + >(() => new Map()); + React.useEffect(() => { + const stored = sessionStorage.getItem(BOARD_REPLAY_STORAGE_KEY); + if (!stored) return; + sessionStorage.removeItem(BOARD_REPLAY_STORAGE_KEY); + try { + const parsed: unknown = JSON.parse(stored); + if (Array.isArray(parsed)) { + setReplayReferences( + parsed.flatMap((value) => { + const references = parseBoardReferences([ + ["buzz:board-ref", "1", JSON.stringify(value)], + ]); + return references; + }), + ); + } + } catch { + // Malformed cross-route replay state fails closed. + } + }, []); const workstreamChannels = filterWorkstreamChannels(channelsQuery.data ?? []); + const discussionChannel = + workstreamChannels.find((channel) => channel.id === discussionChannelId) ?? + null; + const discussionMessages = useChannelMessagesQuery(discussionChannel); + const sendMessage = useSendMessageMutation( + discussionChannel, + identityQuery.data, + ); + const selectedKeys = React.useMemo( + () => new Set(selectedReferences.map(boardReferenceKey)), + [selectedReferences], + ); + const replayKeys = React.useMemo( + () => new Set(replayReferences.map(boardReferenceKey)), + [replayReferences], + ); + const liveReferences = React.useMemo( + () => + new Map( + [...liveReferencesByChannel.values()] + .flat() + .map((reference) => [reference.identity, reference]), + ), + [liveReferencesByChannel], + ); + const onReferencesChange = React.useCallback( + (channelId: string, references: readonly BoardReference[]) => { + setLiveReferencesByChannel((current) => { + const previous = current.get(channelId); + if (JSON.stringify(previous) === JSON.stringify(references)) + return current; + const next = new Map(current); + next.set(channelId, references); + return next; + }); + }, + [], + ); + const toggleReference = React.useCallback((reference: BoardReference) => { + setDiscussionChannelId( + (current) => current ?? reference.placement.workstreamId, + ); + setSelectedReferences((current) => { + if ( + current.length > 0 && + current[0].placement.workstreamId !== reference.placement.workstreamId + ) + return current; + const key = boardReferenceKey(reference); + return current.some((item) => boardReferenceKey(item) === key) + ? current.filter((item) => boardReferenceKey(item) !== key) + : [...current, reference]; + }); + }, []); const activeTurnsByChannel = useActiveAgentTurnsByChannel(); const activeWorkstreamTurns = React.useMemo( () => getActiveWorkstreamTurns(workstreamChannels, activeTurnsByChannel), @@ -105,6 +204,162 @@ export function WorkstreamBoardScreen() { description="Live canvases for active workstream channels." title="Workstream Board" /> +
+ + {replayReferences.length > 0 ? ( + + ) : null} +
+ {discussionMode ? ( +
+

+ Reference tray ({selectedReferences.length}) +

+
    + {selectedReferences.map((reference, index) => ( +
  1. + + {index + 1}. {reference.kind}: {reference.snapshot.label} + + +
  2. + ))} +
+