mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Claude Fable 5
parent
7d5f97d742
commit
1fe8695c69
@@ -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 || " "}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user