feat(frontend/copilot): add per-turn work-done summary stats (#12257)

## Summary
- Adds per-turn work-done counters (e.g. "3 searches", "1 agent run")
shown as plain text on the final assistant message of each
user/assistant interaction pair
- Counters aggregate tool calls by category (searches, agents run,
blocks run, agents created/edited, agents scheduled)
- Copy and TTS actions now appear only on the final assistant message
per turn, with text aggregated from all assistant messages in that turn
- Removes the global JobStatsBar above the chat input

Resolves: SECRT-2026

## Test plan
- [ ] Work-done counters appear only on the last assistant message of
each turn (not on intermediate assistant messages)
- [ ] Counters increment correctly as tool call parts appear in messages
- [ ] Internal operations (add_understanding, search_docs, get_doc_page,
find_block) are NOT counted
- [ ] Max 3 counter categories shown, sorted by volume
- [ ] Copy/TTS actions appear only on the final assistant message per
turn
- [ ] Copy/TTS aggregate text from all assistant messages in the turn
- [ ] No counters or actions shown while streaming is still in progress
- [ ] No type errors, lint errors, or format issues introduced

Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ubbe
2026-03-05 19:32:48 +08:00
committed by GitHub
parent 476cf1c601
commit 6abe39b33a
5 changed files with 139 additions and 8 deletions

View File

@@ -733,7 +733,10 @@ async def mark_session_completed(
# This is the SINGLE place that publishes StreamFinish — services and
# the processor must NOT publish it themselves.
try:
await publish_chunk(turn_id, StreamFinish())
await publish_chunk(
turn_id,
StreamFinish(),
)
except Exception as e:
logger.error(
f"Failed to publish StreamFinish for session {session_id}: {e}. "

View File

@@ -6,6 +6,8 @@ import {
import { Message, MessageContent } from "@/components/ai-elements/message";
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
import { FileUIPart, UIDataTypes, UIMessage, UITools } from "ai";
import { TOOL_PART_PREFIX } from "../JobStatsBar/constants";
import { TurnStatsBar } from "../JobStatsBar/TurnStatsBar";
import { parseSpecialMarkers } from "./helpers";
import { AssistantMessageActions } from "./components/AssistantMessageActions";
import { MessageAttachments } from "./components/MessageAttachments";
@@ -21,6 +23,23 @@ interface Props {
sessionID?: string | null;
}
/** Collect all messages belonging to a turn: the user message + every
* assistant message up to (but not including) the next user message. */
function getTurnMessages(
messages: UIMessage<unknown, UIDataTypes, UITools>[],
lastAssistantIndex: number,
): UIMessage<unknown, UIDataTypes, UITools>[] {
const userIndex = messages.findLastIndex(
(m, i) => i < lastAssistantIndex && m.role === "user",
);
const nextUserIndex = messages.findIndex(
(m, i) => i > lastAssistantIndex && m.role === "user",
);
const start = userIndex >= 0 ? userIndex : lastAssistantIndex;
const end = nextUserIndex >= 0 ? nextUserIndex : messages.length;
return messages.slice(start, end);
}
export function ChatMessagesContainer({
messages,
status,
@@ -31,9 +50,6 @@ export function ChatMessagesContainer({
}: Props) {
const lastMessage = messages[messages.length - 1];
// Determine if something is visibly "in-flight" in the last assistant message:
// - Text is actively streaming (last part is non-empty text)
// - A tool call is pending (state is input-streaming or input-available)
const hasInflight = (() => {
if (lastMessage?.role !== "assistant") return false;
const parts = lastMessage.parts;
@@ -41,13 +57,11 @@ export function ChatMessagesContainer({
const lastPart = parts[parts.length - 1];
// Text is actively being written
if (lastPart.type === "text" && lastPart.text.trim().length > 0)
return true;
// A tool call is still pending (no output yet)
if (
lastPart.type.startsWith("tool-") &&
lastPart.type.startsWith(TOOL_PART_PREFIX) &&
"state" in lastPart &&
(lastPart.state === "input-streaming" ||
lastPart.state === "input-available")
@@ -80,9 +94,13 @@ export function ChatMessagesContainer({
const isCurrentlyStreaming =
isLastAssistant &&
(status === "streaming" || status === "submitted");
const isAssistant = message.role === "assistant";
const nextMessage = messages[messageIndex + 1];
const isLastInTurn =
message.role === "assistant" &&
isAssistant &&
messageIndex <= messages.length - 1 &&
(!nextMessage || nextMessage.role === "user");
const textParts = message.parts.filter(
(p): p is Extract<typeof p, { type: "text" }> => p.type === "text",
@@ -118,6 +136,11 @@ export function ChatMessagesContainer({
partIndex={i}
/>
))}
{isLastInTurn && !isCurrentlyStreaming && (
<TurnStatsBar
turnMessages={getTurnMessages(messages, messageIndex)}
/>
)}
{isLastAssistant && showThinking && (
<ThinkingIndicator active={showThinking} />
)}

View File

@@ -0,0 +1,29 @@
import type { UIDataTypes, UIMessage, UITools } from "ai";
import { getWorkDoneCounters } from "./useWorkDoneCounters";
interface Props {
turnMessages: UIMessage<unknown, UIDataTypes, UITools>[];
}
export function TurnStatsBar({ turnMessages }: Props) {
const { counters } = getWorkDoneCounters(turnMessages);
if (counters.length === 0) return null;
return (
<div className="mt-2 flex items-center gap-1.5">
{counters.map(function renderCounter(counter, index) {
return (
<span key={counter.category} className="flex items-center gap-1">
{index > 0 && (
<span className="text-xs text-neutral-300">&middot;</span>
)}
<span className="text-[11px] tabular-nums text-neutral-500">
{counter.count} {counter.label}
</span>
</span>
);
})}
</div>
);
}

View File

@@ -0,0 +1 @@
export const TOOL_PART_PREFIX = "tool-";

View File

@@ -0,0 +1,75 @@
import type { UIDataTypes, UIMessage, UITools } from "ai";
import { TOOL_PART_PREFIX } from "./constants";
const TOOL_TO_CATEGORY: Record<string, string> = {
find_agent: "search",
find_library_agent: "search",
run_agent: "agent run",
run_block: "block run",
create_agent: "agent created",
edit_agent: "agent edited",
schedule_agent: "agent scheduled",
};
const MAX_COUNTERS = 3;
function pluralize(label: string, count: number): string {
if (count === 1) return label;
// "agent created" -> "agents created", "agent edited" -> "agents edited"
const nounVerbMatch = label.match(
/^(\w+)\s+(created|edited|scheduled|run)$/i,
);
if (nounVerbMatch) {
return pluralizeWord(nounVerbMatch[1]) + " " + nounVerbMatch[2];
}
return pluralizeWord(label);
}
function pluralizeWord(word: string): string {
if (word.endsWith("ch") || word.endsWith("sh") || word.endsWith("x"))
return word + "es";
return word + "s";
}
export interface WorkDoneCounter {
label: string;
count: number;
category: string;
}
export function getWorkDoneCounters(
messages: UIMessage<unknown, UIDataTypes, UITools>[],
) {
const categoryCounts = new Map<string, number>();
for (const message of messages) {
if (message.role !== "assistant") continue;
for (const part of message.parts) {
if (!part.type.startsWith(TOOL_PART_PREFIX)) continue;
const toolName = part.type.replace(TOOL_PART_PREFIX, "");
const category = TOOL_TO_CATEGORY[toolName];
if (!category) continue;
categoryCounts.set(category, (categoryCounts.get(category) ?? 0) + 1);
}
}
const counters: WorkDoneCounter[] = Array.from(categoryCounts.entries())
.map(function toCounter([category, count]) {
return {
label: pluralize(category, count),
count,
category,
};
})
.sort(function byCountDesc(a, b) {
return b.count - a.count;
})
.slice(0, MAX_COUNTERS);
return { counters };
}