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" }); });