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 <noreply@anthropic.com>
This commit is contained in:
klopez4212
2026-07-07 07:46:12 +01:00
co-authored by Claude Fable 5
parent 7d5f97d742
commit 1fe8695c69
9 changed files with 161 additions and 12 deletions
@@ -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<string, number>();
for (const turn of agentTurns.values()) {
const turnIdsByChannel = new Map<string, string[]>();
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);
@@ -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<string>;
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) ? (
<LiveTurnMarker
agentPubkey={agent?.pubkey}
icon={liveTurnMarkerIcon(block)}
@@ -466,6 +471,7 @@ function ChatTranscriptMessageRow({
</Bubble>
) : (
<Markdown
agentAuthored
className="w-full max-w-none text-sm leading-6"
content={displayText || text || " "}
/>
@@ -73,6 +73,7 @@ export function ChatMessageRow({
</MessageHeader>
{isAgent ? (
<Markdown
agentAuthored
className="w-full max-w-none text-sm leading-6"
content={content || " "}
/>
+13 -4
View File
@@ -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}
/>
</MessageScrollerItem>
@@ -535,7 +544,7 @@ export function ChatDetail({
agent={defaultAgent}
blocks={chatActivity.unplacedBlocks}
identityPubkey={identityPubkey}
isTurnActive={isChatTurnActive}
activeTurnIds={activeTurnIds}
profiles={profiles}
/>
</MessageScrollerItem>
@@ -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,
@@ -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 <GithubPullRequestCard className={className} preview={preview} />;
}
const status = githubPullRequestStatus(info);
const meta = PR_STATUS_META[status];
const StatusIcon = meta.icon;
return (
<Attachment
className={cn(
"w-full max-w-xl shrink-0 gap-2 px-4 py-3 no-underline shadow-none",
className,
)}
data-link-preview="github-pull-request-agent"
data-pr-status={status}
orientation="vertical"
>
<div className="flex w-full min-w-0 items-center gap-1.5 text-xs font-medium leading-4 text-muted-foreground">
<GitHubLogo className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 truncate">
{ref.owner}/{ref.repo}
</span>
<span aria-hidden="true">·</span>
<span className="shrink-0">Pull request #{ref.number}</span>
<span
className={cn(
"ml-auto inline-flex shrink-0 items-center gap-1 rounded-full border border-border/70 bg-background px-2 py-0.5 text-2xs font-semibold",
meta.className,
)}
>
<StatusIcon className="h-3 w-3" />
{meta.label}
</span>
</div>
<div className="w-full min-w-0 truncate text-sm font-semibold leading-5 text-foreground">
{info.title || preview.title}
</div>
<div className="flex w-full min-w-0 items-center gap-2 text-xs leading-4">
<span className="shrink-0 font-medium text-[color:var(--status-added)]">
+{info.additions.toLocaleString()}
</span>
<span className="shrink-0 font-medium text-[color:var(--status-deleted)]">
−{info.deletions.toLocaleString()}
</span>
<span className="min-w-0 truncate text-muted-foreground">
{info.changedFiles.toLocaleString()}{" "}
{info.changedFiles === 1 ? "file" : "files"} changed
</span>
<ExternalLink
aria-hidden="true"
className="ml-auto h-3.5 w-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover/attachment:opacity-100 group-focus-within/attachment:opacity-100"
/>
</div>
<AttachmentTrigger asChild>
<a
aria-label={`Open GitHub PR ${ref.owner}/${ref.repo} #${ref.number}: ${info.title || preview.title}`}
href={preview.href}
rel="noreferrer"
target="_blank"
>
<span className="sr-only">
Open GitHub PR {ref.owner}/{ref.repo} #{ref.number}
</span>
</a>
</AttachmentTrigger>
</Attachment>
);
}
export function LinkPreviewAttachment({
className,
preview,
+8 -1
View File
@@ -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" ? (
<GithubPullRequestCard key={preview.href} preview={preview} />
agentAuthored ? (
<AgentPullRequestCard key={preview.href} preview={preview} />
) : (
<GithubPullRequestCard key={preview.href} preview={preview} />
)
) : (
<LinkPreviewAttachment key={preview.href} preview={preview} />
),
@@ -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 &&
+5
View File
@@ -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;
+15 -1
View File
@@ -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" });
});