Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 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
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 @@ -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";
Expand All @@ -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,
Expand All @@ -31,23 +50,18 @@ 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;
if (parts.length === 0) return false;

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")
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -118,6 +136,11 @@ export function ChatMessagesContainer({
partIndex={i}
/>
))}
{isLastInTurn && !isCurrentlyStreaming && (
<TurnStatsBar
turnMessages={getTurnMessages(messages, messageIndex)}
/>
)}
{isLastAssistant && showThinking && (
<ThinkingIndicator active={showThinking} />
)}
Comment thread
0ubbe marked this conversation as resolved.
Expand Down
Original file line number Diff line number Diff line change
@@ -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>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const TOOL_PART_PREFIX = "tool-";
Original file line number Diff line number Diff line change
@@ -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";
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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