From 1fe8695c693e9147777d64814bb4da049a24263a Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sat, 4 Jul 2026 10:01:34 +0100 Subject: [PATCH] Track live turns per turn id; distinct agent-authored PR cards Three chat fixes: - "Working" no longer renders once per turn block: the active-turn store now exposes the live turn ids per channel, so only the actually-live turn shows the marker and completed turns collapse even while a new turn runs (previously a channel-wide boolean re-expanded every old turn and gave each its own Working row). - Agent-authored PR links render a distinct, prominent card (banner layout with repo, status pill, title, and diff stats) instead of the compact chip a pasted link gets. Threaded through a Markdown agentAuthored flag set on agent message rows. - New chats resolve their default agent immediately: quick-start refreshes the managed-agents cache after ensuring Fizz, so the first chat's agent replies render as agent rows instead of member bubbles. The chats e2e now covers the agent card and pins mock message ordering with explicit timestamps (same-second events sorted unstably). Co-Authored-By: Claude Fable 5 --- .../features/agents/activeAgentTurnsStore.ts | 9 +- .../chats/ui/ChatActivityTranscript.tsx | 14 ++- .../chats/ui/ChatConversationRows.tsx | 1 + desktop/src/features/chats/ui/ChatDetail.tsx | 17 +++- .../src/features/chats/ui/QuickStartChat.tsx | 15 +++- .../src/shared/ui/link-preview-attachment.tsx | 87 +++++++++++++++++++ desktop/src/shared/ui/markdown.tsx | 9 +- desktop/src/shared/ui/markdown/types.ts | 5 ++ desktop/tests/e2e/chats-first-message.spec.ts | 16 +++- 9 files changed, 161 insertions(+), 12 deletions(-) diff --git a/desktop/src/features/agents/activeAgentTurnsStore.ts b/desktop/src/features/agents/activeAgentTurnsStore.ts index 9ffd34db6..08a58170a 100644 --- a/desktop/src/features/agents/activeAgentTurnsStore.ts +++ b/desktop/src/features/agents/activeAgentTurnsStore.ts @@ -42,6 +42,8 @@ type ActiveTurn = { export type ActiveTurnSummary = { channelId: string; anchorAt: number; + /** Ids of the live turns collapsed into this channel summary. */ + turnIds: string[]; }; /** One channel with active agent work, aggregated across agents. */ @@ -420,17 +422,22 @@ export function getActiveTurnsForAgent( // should count from when the channel's oldest live turn began. Anchors are // derived here (startedAt + offset) so the latest skew estimate applies. const earliestByChannel = new Map(); - for (const turn of agentTurns.values()) { + const turnIdsByChannel = new Map(); + for (const [turnId, turn] of agentTurns.entries()) { const prior = earliestByChannel.get(turn.channelId); if (prior === undefined || turn.startedAt < prior) { earliestByChannel.set(turn.channelId, turn.startedAt); } + const ids = turnIdsByChannel.get(turn.channelId) ?? []; + ids.push(turnId); + turnIdsByChannel.set(turn.channelId, ids); } const result = [...earliestByChannel.entries()] .map(([channelId, startedAt]) => ({ channelId, anchorAt: startedAt + offset, + turnIds: turnIdsByChannel.get(channelId) ?? [], })) .sort((a, b) => a.channelId.localeCompare(b.channelId)); cachedTurnSummaries.set(key, result); diff --git a/desktop/src/features/chats/ui/ChatActivityTranscript.tsx b/desktop/src/features/chats/ui/ChatActivityTranscript.tsx index 58ff77d19..3ec0dcfbe 100644 --- a/desktop/src/features/chats/ui/ChatActivityTranscript.tsx +++ b/desktop/src/features/chats/ui/ChatActivityTranscript.tsx @@ -88,16 +88,17 @@ function isSetupLifecycleItem(item: TranscriptItem) { } export function ChatActivityTranscript({ + activeTurnIds, agent, blocks, identityPubkey, - isTurnActive = false, profiles, }: { + /** Turn ids currently live in this channel — drives per-turn rendering. */ + activeTurnIds?: ReadonlySet; agent: ManagedAgent | null; blocks: ChatActivityRenderBlock[]; identityPubkey?: string; - isTurnActive?: boolean; profiles?: UserProfileLookup; }) { if (blocks.length === 0) { @@ -111,7 +112,10 @@ export function ChatActivityTranscript({ agent={agent} block={renderBlock.block} identityPubkey={identityPubkey} - isTurnActive={isTurnActive} + isTurnActive={ + renderBlock.block.kind === "turn" && + (activeTurnIds?.has(renderBlock.block.turnId) ?? false) + } key={renderBlock.id} profiles={profiles} suppressPromptMessage={renderBlock.suppressPromptMessage} @@ -132,6 +136,7 @@ function ChatActivityBlockView({ agent: ManagedAgent | null; block: TranscriptDisplayBlock; identityPubkey?: string; + /** Whether THIS block's turn is live (per-turn, never channel-wide). */ isTurnActive: boolean; profiles?: UserProfileLookup; suppressPromptMessage: boolean; @@ -172,7 +177,7 @@ function ChatActivityBlockView({ suppressPromptMessage={suppressPromptMessage} /> ))} - {!hasLiveActivityItem(block) ? ( + {isTurnActive && !hasLiveActivityItem(block) ? ( ) : ( diff --git a/desktop/src/features/chats/ui/ChatConversationRows.tsx b/desktop/src/features/chats/ui/ChatConversationRows.tsx index e95bfc798..33f33ca1a 100644 --- a/desktop/src/features/chats/ui/ChatConversationRows.tsx +++ b/desktop/src/features/chats/ui/ChatConversationRows.tsx @@ -73,6 +73,7 @@ export function ChatMessageRow({ {isAgent ? ( diff --git a/desktop/src/features/chats/ui/ChatDetail.tsx b/desktop/src/features/chats/ui/ChatDetail.tsx index ab66839f3..6d0806843 100644 --- a/desktop/src/features/chats/ui/ChatDetail.tsx +++ b/desktop/src/features/chats/ui/ChatDetail.tsx @@ -110,10 +110,19 @@ export function ChatDetail({ const updateMetadataMutation = useUpdateChatMetadataMutation(); const hasObserver = defaultAgent ? isManagedAgentActive(defaultAgent) : false; const activeAgentTurns = useActiveAgentTurns(defaultAgent?.pubkey); - const isChatTurnActive = React.useMemo( - () => activeAgentTurns.some((turn) => turn.channelId === chat.id), + // Per-turn ids, not a channel-wide boolean: while a new turn runs, older + // turn blocks must still render as completed (and never show their own + // "Working" marker). + const activeTurnIds = React.useMemo( + () => + new Set( + activeAgentTurns + .filter((turn) => turn.channelId === chat.id) + .flatMap((turn) => turn.turnIds), + ), [activeAgentTurns, chat.id], ); + const isChatTurnActive = activeTurnIds.size > 0; const transcript = useAgentTranscript(hasObserver, defaultAgent?.pubkey); const scopedTranscript = React.useMemo( () => scopeByChannel(transcript, chat.id), @@ -509,7 +518,7 @@ export function ChatDetail({ agent={defaultAgent} blocks={activityBlocks} identityPubkey={identityPubkey} - isTurnActive={isChatTurnActive} + activeTurnIds={activeTurnIds} profiles={profiles} /> @@ -535,7 +544,7 @@ export function ChatDetail({ agent={defaultAgent} blocks={chatActivity.unplacedBlocks} identityPubkey={identityPubkey} - isTurnActive={isChatTurnActive} + activeTurnIds={activeTurnIds} profiles={profiles} /> diff --git a/desktop/src/features/chats/ui/QuickStartChat.tsx b/desktop/src/features/chats/ui/QuickStartChat.tsx index 699b4e0dd..3f3196952 100644 --- a/desktop/src/features/chats/ui/QuickStartChat.tsx +++ b/desktop/src/features/chats/ui/QuickStartChat.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { Check, ChevronDown, @@ -12,7 +13,10 @@ import { toast } from "sonner"; import { useChannelTemplatesQuery } from "@/features/channel-templates/hooks"; import { useApplyTemplate } from "@/features/channel-templates/useApplyTemplate"; import { ChatHeader } from "@/features/chat/ui/ChatHeader"; -import { useManagedAgentsQuery } from "@/features/agents/hooks"; +import { + managedAgentsQueryKey, + useManagedAgentsQuery, +} from "@/features/agents/hooks"; import { useCreateChatMutation, useSendChatContextMessageMutation, @@ -88,6 +92,7 @@ export function QuickStartChat({ string | null >(() => initialProjectSelection(initialProjectId, projects)); const [isCreating, setIsCreating] = React.useState(false); + const queryClient = useQueryClient(); const identityQuery = useIdentityQuery(); const createChatMutation = useCreateChatMutation(); const updateMetadataMutation = useUpdateChatMetadataMutation(); @@ -168,6 +173,13 @@ export function QuickStartChat({ void applyAgents(templateId, chat.id); const agent = await ensureWelcomeGuideAgentInChannel(chat.id, relayUrl); + // The agent may have just been created/started outside the mutation + // hooks — refresh the managed-agents cache so the new chat resolves + // its default agent immediately (agent replies render as agent rows, + // not member bubbles). + await queryClient.invalidateQueries({ + queryKey: managedAgentsQueryKey, + }); const setupContext = buildProjectSetupContext({ agent, project: selectedProject, @@ -260,6 +272,7 @@ export function QuickStartChat({ identityQuery.data?.pubkey, isCreating, managedAgentsQuery.data, + queryClient, onCreated, onProjectCreated, relayUrl, diff --git a/desktop/src/shared/ui/link-preview-attachment.tsx b/desktop/src/shared/ui/link-preview-attachment.tsx index c16c29181..c9a90e872 100644 --- a/desktop/src/shared/ui/link-preview-attachment.tsx +++ b/desktop/src/shared/ui/link-preview-attachment.tsx @@ -227,6 +227,93 @@ export function GithubPullRequestCard({ ); } +/** + * Prominent PR card for agent-authored messages — when the agent reports a + * pull request it created, the card reads as a piece of delivered work + * (banner layout, status pill, full-width diff stats) rather than a pasted + * link. Falls back to the standard rich card without live data. + */ +export function AgentPullRequestCard({ + className, + preview, +}: { + className?: string; + preview: SupportedLinkPreview; +}) { + const ref = parseGithubPullRequestRef(preview.href); + const query = useGithubPullRequestQuery(ref); + const info = query.data ?? null; + + if (!ref || !info) { + return ; + } + + const status = githubPullRequestStatus(info); + const meta = PR_STATUS_META[status]; + const StatusIcon = meta.icon; + + return ( + +
+ + + {ref.owner}/{ref.repo} + + + Pull request #{ref.number} + + + {meta.label} + +
+
+ {info.title || preview.title} +
+
+ + +{info.additions.toLocaleString()} + + + −{info.deletions.toLocaleString()} + + + {info.changedFiles.toLocaleString()}{" "} + {info.changedFiles === 1 ? "file" : "files"} changed + +
+ + + + Open GitHub PR {ref.owner}/{ref.repo} #{ref.number} + + + +
+ ); +} + export function LinkPreviewAttachment({ className, preview, diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index 90c3510d7..93a4092cd 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -37,6 +37,7 @@ import remarkMessageLinks from "@/features/messages/lib/remarkMessageLinks"; import { AttachmentGroup } from "@/shared/ui/attachment"; import { ConfigNudgeCard } from "@/shared/ui/config-nudge-attachment"; import { + AgentPullRequestCard, GithubPullRequestCard, LinkPreviewAttachment, } from "@/shared/ui/link-preview-attachment"; @@ -1918,6 +1919,7 @@ function createMarkdownComponents( } function MarkdownInner({ + agentAuthored = false, channelNames, className, configNudgeAuthorPubkey, @@ -2077,7 +2079,11 @@ function MarkdownInner({ > {resolvedLinkPreviews.map((preview) => preview.kind === "github-pull-request" ? ( - + agentAuthored ? ( + + ) : ( + + ) ) : ( ), @@ -2093,6 +2099,7 @@ export const Markdown = React.memo( MarkdownInner, (prev, next) => prev.content === next.content && + prev.agentAuthored === next.agentAuthored && prev.className === next.className && prev.customEmoji === next.customEmoji && prev.interactive === next.interactive && diff --git a/desktop/src/shared/ui/markdown/types.ts b/desktop/src/shared/ui/markdown/types.ts index beea8852f..86fea5b72 100644 --- a/desktop/src/shared/ui/markdown/types.ts +++ b/desktop/src/shared/ui/markdown/types.ts @@ -43,6 +43,11 @@ export type MarkdownRuntime = { }; export type MarkdownProps = { + /** + * Message author is an agent — link previews it authored (e.g. PR links) + * render their richer agent-work variants. + */ + agentAuthored?: boolean; channelNames?: string[]; className?: string; content: string; diff --git a/desktop/tests/e2e/chats-first-message.spec.ts b/desktop/tests/e2e/chats-first-message.spec.ts index 39664bdb9..bb16fdff1 100644 --- a/desktop/tests/e2e/chats-first-message.spec.ts +++ b/desktop/tests/e2e/chats-first-message.spec.ts @@ -96,17 +96,24 @@ test("first message in a new chat is sent and rendered", async ({ page }) => { __BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: { channelName: string; content: string; + createdAt?: number; pubkey?: string; }) => unknown; }; + // Explicit ascending timestamps: same-second events sort unstably and + // can flip which agent reply the run-collapse keeps visible. + const base = Math.floor(Date.now() / 1000); win.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ channelName: "Hello Fizz, first message", content: "Sure — that first message says hello.", + createdAt: base, pubkey: pubkey ?? undefined, }); win.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ channelName: "Hello Fizz, first message", - content: "Anything else you want to check while we're here?", + content: + "Done! I've opened https://github.com/block/buzz/pull/1460 with the changes.", + createdAt: base + 2, pubkey: pubkey ?? undefined, }); }, @@ -133,4 +140,11 @@ test("first message in a new chat is sent and rendered", async ({ page }) => { }), ) .toBeGreaterThanOrEqual(-1); + + // Agent-authored PR links render the prominent agent-work card variant + // (banner layout with status pill), not the compact link chip. + await expect( + page.locator("[data-link-preview='github-pull-request-agent']"), + ).toBeVisible({ timeout: 10_000 }); + await page.screenshot({ path: "test-results/agent-pr-card.png" }); });