Add Board discussion and reference replay

Co-authored-by: Larry <loganj+sbot-larry@squareup.com>
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-08-20 01:06:18 +00:00
parent 7f0c61f9da
commit 6b423dc36d
5 changed files with 446 additions and 2 deletions

View File

@@ -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}
<div className={bodyContainerClass} data-testid="message-body">
{messageBodyNode}
<BoardReferenceSet tags={message.tags} />
</div>
</div>
</>
@@ -902,6 +905,7 @@ export const MessageRow = React.memo(
{headerNode}
<div className={bodyContainerClass} data-testid="message-body">
{messageBodyNode}
<BoardReferenceSet tags={message.tags} />
</div>
</div>
</>

View File

@@ -155,3 +155,9 @@ export function resolveBoardReferences(
};
});
}
export function boardReferenceKey(
reference: Pick<BoardReference, "kind" | "identity">,
): string {
return `${reference.kind}:${reference.identity}`;
}

View File

@@ -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 (
<button
className="mt-2 flex w-full flex-wrap gap-1.5 rounded-lg border bg-muted/40 p-2 text-left"
data-testid="message-board-references"
onClick={() => {
sessionStorage.setItem(
BOARD_REPLAY_STORAGE_KEY,
JSON.stringify(references),
);
void goWorkstreams();
}}
type="button"
>
{references.map((reference, index) => (
<span
className="rounded-full border bg-background px-2 py-1 text-3xs"
key={`${reference.kind}:${reference.identity}`}
>
{index + 1}. {reference.kind}: {reference.snapshot.label}
</span>
))}
</button>
);
}

View File

@@ -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<string, readonly BoardReference[]>
>(() => 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"
/>
<div className="flex flex-wrap items-center gap-2">
<Button
onClick={() => {
if (
discussionMode &&
selectedReferences.length > 0 &&
!window.confirm("Discard the unsent reference tray?")
)
return;
setDiscussionMode(!discussionMode);
setSelectedReferences([]);
setReplayReferences([]);
setDiscussionChannelId(null);
}}
variant={discussionMode ? "secondary" : "default"}
>
{discussionMode ? "Exit discussion" : "Discuss Board"}
</Button>
{replayReferences.length > 0 ? (
<Button onClick={() => setReplayReferences([])} variant="outline">
End replay
</Button>
) : null}
</div>
{discussionMode ? (
<section
aria-label="Board discussion"
className="space-y-3 rounded-xl border bg-muted/30 p-4"
data-testid="board-discussion"
>
<p className="text-sm font-semibold">
Reference tray ({selectedReferences.length})
</p>
<ol className="space-y-1">
{selectedReferences.map((reference, index) => (
<li
className="flex items-center gap-2 text-xs"
key={boardReferenceKey(reference)}
>
<span>
{index + 1}. {reference.kind}: {reference.snapshot.label}
</span>
<button
className="underline"
onClick={() => toggleReference(reference)}
type="button"
>
Remove
</button>
</li>
))}
</ol>
<textarea
aria-label="Discussion message"
className="min-h-20 w-full rounded-md border bg-background p-2 text-sm"
onChange={(event) => setDraft(event.target.value)}
placeholder="Discuss these Board references…"
value={draft}
/>
<Button
disabled={
!discussionChannel ||
(!draft.trim() && selectedReferences.length === 0) ||
sendMessage.isPending
}
onClick={async () => {
if (!discussionChannel) return;
await sendMessage.mutateAsync({
content: draft || "Board references",
boardReferences: selectedReferences,
});
setDraft("");
setSelectedReferences([]);
}}
>
Send
</Button>
{discussionChannel ? (
<div className="space-y-2 border-t pt-3">
<p className="text-xs font-semibold">
#{discussionChannel.name} messages
</p>
{(discussionMessages.data ?? [])
.filter(
(event) => parseBoardReferences(event.tags).length > 0,
)
.map((event) => {
const refs = parseBoardReferences(event.tags);
return (
<button
className="block w-full rounded-md border bg-background p-2 text-left text-xs"
key={event.id}
onClick={() => setReplayReferences(refs)}
type="button"
>
<span className="block">{event.content}</span>
<span className="text-muted-foreground">
{refs
.map((reference) => reference.snapshot.label)
.join(" · ")}
</span>
</button>
);
})}
</div>
) : (
<p className="text-xs text-muted-foreground">
Select an object in one workstream to choose the discussion.
</p>
)}
{replayReferences.length > 0
? (() => {
const resolved = resolveBoardReferences(
replayReferences,
liveReferences,
);
return (
<div className="text-xs">
<strong>Replay:</strong>{" "}
{
resolved.filter((item) => item.state === "live")
.length
}{" "}
live ·{" "}
{
resolved.filter((item) => item.state === "changed")
.length
}{" "}
changed ·{" "}
{
resolved.filter((item) => item.state === "historical")
.length
}{" "}
historical
{resolved.some(
(item) => item.state === "historical",
) ? (
<div className="mt-2 rounded-md border border-dashed p-2">
<strong>Historical references</strong>
{resolved
.filter((item) => item.state === "historical")
.map((item) => (
<p key={boardReferenceKey(item.reference)}>
{item.reference.snapshot.label} · formerly{" "}
{item.reference.placement.workstreamLabel} /{" "}
{item.reference.placement.sectionLabel}
</p>
))}
</div>
) : null}
</div>
);
})()
: null}
</section>
) : null}
{channelsQuery.isLoading ? (
<p className="text-sm text-muted-foreground">
Loading workstreams…
@@ -131,6 +386,13 @@ export function WorkstreamBoardScreen() {
<WorkstreamCard
activeWorking={activeTurnsByChannelId.get(channel.id)}
channel={channel}
discussionMode={discussionMode}
onToggleReference={toggleReference}
onReferencesChange={(references) =>
onReferencesChange(channel.id, references)
}
replayReferenceKeys={replayKeys}
selectedReferenceKeys={selectedKeys}
currentOwnerPubkey={identityQuery.data?.pubkey}
key={channel.id}
onSelect={(channelId) => void goChannel(channelId)}

View File

@@ -1,5 +1,7 @@
import * as React from "react";
import { Hash } from "lucide-react";
import type { BoardReference } from "@/features/workstream-board/lib/boardReferences";
import { boardReferenceKey } from "@/features/workstream-board/lib/boardReferences";
import type { ActiveChannelTurnSummary } from "@/features/agents/activeAgentTurnsStore";
import {
@@ -12,6 +14,7 @@ import {
useWorkstreamPullRequestStatuses,
} from "@/features/workstream-board/lib/workstreamPullRequestStatus";
import {
parseWorkstreamWaitMessageLink,
waitsForCard,
type WorkstreamWait,
} from "@/features/workstream-board/lib/workstreamWaits";
@@ -34,15 +37,25 @@ type WorkstreamCardProps = {
createdAt: string | null | undefined,
) => void;
onSelect: (channelId: string) => void;
discussionMode?: boolean;
selectedReferenceKeys?: ReadonlySet<string>;
replayReferenceKeys?: ReadonlySet<string>;
onToggleReference?: (reference: BoardReference) => void;
onReferencesChange?: (references: readonly BoardReference[]) => void;
};
export function WorkstreamCard({
activeWorking,
channel,
discussionMode = false,
currentOwnerPubkey,
onSelect,
onWaitsChange,
onToggleReference,
onReferencesChange,
profiles,
replayReferenceKeys,
selectedReferenceKeys,
}: WorkstreamCardProps) {
const canvasQuery = useCanvasQuery(channel.id);
const detailsQuery = useChannelDetailsQuery(channel.id);
@@ -87,18 +100,102 @@ export function WorkstreamCard({
normalizePubkey(wait.actor.pubkey) ===
normalizePubkey(currentOwnerPubkey),
);
const discussionReferences = React.useMemo<BoardReference[]>(() => {
if (viewModel.status !== "ready") return [];
const placement = {
workstreamId: channel.id,
workstreamLabel: channel.name,
sectionId: "workstream",
sectionLabel: "Workstream",
};
const result: BoardReference[] = [
{
kind: "workstream",
identity: channel.id,
snapshot: { label: channel.name, detail: viewModel.card.synopsis },
placement,
},
];
for (const reference of references) {
result.push({
kind: "pull-request",
identity: reference.identity,
snapshot: { label: reference.rawUrl },
placement: {
...placement,
sectionId: "pull-requests",
sectionLabel: "Pull requests",
},
});
}
for (const assignee of viewModel.card.assignees) {
const identity = normalizePubkey(assignee.pubkey);
if (/^[0-9a-f]{64}$/.test(identity))
result.push({
kind: "agent",
identity,
snapshot: { label: assignee.name },
placement: {
...placement,
sectionId: "agents",
sectionLabel: "Agents",
},
});
}
result.push({
kind: "agent-group",
identity: `orchestrator:${normalizePubkey(viewModel.card.orchestrator.pubkey)}`,
snapshot: {
label: `${viewModel.card.orchestrator.name}'s team`,
detail: "Orchestrator group",
},
placement: { ...placement, sectionId: "agents", sectionLabel: "Agents" },
});
for (const wait of waits) {
const destination = parseWorkstreamWaitMessageLink(wait.message);
if (!destination) continue;
result.push({
kind: "thread-blocker",
identity: wait.key,
destination: {
channelId: destination.channelId,
messageId: destination.messageId,
...(destination.threadRootId
? { threadRootId: destination.threadRootId }
: {}),
},
snapshot: { label: wait.reason, detail: wait.actor.name },
placement: {
...placement,
sectionId: "blockers",
sectionLabel: "Blockers",
},
});
}
return result;
}, [channel.id, channel.name, references, viewModel, waits]);
React.useEffect(() => {
onReferencesChange?.(discussionReferences);
}, [discussionReferences, onReferencesChange]);
return (
<div
className={cn(
"group relative w-full self-start 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",
waitingOnPrincipal && "border-t-4 border-t-amber-400/70",
discussionReferences.some((reference) =>
replayReferenceKeys?.has(boardReferenceKey(reference)),
) && "ring-2 ring-primary ring-offset-2 ring-offset-background",
)}
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)}
onClick={() =>
discussionMode
? onToggleReference?.(discussionReferences[0])
: onSelect(channel.id)
}
type="button"
>
<span className="sr-only">Open #{channel.name}</span>
@@ -111,12 +208,50 @@ export function WorkstreamCard({
<div className="pointer-events-none relative z-10 flex flex-col">
<button
className="pointer-events-auto flex max-w-[calc(100%-4rem)] items-center gap-1.5 text-xs font-semibold text-muted-foreground hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => onSelect(channel.id)}
onClick={() =>
discussionMode
? onToggleReference?.(discussionReferences[0])
: onSelect(channel.id)
}
type="button"
>
<Hash className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{channel.name}</span>
</button>
{discussionMode ? (
<div
className="pointer-events-auto mt-3 flex flex-wrap gap-1.5"
data-testid={`board-reference-options-${channel.id}`}
>
{discussionReferences.map((reference) => {
const key = boardReferenceKey(reference);
const selected = selectedReferenceKeys?.has(key) ?? false;
const replayed = replayReferenceKeys?.has(key) ?? false;
return (
<button
aria-pressed={selected}
className={cn(
"rounded-full border px-2 py-1 text-3xs",
selected && "border-primary bg-primary/15",
replayed && "ring-2 ring-primary",
)}
key={key}
onClick={() => onToggleReference?.(reference)}
type="button"
>
{reference.kind}: {reference.snapshot.label}
</button>
);
})}
<button
className="rounded-full border px-2 py-1 text-3xs font-semibold"
onClick={() => onSelect(channel.id)}
type="button"
>
Open
</button>
</div>
) : null}
{viewModel.status === "ready" ? (
<>
<p className="mt-3 line-clamp-3 text-sm leading-relaxed text-foreground">