Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
15c40e8
feat(frontend/copilot): add live job duration timer and work-done sum…
0ubbe Mar 2, 2026
38b2c6f
fix(frontend/copilot): address review suggestions for job stats bar
0ubbe Mar 3, 2026
1ea7c27
chore: review suggestions
0ubbe Mar 4, 2026
f375110
feat(platform): add per-turn stats and clickable tool invocation moda…
0ubbe Mar 4, 2026
20b74ec
fix(frontend): only show per-turn stats on the final assistant messag…
0ubbe Mar 4, 2026
21d23bb
refactor(frontend): remove clickable modals from stats counters, disp…
0ubbe Mar 4, 2026
af228eb
fix(frontend): show copy/TTS actions only on final assistant message …
0ubbe Mar 4, 2026
c0d1850
refactor(frontend): remove global JobStatsBar above chat input
0ubbe Mar 4, 2026
1878941
fix(frontend): strip custom SSE fields before AI SDK parses them
0ubbe Mar 4, 2026
5082240
fix(platform): use client-side timing instead of custom SSE fields
0ubbe Mar 4, 2026
9dbd35e
refactor(frontend): remove turn duration/timing code from copilot
0ubbe Mar 4, 2026
648ea0a
Merge remote-tracking branch 'origin/dev' into lluisagusti/secrt-2026…
0ubbe Mar 5, 2026
bc0481d
refactor(frontend/copilot): clean up comments and tidy code per conve…
0ubbe Mar 5, 2026
4f90d23
Merge remote-tracking branch 'origin/dev' and address review feedback
0ubbe Mar 5, 2026
e9b0fcd
Merge branch 'dev' into lluisagusti/secrt-2026-add-live-job-duration-…
0ubbe Mar 5, 2026
dcd8c0d
Merge branch 'dev' into lluisagusti/secrt-2026-add-live-job-duration-…
0ubbe Mar 5, 2026
79fd9e6
Merge branch 'dev' into lluisagusti/secrt-2026-add-live-job-duration-…
0ubbe Mar 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion autogpt_platform/backend/backend/copilot/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -530,7 +530,10 @@ async def _update_title():
extra={"json_fields": {**log_meta, "setup_time_ms": setup_time}},
)
if not is_continuation:
yield StreamStart(messageId=message_id, sessionId=session.session_id)
yield StreamStart(
messageId=message_id,
sessionId=session.session_id,
)

# Emit start-step before each LLM call (AI SDK uses this to add step boundaries)
yield StreamStartStep()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}. "
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from "@/components/ai-elements/message";
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
import { FileUIPart, UIDataTypes, UIMessage, UITools } from "ai";
import { TurnStatsBar } from "../JobStatsBar/TurnStatsBar";
import { MessageAttachments } from "./components/MessageAttachments";
import { MessagePartRenderer } from "./components/MessagePartRenderer";
import { ThinkingIndicator } from "./components/ThinkingIndicator";
Expand All @@ -25,6 +26,26 @@ interface Props {
headerSlot?: React.ReactNode;
}

/** 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>[] {
// Walk back to find the user message that started this turn
let userIndex = lastAssistantIndex - 1;
while (userIndex >= 0 && messages[userIndex].role !== "user") {
Comment thread
0ubbe marked this conversation as resolved.
Outdated
userIndex--;
}
// Walk forward to find the end of the turn (next user message or end)
let endIndex = lastAssistantIndex + 1;
while (endIndex < messages.length && messages[endIndex].role !== "user") {
Comment thread
0ubbe marked this conversation as resolved.
Outdated
endIndex++;
}
const start = userIndex >= 0 ? userIndex : lastAssistantIndex;
return messages.slice(start, endIndex);
}

export function ChatMessagesContainer({
messages,
status,
Expand Down Expand Up @@ -89,6 +110,13 @@ export function ChatMessagesContainer({
(!isLastAssistant ||
(status !== "streaming" && status !== "submitted"));

// True when this is the last assistant message in its turn
// (next message is a user message or end of list).
const isLastAssistantInTurn =
isAssistant &&
(messageIndex === messages.length - 1 ||
Comment thread
0ubbe marked this conversation as resolved.
Outdated
messages[messageIndex + 1].role === "user");

const fileParts = message.parts.filter(
(p): p is FileUIPart => p.type === "file",
);
Expand All @@ -110,6 +138,16 @@ export function ChatMessagesContainer({
partIndex={i}
/>
))}
{/* Per-turn stats — shown only on the final assistant message of each turn */}
{isLastAssistantInTurn &&
!(
isLastAssistant &&
(status === "streaming" || status === "submitted")
) && (
<TurnStatsBar
turnMessages={getTurnMessages(messages, messageIndex)}
/>
)}
{isLastAssistant && showThinking && (
<ThinkingIndicator active={showThinking} />
)}
Comment thread
0ubbe marked this conversation as resolved.
Expand All @@ -120,23 +158,32 @@ export function ChatMessagesContainer({
isUser={message.role === "user"}
/>
)}
{isAssistantDone &&
{isLastAssistantInTurn &&
isAssistantDone &&
(() => {
const textParts = message.parts.filter(
(p): p is Extract<typeof p, { type: "text" }> =>
p.type === "text",
);
// Collect text from ALL assistant messages in this turn
const turnMsgs = getTurnMessages(messages, messageIndex);
const allTextParts = turnMsgs
.filter((m) => m.role === "assistant")
.flatMap((m) =>
m.parts.filter(
(p): p is Extract<typeof p, { type: "text" }> =>
p.type === "text",
),
);

// Hide actions when the message ended with an error or cancellation
const lastTextPart = textParts[textParts.length - 1];
// Hide actions when the turn ended with an error or cancellation
const lastTextPart = allTextParts[allTextParts.length - 1];
if (lastTextPart) {
const { markerType } = parseSpecialMarkers(
lastTextPart.text,
);
if (markerType === "error") return null;
}

const textContent = textParts.map((p) => p.text).join("\n");
const textContent = allTextParts
.map((p) => p.text)
.join("\n");
return (
<MessageActions>
<CopyButton text={textContent} />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { UIDataTypes, UIMessage, UITools } from "ai";
import { useWorkDoneCounters } from "./useWorkDoneCounters";

interface Props {
/** Messages scoped to this turn (user message + assistant response) */
turnMessages: UIMessage<unknown, UIDataTypes, UITools>[];
}

export function TurnStatsBar({ turnMessages }: Props) {
const { counters } = useWorkDoneCounters(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>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import type { UIDataTypes, UIMessage, UITools } from "ai";

/**
* Counter categories that map tool names to singular human-readable labels.
* Only "meaningful" external actions are counted -- internal operations
* (like add_understanding, search_docs, get_doc_page) are excluded.
*/
const TOOL_TO_CATEGORY: Record<string, string> = {
// Searches
find_agent: "search",
find_library_agent: "search",

// Agent runs
run_agent: "agent run",
run_block: "block run",

// Agent creation / editing
create_agent: "agent created",
edit_agent: "agent edited",

// Scheduling
schedule_agent: "agent scheduled",
};

/** Maximum number of counter categories to display */
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";
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export interface WorkDoneCounter {
label: string;
count: number;
category: string;
}

export function useWorkDoneCounters(
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-")) continue;
Comment thread
0ubbe marked this conversation as resolved.
Outdated

const toolName = part.type.replace("tool-", "");
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 };
}