From f9e344d44d99d646dfdc681cf6ed987d33587ffa Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 1 Jun 2026 22:12:03 +0200 Subject: [PATCH 01/17] feat(dashboard): Add telemetry metric tooltips Add shared dashboard metric components for tokens, durations, messages, and tool calls so conversation and turn headers use the same tooltip styling as chart metadata. Let the duration chart switch between turns and conversations, tighten duration tick labels, remove per-turn Sentry links, and keep Slack thread titles out of requester metadata. Co-Authored-By: GPT-5 Codex --- .../components/ConversationRowStats.tsx | 2 +- .../src/client/components/Metric.tsx | 159 ++++++++ .../client/components/TelemetryMetrics.tsx | 133 ++++++ .../src/client/components/TranscriptTurn.tsx | 83 +++- .../client/components/TurnDurationChart.tsx | 284 ++++++++++--- .../junior-dashboard/src/client/format.ts | 379 ++++++++++++++++-- .../src/client/pages/ConversationPage.tsx | 112 ++++-- packages/junior-dashboard/src/client/types.ts | 7 +- .../junior-dashboard/tests/format.test.ts | 127 +++++- packages/junior/src/reporting.ts | 5 +- 10 files changed, 1129 insertions(+), 162 deletions(-) create mode 100644 packages/junior-dashboard/src/client/components/Metric.tsx create mode 100644 packages/junior-dashboard/src/client/components/TelemetryMetrics.tsx diff --git a/packages/junior-dashboard/src/client/components/ConversationRowStats.tsx b/packages/junior-dashboard/src/client/components/ConversationRowStats.tsx index bc39f14d1..3b0750e40 100644 --- a/packages/junior-dashboard/src/client/components/ConversationRowStats.tsx +++ b/packages/junior-dashboard/src/client/components/ConversationRowStats.tsx @@ -19,7 +19,7 @@ export function ConversationRowStats(props: { const primaryStats = [ `${props.conversation.turns.length} turns`, tokens, - runtime ? `${runtime} runtime` : undefined, + runtime, ].filter(Boolean); const secondaryStats = [ props.timeLabel, diff --git a/packages/junior-dashboard/src/client/components/Metric.tsx b/packages/junior-dashboard/src/client/components/Metric.tsx new file mode 100644 index 000000000..27dc5c74e --- /dev/null +++ b/packages/junior-dashboard/src/client/components/Metric.tsx @@ -0,0 +1,159 @@ +import { + useId, + useRef, + useState, + type CSSProperties, + type ReactNode, +} from "react"; + +import { cn } from "../styles"; + +export type MetricTooltipLine = { + label?: string; + value: string; +}; + +export type MetricListItem = { + content: ReactNode; + key: string; +}; + +function titleText(lines: MetricTooltipLine[]): string { + return lines + .map((line) => (line.label ? `${line.label}: ${line.value}` : line.value)) + .join("\n"); +} + +type TooltipPosition = { + left: number; + top: number; + width: number; +}; + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); +} + +function tooltipPosition( + trigger: HTMLElement, + align: "left" | "right" | undefined, +): TooltipPosition { + const margin = 16; + const viewportWidth = window.innerWidth; + const width = Math.min(320, Math.max(256, viewportWidth - margin * 2)); + const rect = trigger.getBoundingClientRect(); + const preferredLeft = align === "right" ? rect.right - width : rect.left; + return { + left: Math.round( + clamp(preferredLeft, margin, viewportWidth - width - margin), + ), + top: Math.round(rect.bottom + 8), + width, + }; +} + +/** Render compact metadata text with an optional styled hover/focus tooltip. */ +export function MetricValue(props: { + align?: "left" | "right"; + children: ReactNode; + className?: string; + tooltip?: MetricTooltipLine[]; +}) { + const tooltipId = useId(); + const triggerRef = useRef(null); + const [position, setPosition] = useState(null); + const tooltip = props.tooltip?.filter((line) => line.value.trim()); + if (!tooltip?.length) { + return {props.children}; + } + + const showTooltip = () => { + if (!triggerRef.current) return; + setPosition(tooltipPosition(triggerRef.current, props.align)); + }; + const hideTooltip = () => setPosition(null); + const tooltipStyle: CSSProperties | undefined = position + ? { + left: position.left, + top: position.top, + width: position.width, + } + : undefined; + + return ( + + + {props.children} + + {position ? ( + + + {tooltip.map((line, index) => ( + + {line.label ? ( + + {line.label} + + ) : null} + {line.label ? ( + + {line.value} + + ) : ( + line.value + )} + + ))} + + + ) : null} + + ); +} + +/** Render inline metadata with consistent dot spacing across dashboard headers. */ +export function MetricList(props: { + className?: string; + items: MetricListItem[]; +}) { + return ( +
+ {props.items.map((item, index) => ( + + {index > 0 ? · : null} + {item.content} + + ))} +
+ ); +} diff --git a/packages/junior-dashboard/src/client/components/TelemetryMetrics.tsx b/packages/junior-dashboard/src/client/components/TelemetryMetrics.tsx new file mode 100644 index 000000000..a4b19b8e7 --- /dev/null +++ b/packages/junior-dashboard/src/client/components/TelemetryMetrics.tsx @@ -0,0 +1,133 @@ +import { + formatBytes, + formatCompactNumber, + formatMs, + formatTime, + formatTokenSummary, + type MessageSummary, + type TokenUsageSummary, + type ToolCallSummary, +} from "../format"; +import { MetricValue, type MetricTooltipLine } from "./Metric"; + +function plural(label: string, count: number): string { + return `${formatCompactNumber(count)} ${label}${count === 1 ? "" : "s"}`; +} + +function isMetricTooltipLine( + line: MetricTooltipLine | undefined, +): line is MetricTooltipLine { + return Boolean(line); +} + +function tokenTooltip(summary: TokenUsageSummary): MetricTooltipLine[] { + const lines: Array = [ + summary.inputTokens !== undefined + ? { label: "input", value: formatCompactNumber(summary.inputTokens) } + : undefined, + summary.outputTokens !== undefined + ? { label: "output", value: formatCompactNumber(summary.outputTokens) } + : undefined, + summary.cachedInputTokens !== undefined + ? { + label: "cached", + value: formatCompactNumber(summary.cachedInputTokens), + } + : undefined, + summary.cacheCreationTokens !== undefined + ? { + label: "cache write", + value: formatCompactNumber(summary.cacheCreationTokens), + } + : undefined, + summary.providerTotalTokens !== undefined + ? { + label: "provider", + value: formatCompactNumber(summary.providerTotalTokens), + } + : undefined, + ]; + return lines.filter(isMetricTooltipLine); +} + +/** Render total token usage with a hoverable breakdown. */ +export function TokenMetric(props: { + align?: "left" | "right"; + summary: TokenUsageSummary | undefined; +}) { + if (!props.summary) return null; + return ( + + {formatTokenSummary(props.summary)} + + ); +} + +/** Render a duration value with start/end timestamps in the tooltip. */ +export function DurationMetric(props: { + align?: "left" | "right"; + endedAt?: string; + label: string; + startedAt?: string; +}) { + if (!props.label || props.label === "none") return null; + const lines: Array = [ + props.startedAt + ? { label: "started", value: formatTime(props.startedAt) } + : undefined, + props.endedAt + ? { label: "ended", value: formatTime(props.endedAt) } + : undefined, + ]; + const tooltip = lines.filter(isMetricTooltipLine); + return ( + + {props.label} + + ); +} + +/** Render a tool-call count with top tool names, counts, and matched duration. */ +export function ToolCallsMetric(props: { + align?: "left" | "right"; + loading?: boolean; + summary: ToolCallSummary | undefined; +}) { + if (props.loading) return tool calls loading; + if (!props.summary) return null; + const tooltip = props.summary.items.map((item) => ({ + label: item.name, + value: [ + plural("call", item.count), + item.totalDurationMs !== undefined + ? formatMs(item.totalDurationMs) + : undefined, + ] + .filter(Boolean) + .join(" · "), + })); + return ( + + {plural("tool call", props.summary.total)} + + ); +} + +/** Render a conversational message count with per-message author and size. */ +export function MessagesMetric(props: { + align?: "left" | "right"; + loading?: boolean; + summary: MessageSummary | undefined; +}) { + if (props.loading) return messages loading; + if (!props.summary) return null; + const tooltip = props.summary.items.map((item) => ({ + label: item.author, + value: formatBytes(item.bytes), + })); + return ( + + {plural("message", props.summary.total)} + + ); +} diff --git a/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx b/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx index dcf3f1da5..8a427a229 100644 --- a/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx +++ b/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx @@ -10,8 +10,11 @@ import { formatMessageOffset, formatMessageTimestamp, formatMs, - formatUsage, requesterLabel, + summarizeMessages, + summarizeToolCalls, + summarizeUsage, + turnMessageCount, stringifyPartValue, unavailableTranscriptLabel, visualStatusForSession, @@ -24,6 +27,13 @@ import type { } from "../types"; import { StatusBadge } from "./StatusBadge"; import { ToolFrame, toolFrameClass } from "./ToolFrame"; +import { MetricList, type MetricListItem } from "./Metric"; +import { + DurationMetric, + MessagesMetric, + TokenMetric, + ToolCallsMetric, +} from "./TelemetryMetrics"; import { TranscriptText } from "./TranscriptText"; import { TranscriptToolView } from "./TranscriptToolView"; import { @@ -148,22 +158,10 @@ function TurnHeader(props: { number: number; turn: ConversationTurn }) {
Turn {props.number}
-
- {turnMeta(props.turn).join(" · ")} - {props.turn.sentryTraceUrl ? ( - <> - {" · "} - - View in Sentry - - - ) : null} -
+ @@ -352,11 +350,52 @@ function turnActorLabel(turn: ConversationTurn): string { ); } -function turnMeta(turn: ConversationTurn): string[] { - return [ - formatMs(turn.cumulativeDurationMs), - formatUsage(turn.cumulativeUsage), - ].filter((value) => value && value !== "none"); +function turnMessageSummary(turn: ConversationTurn) { + const summary = summarizeMessages([turn]); + if (summary.total > 0) return summary; + const total = turnMessageCount(turn); + return total > 0 ? { items: [], total } : undefined; +} + +function turnMeta(turn: ConversationTurn): MetricListItem[] { + const duration = formatMs(turn.cumulativeDurationMs); + const tokenSummary = summarizeUsage([turn.cumulativeUsage]); + const toolSummary = summarizeToolCalls([turn]); + const messageSummary = turnMessageSummary(turn); + const items: Array = [ + duration !== "none" + ? { + content: ( + + ), + key: "duration", + } + : undefined, + tokenSummary + ? { + content: , + key: "tokens", + } + : undefined, + messageSummary + ? { + content: , + key: "messages", + } + : undefined, + toolSummary.total > 0 + ? { + content: , + key: "tools", + } + : undefined, + ]; + + return items.filter((item): item is MetricListItem => Boolean(item)); } /** diff --git a/packages/junior-dashboard/src/client/components/TurnDurationChart.tsx b/packages/junior-dashboard/src/client/components/TurnDurationChart.tsx index 7ce7393a7..32f91c4f4 100644 --- a/packages/junior-dashboard/src/client/components/TurnDurationChart.tsx +++ b/packages/junior-dashboard/src/client/components/TurnDurationChart.tsx @@ -1,3 +1,4 @@ +import { useState, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; import { useNavigate } from "react-router"; import { @@ -12,20 +13,38 @@ import { import { readConversationData } from "../api"; import { + buildConversations, + conversationDisplayTitle, conversationIdForSession, conversationPath, + formatDurationTick, formatMs, - formatTokenTotal, requesterLabel, slackLocationLabel, - turnToolCallCount, + summarizeMessages, + summarizeToolCalls, + summarizeUsage, visualStatusForSession, + visualStatusForConversation, } from "../format"; import { cn } from "../styles"; -import type { ConversationDetailFeed, Session, VisualStatus } from "../types"; +import type { + Conversation, + ConversationDetailFeed, + ConversationTurn, + Session, + VisualStatus, +} from "../types"; +import { MetricValue } from "./Metric"; import { Section } from "./Section"; import { SectionHeader } from "./SectionHeader"; import { SectionTitle } from "./SectionTitle"; +import { + DurationMetric, + MessagesMetric, + TokenMetric, + ToolCallsMetric, +} from "./TelemetryMetrics"; import { statusBorderClass } from "./statusStyles"; /** Render recent turns by start time and duration. */ @@ -33,6 +52,7 @@ export function TurnDurationChart(props: { sessions: Session[]; timeZone: string; }) { + const [mode, setMode] = useState("turns"); const navigate = useNavigate(); const nowMs = Date.now(); const rangeStartMs = nowMs - 7 * 24 * 60 * 60 * 1000; @@ -40,9 +60,15 @@ export function TurnDurationChart(props: { const chartEdgePaddingMs = 6 * 60 * 60 * 1000; const chartRangeStartMs = rangeStartMs - chartEdgePaddingMs; const chartRangeEndMs = rangeEndMs + chartEdgePaddingMs; - const points = props.sessions - .map((session) => turnPoint(session, props.timeZone)) - .filter((point): point is TurnDurationPoint => Boolean(point)) + const conversations = buildConversations(props.sessions); + const points = ( + mode === "turns" + ? props.sessions.map((session) => turnPoint(session, props.timeZone)) + : conversations.map((conversation) => + conversationPoint(conversation, props.timeZone), + ) + ) + .filter((point): point is DurationPoint => Boolean(point)) .filter((point) => point.x >= rangeStartMs && point.x <= rangeEndMs) .sort((left, right) => left.x - right.x); const totals = points.reduce( @@ -61,26 +87,30 @@ export function TurnDurationChart(props: { const dayTicks = Array.from({ length: 7 }, (_, index) => { return rangeStartMs + index * 24 * 60 * 60 * 1000; }); - const openPoint = (point: TurnDurationPoint) => { - navigate(conversationPath(conversationIdForSession(point.session))); + const openPoint = (point: DurationPoint) => { + navigate(conversationPath(point.conversationId)); }; + const modeLabel = mode === "turns" ? "turns" : "conversations"; return (
+
} > - Turns +
+ Durations + +
@@ -107,7 +137,7 @@ export function TurnDurationChart(props: { axisLine={false} dataKey="durationMs" domain={[0, durationAxisMaxMs]} - tickFormatter={(value) => formatMs(Number(value))} + tickFormatter={(value) => formatDurationTick(Number(value))} tick={{ fill: "#888", fontFamily: @@ -116,6 +146,7 @@ export function TurnDurationChart(props: { }} tickLine={false} type="number" + width={48} /> } @@ -131,7 +162,7 @@ export function TurnDurationChart(props: {
- {totals.total} turns / {totals.hung} hung / {totals.failed} errors + {totals.total} {modeLabel} / {totals.hung} hung / {totals.failed} errors
); @@ -147,25 +178,68 @@ function ChartLegendItem(props: { className: string; label: string }) { } type PlottedTurnStatus = Exclude; +type DurationChartMode = "conversations" | "turns"; -type TurnDurationPoint = { +type DurationPoint = { + conversation?: Conversation; + conversationId: string; durationMs: number; - tooltipLabel: string; - session: Session; + endedAt?: string; + kind: DurationChartMode; + session?: Session; + startedAt?: string; status: PlottedTurnStatus; + subtitle: string; + title: string; + tooltipLabel: string; x: number; }; -function turnPoint( - session: Session, - timeZone: string, -): TurnDurationPoint | null { +function ChartModeToggle(props: { + mode: DurationChartMode; + onChange(mode: DurationChartMode): void; +}) { + const modes: Array<{ label: string; value: DurationChartMode }> = [ + { label: "Turns", value: "turns" }, + { label: "Conversations", value: "conversations" }, + ]; + return ( +
+ {modes.map((mode) => ( + + ))} +
+ ); +} + +function plottedStatus(status: VisualStatus): PlottedTurnStatus | null { + return status === "active" ? null : status; +} + +function turnPoint(session: Session, timeZone: string): DurationPoint | null { const startedAtMs = Date.parse(session.startedAt ?? ""); if (!Number.isFinite(startedAtMs)) { return null; } - const status = visualStatusForSession(session); - if (status === "active") { + const status = plottedStatus(visualStatusForSession(session)); + if (!status) { return null; } @@ -176,9 +250,53 @@ function turnPoint( ? Math.max(0, lastSeenAtMs - startedAtMs) : 0); return { + conversationId: conversationIdForSession(session), durationMs, + endedAt: session.completedAt ?? session.lastSeenAt, + kind: "turns", session, + startedAt: session.startedAt, status, + subtitle: new Date(startedAtMs).toLocaleString(undefined, { + timeZone, + }), + title: turnTooltipTitle(session), + tooltipLabel: new Date(startedAtMs).toLocaleString(undefined, { + timeZone, + }), + x: startedAtMs, + }; +} + +function conversationPoint( + conversation: Conversation, + timeZone: string, +): DurationPoint | null { + const startedAtMs = Date.parse(conversation.startedAt ?? ""); + if (!Number.isFinite(startedAtMs)) { + return null; + } + const status = plottedStatus(visualStatusForConversation(conversation)); + if (!status) { + return null; + } + const lastSeenAtMs = Date.parse(conversation.lastSeenAt ?? ""); + const durationMs = Number.isFinite(lastSeenAtMs) + ? Math.max(0, lastSeenAtMs - startedAtMs) + : 0; + + return { + conversation, + conversationId: conversation.id, + durationMs, + endedAt: conversation.lastSeenAt, + kind: "conversations", + startedAt: conversation.startedAt, + status, + subtitle: new Date(startedAtMs).toLocaleString(undefined, { + timeZone, + }), + title: conversationDisplayTitle(conversation), tooltipLabel: new Date(startedAtMs).toLocaleString(undefined, { timeZone, }), @@ -189,13 +307,10 @@ function turnPoint( type DurationDotProps = { cx?: number; cy?: number; - payload?: TurnDurationPoint; + payload?: DurationPoint; }; -function durationDot( - onOpen: (point: TurnDurationPoint) => void, - active: boolean, -) { +function durationDot(onOpen: (point: DurationPoint) => void, active: boolean) { return (props: DurationDotProps) => { if (props.cx == null || props.cy == null || !props.payload) { return ; @@ -205,7 +320,7 @@ function durationDot( const fill = durationDotFill(point.status, active); return ( ; + payload?: Array<{ payload: DurationPoint }>; }) { const point = props.payload?.[0]?.payload; - const conversationId = point - ? conversationIdForSession(point.session) - : undefined; + const conversationId = point?.conversationId; const detail = useQuery({ enabled: Boolean(props.active && conversationId), queryKey: ["conversation", conversationId], @@ -256,7 +369,7 @@ function TurnDurationTooltip(props: { if (!props.active || !point) { return null; } - const rows = turnTooltipRows(point, detail.data); + const rows = chartTooltipRows(point, detail.data); return (
-
+
- {turnTooltipTitle(point.session)} + {point.title}
{point.tooltipLabel}
- - {point.status} - + {chartTooltipStatus(point.status)}
{rows.map(([label, value]) => ( @@ -293,35 +404,72 @@ function TurnDurationTooltip(props: { ); } -function turnTooltipRows( - point: TurnDurationPoint, +function chartTooltipRows( + point: DurationPoint, detail: ConversationDetailFeed | undefined, -): Array<[string, string]> { - const session = point.session; +): Array<[string, ReactNode]> { + const session = point.session ?? point.conversation; const requester = requesterLabel( - session.requesterIdentity, - session.requester, + session?.requesterIdentity, + session?.requester, ); - const location = slackLocationLabel(session, { includeId: false }); - const tokens = formatTokenTotal(session.cumulativeUsage); - return [ - ["duration", formatMs(point.durationMs)], - tokens ? ["tokens", tokens] : null, - ["tool calls", turnTooltipToolCalls(point, detail)], + const location = session + ? slackLocationLabel(session, { includeId: false }) + : undefined; + const turns = chartTooltipTurns(point, detail); + const tokenSummary = summarizeUsage( + point.kind === "turns" + ? [point.session?.cumulativeUsage] + : (detail?.turns ?? point.conversation?.turns ?? []).map( + (turn) => turn.cumulativeUsage, + ), + ); + const messageSummary = detail ? summarizeMessages(turns) : undefined; + const toolSummary = detail ? summarizeToolCalls(turns) : undefined; + const rows: Array<[string, ReactNode] | null> = [ + [ + "duration", + , + ], + tokenSummary + ? ["tokens", ] + : null, + [ + "messages", + detail ? ( + + ) : ( + "loading" + ), + ], + [ + "tool calls", + detail ? ( + + ) : ( + "loading" + ), + ], requester ? ["requester", requester] : null, location ? ["surface", location] : null, - ].filter((row): row is [string, string] => Boolean(row)); + ]; + return rows.filter((row): row is [string, ReactNode] => row !== null); } -function turnTooltipToolCalls( - point: TurnDurationPoint, +function chartTooltipTurns( + point: DurationPoint, detail: ConversationDetailFeed | undefined, -): string { - if (!detail) { - return "loading"; +): ConversationTurn[] { + if (point.kind === "conversations") { + return detail?.turns ?? []; } - const turn = detail.turns.find((item) => item.id === point.session.id); - return String(turn ? turnToolCallCount(turn) : 0); + const turn = detail?.turns.find((item) => item.id === point.session?.id); + return turn ? [turn] : []; } function turnTooltipTitle(session: Session): string { @@ -332,12 +480,20 @@ function turnTooltipTitle(session: Session): string { ); } -function chartTooltipStatusClass(status: PlottedTurnStatus): string { - return cn( - "shrink-0 text-[0.68rem] font-bold uppercase leading-none", - status === "failed" && "text-rose-300", - status === "hung" && "text-amber-300", - status === "idle" && "text-[#b8b8b8]", +function chartTooltipStatus(status: PlottedTurnStatus): ReactNode { + if (status === "idle") { + return null; + } + return ( + + {status === "failed" ? "error" : status} + ); } diff --git a/packages/junior-dashboard/src/client/format.ts b/packages/junior-dashboard/src/client/format.ts index 8064a9372..200004731 100644 --- a/packages/junior-dashboard/src/client/format.ts +++ b/packages/junior-dashboard/src/client/format.ts @@ -8,6 +8,8 @@ import type { RequesterIdentity, Session, SessionFilter, + TranscriptMessage, + TranscriptPart, TurnUsage, VisualStatus, } from "./types"; @@ -99,11 +101,22 @@ export function formatMs(value: number | undefined): string { if (ms < 1000) return `${ms}ms`; const seconds = ms / 1000; if (seconds < 60) return `${seconds.toFixed(seconds < 10 ? 1 : 0)}s`; - const minutes = Math.floor(seconds / 60); - const remainingSeconds = Math.round(seconds % 60); + const roundedSeconds = Math.round(seconds); + const minutes = Math.floor(roundedSeconds / 60); + const remainingSeconds = roundedSeconds % 60; return `${minutes}m ${remainingSeconds}s`; } +/** Format chart duration ticks without long labels wrapping on the Y axis. */ +export function formatDurationTick(value: number | undefined): string { + if (typeof value !== "number" || !Number.isFinite(value)) return "none"; + const ms = Math.max(0, Math.floor(value)); + if (Math.round(ms / 1000) >= 10 * 60) { + return `${Math.round(ms / (60 * 1000))}m`; + } + return formatMs(ms); +} + /** Format aggregate runtime across turn summaries when duration data exists. */ export function formatDurationTotal( durations: Array, @@ -259,50 +272,281 @@ export function turnToolCallCount(turn: ConversationTurn): number { }, 0); } +export type ToolCallSummaryItem = { + count: number; + name: string; + totalDurationMs?: number; +}; + +export type ToolCallSummary = { + items: ToolCallSummaryItem[]; + total: number; +}; + +type PendingToolCall = { + id?: string; + name: string; + timestamp?: number; +}; + +function toolCallName(part: TranscriptPart): string { + return part.name ?? part.id ?? "unknown"; +} + +function sameToolPart( + call: Pick, + result: Pick, +): boolean { + if (call.id && result.id) return call.id === result.id; + if (call.name && result.name) return call.name === result.name; + return !call.id && !call.name && !result.id && !result.name; +} + +function findPendingToolCallIndex( + calls: PendingToolCall[], + result: TranscriptPart, +): number { + for (let index = calls.length - 1; index >= 0; index -= 1) { + if (sameToolPart(calls[index]!, result)) { + return index; + } + } + return -1; +} + +/** Summarize tool calls and matched result durations from transcript metadata. */ +export function summarizeToolCalls( + turns: ConversationTurn[], + limit = 5, +): ToolCallSummary { + const byName = new Map(); + let total = 0; + + for (const turn of turns) { + const pending: PendingToolCall[] = []; + for (const message of transcriptSource(turn)) { + for (const part of message.parts) { + if (part.type === "tool_call") { + const name = toolCallName(part); + const item = byName.get(name) ?? { count: 0, name }; + item.count += 1; + byName.set(name, item); + pending.push({ + ...(part.id ? { id: part.id } : {}), + name, + ...(typeof message.timestamp === "number" + ? { timestamp: message.timestamp } + : {}), + }); + total += 1; + continue; + } + + if (part.type !== "tool_result") continue; + const pendingIndex = findPendingToolCallIndex(pending, part); + if (pendingIndex < 0) continue; + const [call] = pending.splice(pendingIndex, 1); + if ( + !call || + typeof call.timestamp !== "number" || + typeof message.timestamp !== "number" || + message.timestamp < call.timestamp + ) { + continue; + } + const item = byName.get(call.name); + if (!item) continue; + item.totalDurationMs = + (item.totalDurationMs ?? 0) + (message.timestamp - call.timestamp); + } + } + } + + const items = [...byName.values()] + .sort( + (left, right) => + right.count - left.count || + (right.totalDurationMs ?? 0) - (left.totalDurationMs ?? 0) || + left.name.localeCompare(right.name), + ) + .slice(0, limit); + + return { items, total }; +} + +export type MessageSummaryItem = { + author: string; + bytes: number; +}; + +export type MessageSummary = { + items: MessageSummaryItem[]; + total: number; +}; + +function transcriptMessageAuthor( + turn: ConversationTurn, + message: TranscriptMessage, +): string { + const kind = transcriptRoleKind(message.role); + if (kind === "assistant") return "Junior"; + if (kind === "user") { + return requesterLabel(turn.requesterIdentity, turn.requester) ?? "User"; + } + if (kind === "system") return "System"; + if (kind === "tool") return "Tool"; + return message.role || "Unknown"; +} + +function transcriptPartBytes(part: TranscriptPart): number { + if (typeof part.bytes === "number" && Number.isFinite(part.bytes)) { + return Math.max(0, Math.floor(part.bytes)); + } + if ( + typeof part.inputSizeBytes === "number" && + Number.isFinite(part.inputSizeBytes) + ) { + return Math.max(0, Math.floor(part.inputSizeBytes)); + } + if ( + typeof part.outputSizeBytes === "number" && + Number.isFinite(part.outputSizeBytes) + ) { + return Math.max(0, Math.floor(part.outputSizeBytes)); + } + return new TextEncoder().encode( + stringifyPartValue(part.text ?? part.input ?? part.output ?? part), + ).byteLength; +} + +/** Summarize conversational messages by author and serialized size. */ +export function summarizeMessages(turns: ConversationTurn[]): MessageSummary { + const items: MessageSummaryItem[] = []; + + for (const turn of turns) { + for (const message of transcriptSource(turn)) { + if (!isConversationMessage(message)) continue; + items.push({ + author: transcriptMessageAuthor(turn, message), + bytes: message.parts.reduce( + (sum, part) => sum + transcriptPartBytes(part), + 0, + ), + }); + } + } + + return { items, total: items.length }; +} + +/** Format raw counts with the dashboard's compact number rules. */ +export function formatCompactNumber(value: number | undefined): string { + return formatNumber(value); +} + +export type TokenUsageSummary = { + cachedInputTokens?: number; + cacheCreationTokens?: number; + inputTokens?: number; + outputTokens?: number; + providerTotalTokens?: number; + totalTokens: number; +}; + +function addOptionalCount( + left: number | undefined, + right: number | undefined, +): number | undefined { + return right === undefined ? left : (left ?? 0) + right; +} + +/** Summarize token usage without double-counting provider total fields. */ +export function summarizeUsage( + usages: Array, +): TokenUsageSummary | undefined { + const summary: TokenUsageSummary = { totalTokens: 0 }; + + for (const usage of usages) { + if (!usage) continue; + + const componentTotal = getUsageComponentTotal(usage); + if (componentTotal !== undefined) { + summary.totalTokens += componentTotal; + summary.inputTokens = addOptionalCount( + summary.inputTokens, + getFiniteTokenCount(usage.inputTokens), + ); + summary.outputTokens = addOptionalCount( + summary.outputTokens, + getFiniteTokenCount(usage.outputTokens), + ); + summary.cachedInputTokens = addOptionalCount( + summary.cachedInputTokens, + getFiniteTokenCount(usage.cachedInputTokens), + ); + summary.cacheCreationTokens = addOptionalCount( + summary.cacheCreationTokens, + getFiniteTokenCount(usage.cacheCreationTokens), + ); + continue; + } + + const providerTotal = getFiniteTokenCount(usage.totalTokens); + if (providerTotal !== undefined) { + summary.totalTokens += providerTotal; + summary.providerTotalTokens = + (summary.providerTotalTokens ?? 0) + providerTotal; + } + } + + return summary.totalTokens > 0 ? summary : undefined; +} + function totalUsageTokens(usage: TurnUsage | undefined): number | undefined { - if (!usage) return undefined; - return ( - getUsageComponentTotal(usage) ?? getFiniteTokenCount(usage.totalTokens) - ); + return summarizeUsage([usage])?.totalTokens; +} + +/** Format a summarized token counter for compact metadata. */ +export function formatTokenSummary( + summary: TokenUsageSummary | undefined, +): string { + return summary ? `${formatNumber(summary.totalTokens)} tokens` : ""; } /** Format known token counters without estimating per-message usage. */ export function formatTokenTotal(usage: TurnUsage | undefined): string { - const total = totalUsageTokens(usage); - return total === undefined ? "" : `${formatNumber(total)} tokens`; + return formatTokenSummary(summarizeUsage([usage])); } /** Format the aggregate token count across conversation turns. */ export function formatUsageTotal(usages: Array): string { - const total = usages.reduce((sum, usage) => { - const tokens = totalUsageTokens(usage); - if (tokens === undefined) return sum; - return (sum ?? 0) + tokens; - }, undefined); - return total === undefined ? "" : `${formatNumber(total)} tokens`; + return formatTokenSummary(summarizeUsage(usages)); } /** Format known token counters with available input/output detail. */ export function formatUsage(usage: TurnUsage | undefined): string { - const total = totalUsageTokens(usage); - if (total === undefined) return ""; + const summary = summarizeUsage([usage]); + if (!summary) return ""; const pieces = [ - usage?.inputTokens !== undefined - ? `${formatNumber(usage.inputTokens)} in` + summary.inputTokens !== undefined + ? `${formatNumber(summary.inputTokens)} in` : undefined, - usage?.outputTokens !== undefined - ? `${formatNumber(usage.outputTokens)} out` + summary.outputTokens !== undefined + ? `${formatNumber(summary.outputTokens)} out` : undefined, - usage?.cachedInputTokens !== undefined - ? `${formatNumber(usage.cachedInputTokens)} cached` + summary.cachedInputTokens !== undefined + ? `${formatNumber(summary.cachedInputTokens)} cached` : undefined, - usage?.cacheCreationTokens !== undefined - ? `${formatNumber(usage.cacheCreationTokens)} cache-write` + summary.cacheCreationTokens !== undefined + ? `${formatNumber(summary.cacheCreationTokens)} cache-write` + : undefined, + summary.providerTotalTokens !== undefined + ? `${formatNumber(summary.providerTotalTokens)} provider total` : undefined, ].filter(Boolean); return pieces.length > 0 - ? `${formatNumber(total)} tokens (${pieces.join(" / ")})` - : `${formatNumber(total)} tokens`; + ? `${formatNumber(summary.totalTokens)} tokens (${pieces.join(" / ")})` + : `${formatNumber(summary.totalTokens)} tokens`; } /** Format a conversation span from first turn start to latest activity. */ @@ -330,7 +574,27 @@ function compareTimeAsc(a: string | undefined, b: string | undefined): number { return (parseTime(a) ?? 0) - (parseTime(b) ?? 0); } +function isGenericTurnTitle(title: string, conversationId: string): boolean { + const normalized = title.trim(); + return ( + normalized.length === 0 || + normalized === conversationId || + /^Turn\s+\S+/i.test(normalized) || + /^Awaiting\s+\w+\s+resume$/i.test(normalized) + ); +} + +function meaningfulConversationTitle( + conversation: Conversation, +): string | undefined { + return isGenericTurnTitle(conversation.title, conversation.id) + ? undefined + : conversation.title; +} + function getConversationTitle(conversation: Conversation): string { + const title = meaningfulConversationTitle(conversation); + if (title) return title; if (conversation.surface === "slack") { return ( slackLocationLabel(conversation, { includeId: false }) ?? @@ -353,25 +617,66 @@ export function requesterLabel( requester: RequesterIdentity | undefined, fallback: string | undefined, ): string | undefined { + const slackUserName = requester?.slackUserName?.trim(); + const safeSlackUserName = + slackUserName && !/\s/.test(slackUserName) ? slackUserName : undefined; + const fallbackLabel = + fallback?.trim() && fallback.trim() !== slackUserName + ? fallback.trim() + : undefined; return ( requester?.email ?? - requester?.slackUserName ?? requester?.fullName ?? - fallback ?? + safeSlackUserName ?? + fallbackLabel ?? requester?.slackUserId ); } +function sameDisplayLabel( + left: string | undefined, + right: string | undefined, +): boolean { + return Boolean( + left && right && left.trim().toLowerCase() === right.trim().toLowerCase(), + ); +} + +function suspiciousThreadTitleFromRequester( + requester: RequesterIdentity | undefined, +): string | undefined { + const slackUserName = requester?.slackUserName?.trim(); + return slackUserName && /\s/.test(slackUserName) ? slackUserName : undefined; +} + +function conversationRequesterLabel( + conversation: Conversation | undefined, +): string | undefined { + if (!conversation) return undefined; + const owner = requesterLabel( + conversation.requesterIdentity, + conversation.requester, + ); + if ( + sameDisplayLabel(owner, conversation.conversationTitle) || + sameDisplayLabel(owner, meaningfulConversationTitle(conversation)) || + sameDisplayLabel( + owner, + slackLocationLabel(conversation, { includeId: false }), + ) + ) { + return undefined; + } + return owner; +} + /** Format the owner and permalink id line shared by conversation rows and headers. */ export function conversationIdentityMeta( conversation: Conversation | undefined, conversationId: string | undefined, ): string { const id = conversationId ?? "missing conversation id"; - const owner = requesterLabel( - conversation?.requesterIdentity, - conversation?.requester, - ); + const owner = conversationRequesterLabel(conversation); return owner ? `${owner} · ${id}` : id; } @@ -697,12 +1002,18 @@ export function buildConversations(sessions: Session[]): Conversation[] { const requesterTurn = sortedTurns.find((turn) => turn.requesterIdentity) ?? sortedTurns.find((turn) => turn.requester); + const conversationTitle = + sortedTurns.find((turn) => turn.conversationTitle)?.conversationTitle ?? + sortedTurns + .map((turn) => + suspiciousThreadTitleFromRequester(turn.requesterIdentity), + ) + .find((title): title is string => Boolean(title)); return { channel: newest.channel, channelName: sortedTurns.find((turn) => turn.channelName)?.channelName, - conversationTitle: sortedTurns.find((turn) => turn.conversationTitle) - ?.conversationTitle, + conversationTitle, id, lastSeenAt: newest.lastSeenAt, requester: requesterLabel( diff --git a/packages/junior-dashboard/src/client/pages/ConversationPage.tsx b/packages/junior-dashboard/src/client/pages/ConversationPage.tsx index 29fdd1276..6adb4c8a2 100644 --- a/packages/junior-dashboard/src/client/pages/ConversationPage.tsx +++ b/packages/junior-dashboard/src/client/pages/ConversationPage.tsx @@ -4,16 +4,24 @@ import { useConversationData } from "../api"; import { StatusBadge } from "../components/StatusBadge"; import { buildConversations, + conversationIdentityMeta, conversationDisplayTitle, formatConversationDuration, formatRelativeTime, formatTime, - formatUsageTotal, slackLocationLabel, - turnMessageCount, - turnToolCallCount, + summarizeMessages, + summarizeToolCalls, + summarizeUsage, visualStatusForConversation, } from "../format"; +import { MetricList, type MetricListItem } from "../components/Metric"; +import { + DurationMetric, + MessagesMetric, + TokenMetric, + ToolCallsMetric, +} from "../components/TelemetryMetrics"; import { Transcript } from "../components/Transcript"; import { TranscriptLoading } from "../components/TranscriptLoading"; import type { @@ -81,15 +89,9 @@ function ConversationIdentity(props: { conversation: Conversation | undefined; conversationId: string | undefined; }) { - const id = props.conversationId ?? "missing conversation id"; - const owner = - props.conversation?.requesterIdentity?.email ?? - props.conversation?.requester ?? - props.conversation?.requesterIdentity?.slackUserName; return ( <> - {owner ? `${owner} · ` : ""} - {id} + {conversationIdentityMeta(props.conversation, props.conversationId)} {props.conversation?.sentryConversationUrl ? ( <> {" · "} @@ -112,41 +114,75 @@ function ConversationStats(props: { detail?: ConversationDetailFeed; }) { if (!props.conversation) return null; - const messages = props.detail - ? props.detail.turns.reduce( - (count, turn) => count + turnMessageCount(turn), - 0, - ) + const messageSummary = props.detail + ? summarizeMessages(props.detail.turns) : undefined; - const toolCalls = props.detail - ? props.detail.turns.reduce( - (count, turn) => count + turnToolCallCount(turn), - 0, - ) + const toolSummary = props.detail + ? summarizeToolCalls(props.detail.turns) : undefined; - const tokens = formatUsageTotal( + const tokenSummary = summarizeUsage( (props.detail?.turns ?? props.conversation.turns).map( (turn) => turn.cumulativeUsage, ), ); - const stats = [ - slackLocationLabel(props.conversation, { includeId: false }), - `${props.conversation.turns.length} turns`, - messages === undefined ? "messages loading" : `${messages} messages`, - toolCalls === undefined ? "tool calls loading" : `${toolCalls} tool calls`, - tokens, - formatConversationDuration(props.conversation), - `started ${formatTime(props.conversation.startedAt)}`, - ].filter(Boolean); + const location = slackLocationLabel(props.conversation, { + includeId: false, + }); + const durationLabel = formatConversationDuration(props.conversation); + const rawStats: Array = [ + location + ? { + content: location, + key: "location", + } + : undefined, + { + content: `${props.conversation.turns.length} turns`, + key: "turns", + }, + { + content: ( + + ), + key: "messages", + }, + { + content: ( + + ), + key: "tools", + }, + tokenSummary + ? { + content: , + key: "tokens", + } + : undefined, + durationLabel !== "none" + ? { + content: ( + + ), + key: "duration", + } + : undefined, + { + content: `started ${formatTime(props.conversation.startedAt)}`, + key: "started", + }, + ]; + const stats = rawStats.filter( + (item): item is MetricListItem => item !== undefined, + ); return ( -
- {stats.map((value, index) => ( - - {index > 0 ? · : null} - {value} - - ))} -
+ ); } diff --git a/packages/junior-dashboard/src/client/types.ts b/packages/junior-dashboard/src/client/types.ts index 56c9ede4b..7ccb59bff 100644 --- a/packages/junior-dashboard/src/client/types.ts +++ b/packages/junior-dashboard/src/client/types.ts @@ -31,6 +31,7 @@ export type TurnUsage = { export type Session = { channel?: string; channelName?: string; + completedAt?: string; conversationId?: string; conversationTitle?: string; cumulativeDurationMs?: number; @@ -141,7 +142,11 @@ export type SessionFilter = "active" | "recent" | "hung" | "failed" | "all"; export type VisualStatus = "active" | "failed" | "hung" | "idle"; -export type CodeBlock = { code: string; fenced?: boolean; language: BundledLanguage }; +export type CodeBlock = { + code: string; + fenced?: boolean; + language: BundledLanguage; +}; export type MarkupNode = | { diff --git a/packages/junior-dashboard/tests/format.test.ts b/packages/junior-dashboard/tests/format.test.ts index 5c86c62ac..9df5da6c5 100644 --- a/packages/junior-dashboard/tests/format.test.ts +++ b/packages/junior-dashboard/tests/format.test.ts @@ -1,14 +1,21 @@ import { describe, expect, it } from "vitest"; import { + buildConversations, canRenderStructuredMarkup, + conversationDisplayTitle, + conversationIdentityMeta, formatDurationTotal, + formatDurationTick, formatTokenTotal, formatUsageTotal, parseMarkdownBlocks, + summarizeMessages, + summarizeToolCalls, + summarizeUsage, turnMessageCount, } from "../src/client/format"; -import type { ConversationTurn } from "../src/client/types"; +import type { ConversationTurn, Session } from "../src/client/types"; describe("dashboard token formatting", () => { it("sums turn usage for conversation totals", () => { @@ -41,6 +48,12 @@ describe("dashboard token formatting", () => { expect(formatDurationTotal([1_000, 2_500, undefined])).toBe("3.5s"); }); + it("rounds long chart duration ticks to whole minutes", () => { + expect(formatDurationTick(17 * 60_000 + 38_000)).toBe("18m"); + expect(formatDurationTick(9 * 60_000 + 38_000)).toBe("9m 38s"); + expect(formatDurationTick(9 * 60_000 + 59_900)).toBe("10m"); + }); + it("counts conversational transcript messages instead of tool events", () => { const turn = { id: "turn-1", @@ -72,6 +85,118 @@ describe("dashboard token formatting", () => { expect(turnMessageCount(turn)).toBe(2); }); + + it("summarizes tooltip metrics from visible transcripts", () => { + const turn = { + id: "turn-1", + requester: "alice", + status: "completed", + transcriptAvailable: true, + transcript: [ + { + role: "user", + parts: [{ type: "text", text: "run search" }], + }, + { + role: "assistant", + timestamp: 1_000, + parts: [{ type: "tool_call", id: "call-1", name: "search" }], + }, + { + role: "toolResult", + timestamp: 2_500, + parts: [{ type: "tool_result", id: "call-1", name: "search" }], + }, + { + role: "assistant", + parts: [{ type: "text", text: "done" }], + }, + ], + } as ConversationTurn; + + expect(summarizeToolCalls([turn])).toEqual({ + items: [{ count: 1, name: "search", totalDurationMs: 1_500 }], + total: 1, + }); + expect(summarizeMessages([turn])).toEqual({ + items: [ + { author: "alice", bytes: 10 }, + { author: "Junior", bytes: 4 }, + ], + total: 2, + }); + expect( + summarizeUsage([ + { cachedInputTokens: 2, inputTokens: 3, outputTokens: 5 }, + { totalTokens: 7 }, + ]), + ).toMatchObject({ + cachedInputTokens: 2, + inputTokens: 3, + outputTokens: 5, + providerTotalTokens: 7, + totalTokens: 17, + }); + }); + + it("does not match tool durations across different turns", () => { + const turns = [ + { + id: "turn-1", + status: "completed", + transcriptAvailable: true, + transcript: [ + { + role: "assistant", + timestamp: 1_000, + parts: [{ type: "tool_call", name: "search" }], + }, + ], + }, + { + id: "turn-2", + status: "completed", + transcriptAvailable: true, + transcript: [ + { + role: "toolResult", + timestamp: 2_000, + parts: [{ type: "tool_result", name: "search" }], + }, + ], + }, + ] as ConversationTurn[]; + + expect(summarizeToolCalls(turns)).toEqual({ + items: [{ count: 1, name: "search" }], + total: 1, + }); + }); + + it("keeps suspicious Slack thread titles out of requester metadata", () => { + const sessions: Session[] = [ + { + channel: "C1", + conversationId: "slack:C1:123", + id: "turn-1", + lastSeenAt: "2026-06-01T10:05:00.000Z", + requesterIdentity: { + slackUserId: "U1", + slackUserName: "jr upcoming holidays", + }, + startedAt: "2026-06-01T10:00:00.000Z", + status: "completed", + surface: "slack", + title: "Turn turn-1", + }, + ]; + const [conversation] = buildConversations(sessions); + + expect(conversationDisplayTitle(conversation)).toBe("jr upcoming holidays"); + expect(conversationIdentityMeta(conversation, conversation?.id)).toBe( + "U1 · slack:C1:123", + ); + }); }); describe("parseMarkdownBlocks prose language detection", () => { diff --git a/packages/junior/src/reporting.ts b/packages/junior/src/reporting.ts index cd34acc34..2eb562e3a 100644 --- a/packages/junior/src/reporting.ts +++ b/packages/junior/src/reporting.ts @@ -217,10 +217,13 @@ function requesterLabel( requester: AgentTurnRequester | undefined, ): string | undefined { if (!requester) return undefined; + const slackUserName = requester.slackUserName?.trim(); + const safeSlackUserName = + slackUserName && !/\s/.test(slackUserName) ? slackUserName : undefined; return ( requester.email ?? - requester.slackUserName ?? requester.fullName ?? + safeSlackUserName ?? requester.slackUserId ); } From fad6c810fe24c90b53745056994ecf07e0787f77 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Mon, 1 Jun 2026 23:02:30 +0200 Subject: [PATCH 02/17] fix(dashboard): Address telemetry review feedback Match tool results only by id when ids are present, and reuse conversation display rules in duration chart tooltips. Keep Slack display names available in dashboard reporting while preferring stable requester labels. Co-Authored-By: GPT-5 Codex --- .../client/components/TurnDurationChart.tsx | 19 +++-- .../junior-dashboard/src/client/format.ts | 24 ++---- .../junior-dashboard/tests/format.test.ts | 80 ++++++++++++++++++- packages/junior/src/reporting.ts | 5 +- 4 files changed, 100 insertions(+), 28 deletions(-) diff --git a/packages/junior-dashboard/src/client/components/TurnDurationChart.tsx b/packages/junior-dashboard/src/client/components/TurnDurationChart.tsx index 32f91c4f4..65c860e34 100644 --- a/packages/junior-dashboard/src/client/components/TurnDurationChart.tsx +++ b/packages/junior-dashboard/src/client/components/TurnDurationChart.tsx @@ -15,8 +15,10 @@ import { readConversationData } from "../api"; import { buildConversations, conversationDisplayTitle, + conversationRequesterLabel, conversationIdForSession, conversationPath, + formatConversationDuration, formatDurationTick, formatMs, requesterLabel, @@ -409,10 +411,10 @@ function chartTooltipRows( detail: ConversationDetailFeed | undefined, ): Array<[string, ReactNode]> { const session = point.session ?? point.conversation; - const requester = requesterLabel( - session?.requesterIdentity, - session?.requester, - ); + const requester = + point.kind === "conversations" + ? conversationRequesterLabel(point.conversation) + : requesterLabel(session?.requesterIdentity, session?.requester); const location = session ? slackLocationLabel(session, { includeId: false }) : undefined; @@ -432,7 +434,7 @@ function chartTooltipRows( , ], @@ -461,6 +463,13 @@ function chartTooltipRows( return rows.filter((row): row is [string, ReactNode] => row !== null); } +function chartDurationLabel(point: DurationPoint): string { + if (point.kind === "conversations" && point.conversation) { + return formatConversationDuration(point.conversation); + } + return formatMs(point.durationMs); +} + function chartTooltipTurns( point: DurationPoint, detail: ConversationDetailFeed | undefined, diff --git a/packages/junior-dashboard/src/client/format.ts b/packages/junior-dashboard/src/client/format.ts index 200004731..b304eb096 100644 --- a/packages/junior-dashboard/src/client/format.ts +++ b/packages/junior-dashboard/src/client/format.ts @@ -297,7 +297,9 @@ function sameToolPart( call: Pick, result: Pick, ): boolean { - if (call.id && result.id) return call.id === result.id; + if (call.id || result.id) { + return Boolean(call.id && result.id && call.id === result.id); + } if (call.name && result.name) return call.name === result.name; return !call.id && !call.name && !result.id && !result.name; } @@ -642,14 +644,8 @@ function sameDisplayLabel( ); } -function suspiciousThreadTitleFromRequester( - requester: RequesterIdentity | undefined, -): string | undefined { - const slackUserName = requester?.slackUserName?.trim(); - return slackUserName && /\s/.test(slackUserName) ? slackUserName : undefined; -} - -function conversationRequesterLabel( +/** Suppress redundant conversation owners already represented by title or surface. */ +export function conversationRequesterLabel( conversation: Conversation | undefined, ): string | undefined { if (!conversation) return undefined; @@ -1002,13 +998,9 @@ export function buildConversations(sessions: Session[]): Conversation[] { const requesterTurn = sortedTurns.find((turn) => turn.requesterIdentity) ?? sortedTurns.find((turn) => turn.requester); - const conversationTitle = - sortedTurns.find((turn) => turn.conversationTitle)?.conversationTitle ?? - sortedTurns - .map((turn) => - suspiciousThreadTitleFromRequester(turn.requesterIdentity), - ) - .find((title): title is string => Boolean(title)); + const conversationTitle = sortedTurns.find( + (turn) => turn.conversationTitle, + )?.conversationTitle; return { channel: newest.channel, diff --git a/packages/junior-dashboard/tests/format.test.ts b/packages/junior-dashboard/tests/format.test.ts index 9df5da6c5..f2d8dd019 100644 --- a/packages/junior-dashboard/tests/format.test.ts +++ b/packages/junior-dashboard/tests/format.test.ts @@ -5,6 +5,8 @@ import { canRenderStructuredMarkup, conversationDisplayTitle, conversationIdentityMeta, + conversationRequesterLabel, + formatConversationDuration, formatDurationTotal, formatDurationTick, formatTokenTotal, @@ -173,7 +175,37 @@ describe("dashboard token formatting", () => { }); }); - it("keeps suspicious Slack thread titles out of requester metadata", () => { + it("does not match id-bearing tool calls to name-only results", () => { + const turn = { + id: "turn-1", + status: "completed", + transcriptAvailable: true, + transcript: [ + { + role: "assistant", + timestamp: 1_000, + parts: [{ type: "tool_call", id: "call-1", name: "search" }], + }, + { + role: "assistant", + timestamp: 1_200, + parts: [{ type: "tool_call", id: "call-2", name: "search" }], + }, + { + role: "toolResult", + timestamp: 1_800, + parts: [{ type: "tool_result", name: "search" }], + }, + ], + } as ConversationTurn; + + expect(summarizeToolCalls([turn])).toEqual({ + items: [{ count: 2, name: "search" }], + total: 2, + }); + }); + + it("does not synthesize conversation titles from requester display names", () => { const sessions: Session[] = [ { channel: "C1", @@ -182,7 +214,7 @@ describe("dashboard token formatting", () => { lastSeenAt: "2026-06-01T10:05:00.000Z", requesterIdentity: { slackUserId: "U1", - slackUserName: "jr upcoming holidays", + slackUserName: "Alice Reviewer", }, startedAt: "2026-06-01T10:00:00.000Z", status: "completed", @@ -192,11 +224,53 @@ describe("dashboard token formatting", () => { ]; const [conversation] = buildConversations(sessions); - expect(conversationDisplayTitle(conversation)).toBe("jr upcoming holidays"); + expect(conversationDisplayTitle(conversation)).toBe("Public Channel"); expect(conversationIdentityMeta(conversation, conversation?.id)).toBe( "U1 · slack:C1:123", ); }); + + it("suppresses redundant conversation requester labels", () => { + const sessions: Session[] = [ + { + channel: "C1", + channelName: "alice", + conversationId: "slack:C1:123", + conversationTitle: "Alice", + id: "turn-1", + lastSeenAt: "2026-06-01T10:05:00.000Z", + requesterIdentity: { + fullName: "alice", + slackUserId: "U1", + }, + startedAt: "2026-06-01T10:00:00.000Z", + status: "completed", + surface: "slack", + title: "Turn turn-1", + }, + ]; + const [conversation] = buildConversations(sessions); + + expect(conversationRequesterLabel(conversation)).toBeUndefined(); + expect(conversationIdentityMeta(conversation, conversation?.id)).toBe( + "slack:C1:123", + ); + }); + + it("formats conversation spans with the compact conversation duration rules", () => { + const [conversation] = buildConversations([ + { + conversationId: "slack:C1:123", + id: "turn-1", + lastSeenAt: "2026-06-01T10:02:29.000Z", + startedAt: "2026-06-01T10:00:00.000Z", + status: "completed", + title: "Turn turn-1", + }, + ]); + + expect(formatConversationDuration(conversation!)).toBe("2m"); + }); }); describe("parseMarkdownBlocks prose language detection", () => { diff --git a/packages/junior/src/reporting.ts b/packages/junior/src/reporting.ts index 2eb562e3a..e60d55164 100644 --- a/packages/junior/src/reporting.ts +++ b/packages/junior/src/reporting.ts @@ -217,13 +217,10 @@ function requesterLabel( requester: AgentTurnRequester | undefined, ): string | undefined { if (!requester) return undefined; - const slackUserName = requester.slackUserName?.trim(); - const safeSlackUserName = - slackUserName && !/\s/.test(slackUserName) ? slackUserName : undefined; return ( requester.email ?? requester.fullName ?? - safeSlackUserName ?? + requester.slackUserName ?? requester.slackUserId ); } From f95aafbcf1600453ab05b05f250c855ebadfbdd2 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 2 Jun 2026 05:43:07 +0200 Subject: [PATCH 03/17] fix(dashboard): Address remaining telemetry feedback Avoid rebuilding dashboard conversation groups while the duration chart is in turn mode. Tighten generic turn-title matching so meaningful titles that start with Turn remain visible. Co-Authored-By: GPT-5 Codex --- .../client/components/TurnDurationChart.tsx | 7 +++++-- .../junior-dashboard/src/client/format.ts | 2 +- .../junior-dashboard/tests/format.test.ts | 20 +++++++++++++++++++ 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/junior-dashboard/src/client/components/TurnDurationChart.tsx b/packages/junior-dashboard/src/client/components/TurnDurationChart.tsx index 65c860e34..ad44fa130 100644 --- a/packages/junior-dashboard/src/client/components/TurnDurationChart.tsx +++ b/packages/junior-dashboard/src/client/components/TurnDurationChart.tsx @@ -1,4 +1,4 @@ -import { useState, type ReactNode } from "react"; +import { useMemo, useState, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; import { useNavigate } from "react-router"; import { @@ -62,7 +62,10 @@ export function TurnDurationChart(props: { const chartEdgePaddingMs = 6 * 60 * 60 * 1000; const chartRangeStartMs = rangeStartMs - chartEdgePaddingMs; const chartRangeEndMs = rangeEndMs + chartEdgePaddingMs; - const conversations = buildConversations(props.sessions); + const conversations = useMemo( + () => (mode === "conversations" ? buildConversations(props.sessions) : []), + [mode, props.sessions], + ); const points = ( mode === "turns" ? props.sessions.map((session) => turnPoint(session, props.timeZone)) diff --git a/packages/junior-dashboard/src/client/format.ts b/packages/junior-dashboard/src/client/format.ts index b304eb096..4367ff1dc 100644 --- a/packages/junior-dashboard/src/client/format.ts +++ b/packages/junior-dashboard/src/client/format.ts @@ -581,7 +581,7 @@ function isGenericTurnTitle(title: string, conversationId: string): boolean { return ( normalized.length === 0 || normalized === conversationId || - /^Turn\s+\S+/i.test(normalized) || + /^Turn\s+\S+$/i.test(normalized) || /^Awaiting\s+\w+\s+resume$/i.test(normalized) ); } diff --git a/packages/junior-dashboard/tests/format.test.ts b/packages/junior-dashboard/tests/format.test.ts index f2d8dd019..4107065d7 100644 --- a/packages/junior-dashboard/tests/format.test.ts +++ b/packages/junior-dashboard/tests/format.test.ts @@ -230,6 +230,26 @@ describe("dashboard token formatting", () => { ); }); + it("keeps meaningful conversation titles that start with turn", () => { + const [conversation] = buildConversations([ + { + channel: "C1", + channelName: "engineering", + conversationId: "slack:C1:123", + id: "turn-1", + lastSeenAt: "2026-06-01T10:05:00.000Z", + startedAt: "2026-06-01T10:00:00.000Z", + status: "completed", + surface: "slack", + title: "Turn around the API design", + }, + ]); + + expect(conversationDisplayTitle(conversation)).toBe( + "Turn around the API design", + ); + }); + it("suppresses redundant conversation requester labels", () => { const sessions: Session[] = [ { From d216976f27882cec1962094fe150c2ff72d11d17 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 2 Jun 2026 05:55:12 +0200 Subject: [PATCH 04/17] fix(dashboard): Keep duration chart labels aligned Use the same current-time snapshot for open-ended conversation duration points and labels. Keep Slack display names with spaces as requester labels instead of degrading to raw ids. Co-Authored-By: GPT-5 Codex --- .../client/components/TurnDurationChart.tsx | 16 +++++----- .../junior-dashboard/src/client/format.ts | 24 +++++++------- .../junior-dashboard/tests/format.test.ts | 31 ++++++++++++++++++- 3 files changed, 50 insertions(+), 21 deletions(-) diff --git a/packages/junior-dashboard/src/client/components/TurnDurationChart.tsx b/packages/junior-dashboard/src/client/components/TurnDurationChart.tsx index ad44fa130..5633f642b 100644 --- a/packages/junior-dashboard/src/client/components/TurnDurationChart.tsx +++ b/packages/junior-dashboard/src/client/components/TurnDurationChart.tsx @@ -70,7 +70,7 @@ export function TurnDurationChart(props: { mode === "turns" ? props.sessions.map((session) => turnPoint(session, props.timeZone)) : conversations.map((conversation) => - conversationPoint(conversation, props.timeZone), + conversationPoint(conversation, props.timeZone, nowMs), ) ) .filter((point): point is DurationPoint => Boolean(point)) @@ -188,6 +188,7 @@ type DurationChartMode = "conversations" | "turns"; type DurationPoint = { conversation?: Conversation; conversationId: string; + durationLabel: string; durationMs: number; endedAt?: string; kind: DurationChartMode; @@ -256,6 +257,7 @@ function turnPoint(session: Session, timeZone: string): DurationPoint | null { : 0); return { conversationId: conversationIdForSession(session), + durationLabel: formatMs(durationMs), durationMs, endedAt: session.completedAt ?? session.lastSeenAt, kind: "turns", @@ -276,6 +278,7 @@ function turnPoint(session: Session, timeZone: string): DurationPoint | null { function conversationPoint( conversation: Conversation, timeZone: string, + nowMs: number, ): DurationPoint | null { const startedAtMs = Date.parse(conversation.startedAt ?? ""); if (!Number.isFinite(startedAtMs)) { @@ -286,13 +289,13 @@ function conversationPoint( return null; } const lastSeenAtMs = Date.parse(conversation.lastSeenAt ?? ""); - const durationMs = Number.isFinite(lastSeenAtMs) - ? Math.max(0, lastSeenAtMs - startedAtMs) - : 0; + const durationEndMs = Number.isFinite(lastSeenAtMs) ? lastSeenAtMs : nowMs; + const durationMs = Math.max(0, durationEndMs - startedAtMs); return { conversation, conversationId: conversation.id, + durationLabel: formatConversationDuration(conversation, nowMs), durationMs, endedAt: conversation.lastSeenAt, kind: "conversations", @@ -467,10 +470,7 @@ function chartTooltipRows( } function chartDurationLabel(point: DurationPoint): string { - if (point.kind === "conversations" && point.conversation) { - return formatConversationDuration(point.conversation); - } - return formatMs(point.durationMs); + return point.durationLabel; } function chartTooltipTurns( diff --git a/packages/junior-dashboard/src/client/format.ts b/packages/junior-dashboard/src/client/format.ts index 4367ff1dc..8a59177cb 100644 --- a/packages/junior-dashboard/src/client/format.ts +++ b/packages/junior-dashboard/src/client/format.ts @@ -552,9 +552,12 @@ export function formatUsage(usage: TurnUsage | undefined): string { } /** Format a conversation span from first turn start to latest activity. */ -export function formatConversationDuration(conversation: Conversation): string { +export function formatConversationDuration( + conversation: Conversation, + nowMs = Date.now(), +): string { const start = parseTime(conversation.startedAt); - const end = parseTime(conversation.lastSeenAt) ?? Date.now(); + const end = parseTime(conversation.lastSeenAt) ?? nowMs; if (start == null || end < start) return "none"; const seconds = Math.max(1, Math.round((end - start) / 1000)); if (seconds < 60) return `${seconds}s`; @@ -619,17 +622,14 @@ export function requesterLabel( requester: RequesterIdentity | undefined, fallback: string | undefined, ): string | undefined { - const slackUserName = requester?.slackUserName?.trim(); - const safeSlackUserName = - slackUserName && !/\s/.test(slackUserName) ? slackUserName : undefined; - const fallbackLabel = - fallback?.trim() && fallback.trim() !== slackUserName - ? fallback.trim() - : undefined; + const email = requester?.email?.trim() || undefined; + const fullName = requester?.fullName?.trim() || undefined; + const slackUserName = requester?.slackUserName?.trim() || undefined; + const fallbackLabel = fallback?.trim() || undefined; return ( - requester?.email ?? - requester?.fullName ?? - safeSlackUserName ?? + email ?? + fullName ?? + slackUserName ?? fallbackLabel ?? requester?.slackUserId ); diff --git a/packages/junior-dashboard/tests/format.test.ts b/packages/junior-dashboard/tests/format.test.ts index 4107065d7..22f183f15 100644 --- a/packages/junior-dashboard/tests/format.test.ts +++ b/packages/junior-dashboard/tests/format.test.ts @@ -12,6 +12,7 @@ import { formatTokenTotal, formatUsageTotal, parseMarkdownBlocks, + requesterLabel, summarizeMessages, summarizeToolCalls, summarizeUsage, @@ -226,10 +227,19 @@ describe("dashboard token formatting", () => { expect(conversationDisplayTitle(conversation)).toBe("Public Channel"); expect(conversationIdentityMeta(conversation, conversation?.id)).toBe( - "U1 · slack:C1:123", + "Alice Reviewer · slack:C1:123", ); }); + it("keeps Slack display names with spaces as requester labels", () => { + expect( + requesterLabel( + { slackUserId: "U1", slackUserName: "Alice Reviewer" }, + "Alice Reviewer", + ), + ).toBe("Alice Reviewer"); + }); + it("keeps meaningful conversation titles that start with turn", () => { const [conversation] = buildConversations([ { @@ -291,6 +301,25 @@ describe("dashboard token formatting", () => { expect(formatConversationDuration(conversation!)).toBe("2m"); }); + + it("formats open-ended conversation spans from the supplied current time", () => { + const [conversation] = buildConversations([ + { + conversationId: "slack:C1:123", + id: "turn-1", + startedAt: "2026-06-01T10:00:00.000Z", + status: "completed", + title: "Turn turn-1", + }, + ]); + + expect( + formatConversationDuration( + conversation!, + Date.parse("2026-06-01T10:02:29.000Z"), + ), + ).toBe("2m"); + }); }); describe("parseMarkdownBlocks prose language detection", () => { From 5fd84e8261f0eebfb52341178eee2167842ef1fe Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 2 Jun 2026 06:20:54 +0200 Subject: [PATCH 05/17] fix(dashboard): Use structured requester identity Remove the derived requester string from dashboard reporting responses and make the client derive display labels only from requesterIdentity. Align dashboard client payload types with the reporting API types and avoid inventing conversation durations without valid timestamps. Co-Authored-By: GPT-5 Codex --- .../src/client/components/TranscriptTurn.tsx | 4 +- .../client/components/TurnDurationChart.tsx | 13 +- .../junior-dashboard/src/client/format.ts | 41 ++----- packages/junior-dashboard/src/client/types.ts | 111 ++++-------------- .../junior-dashboard/tests/format.test.ts | 22 ++-- packages/junior/src/reporting.ts | 15 --- .../integration/dashboard-reporting.test.ts | 5 + 7 files changed, 57 insertions(+), 154 deletions(-) diff --git a/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx b/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx index 8a427a229..a571c1bd4 100644 --- a/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx +++ b/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx @@ -345,9 +345,7 @@ function redactedMessageSize(part: TranscriptPart): string | undefined { } function turnActorLabel(turn: ConversationTurn): string { - return ( - requesterLabel(turn.requesterIdentity, turn.requester) ?? "unknown actor" - ); + return requesterLabel(turn.requesterIdentity) ?? "unknown actor"; } function turnMessageSummary(turn: ConversationTurn) { diff --git a/packages/junior-dashboard/src/client/components/TurnDurationChart.tsx b/packages/junior-dashboard/src/client/components/TurnDurationChart.tsx index 5633f642b..53b986c89 100644 --- a/packages/junior-dashboard/src/client/components/TurnDurationChart.tsx +++ b/packages/junior-dashboard/src/client/components/TurnDurationChart.tsx @@ -70,7 +70,7 @@ export function TurnDurationChart(props: { mode === "turns" ? props.sessions.map((session) => turnPoint(session, props.timeZone)) : conversations.map((conversation) => - conversationPoint(conversation, props.timeZone, nowMs), + conversationPoint(conversation, props.timeZone), ) ) .filter((point): point is DurationPoint => Boolean(point)) @@ -278,7 +278,6 @@ function turnPoint(session: Session, timeZone: string): DurationPoint | null { function conversationPoint( conversation: Conversation, timeZone: string, - nowMs: number, ): DurationPoint | null { const startedAtMs = Date.parse(conversation.startedAt ?? ""); if (!Number.isFinite(startedAtMs)) { @@ -289,13 +288,15 @@ function conversationPoint( return null; } const lastSeenAtMs = Date.parse(conversation.lastSeenAt ?? ""); - const durationEndMs = Number.isFinite(lastSeenAtMs) ? lastSeenAtMs : nowMs; - const durationMs = Math.max(0, durationEndMs - startedAtMs); + if (!Number.isFinite(lastSeenAtMs)) { + return null; + } + const durationMs = Math.max(0, lastSeenAtMs - startedAtMs); return { conversation, conversationId: conversation.id, - durationLabel: formatConversationDuration(conversation, nowMs), + durationLabel: formatConversationDuration(conversation), durationMs, endedAt: conversation.lastSeenAt, kind: "conversations", @@ -420,7 +421,7 @@ function chartTooltipRows( const requester = point.kind === "conversations" ? conversationRequesterLabel(point.conversation) - : requesterLabel(session?.requesterIdentity, session?.requester); + : requesterLabel(session?.requesterIdentity); const location = session ? slackLocationLabel(session, { includeId: false }) : undefined; diff --git a/packages/junior-dashboard/src/client/format.ts b/packages/junior-dashboard/src/client/format.ts index 8a59177cb..441e2d9e8 100644 --- a/packages/junior-dashboard/src/client/format.ts +++ b/packages/junior-dashboard/src/client/format.ts @@ -26,7 +26,7 @@ function displayTimeZone(): string { } function isActiveSession(session: Session): boolean { - return session.status === "active" || session.status === "running"; + return session.status === "active"; } /** Identify turn summaries that should appear in failed conversation filters. */ @@ -393,7 +393,7 @@ function transcriptMessageAuthor( const kind = transcriptRoleKind(message.role); if (kind === "assistant") return "Junior"; if (kind === "user") { - return requesterLabel(turn.requesterIdentity, turn.requester) ?? "User"; + return requesterLabel(turn.requesterIdentity) ?? "User"; } if (kind === "system") return "System"; if (kind === "tool") return "Tool"; @@ -552,13 +552,10 @@ export function formatUsage(usage: TurnUsage | undefined): string { } /** Format a conversation span from first turn start to latest activity. */ -export function formatConversationDuration( - conversation: Conversation, - nowMs = Date.now(), -): string { +export function formatConversationDuration(conversation: Conversation): string { const start = parseTime(conversation.startedAt); - const end = parseTime(conversation.lastSeenAt) ?? nowMs; - if (start == null || end < start) return "none"; + const end = parseTime(conversation.lastSeenAt); + if (start == null || end == null || end < start) return "none"; const seconds = Math.max(1, Math.round((end - start) / 1000)); if (seconds < 60) return `${seconds}s`; const minutes = Math.round(seconds / 60); @@ -620,19 +617,11 @@ export function conversationDisplayTitle( /** Prefer stable requester identifiers while keeping Slack ids as a last resort. */ export function requesterLabel( requester: RequesterIdentity | undefined, - fallback: string | undefined, ): string | undefined { const email = requester?.email?.trim() || undefined; const fullName = requester?.fullName?.trim() || undefined; const slackUserName = requester?.slackUserName?.trim() || undefined; - const fallbackLabel = fallback?.trim() || undefined; - return ( - email ?? - fullName ?? - slackUserName ?? - fallbackLabel ?? - requester?.slackUserId - ); + return email ?? fullName ?? slackUserName ?? requester?.slackUserId; } function sameDisplayLabel( @@ -649,10 +638,7 @@ export function conversationRequesterLabel( conversation: Conversation | undefined, ): string | undefined { if (!conversation) return undefined; - const owner = requesterLabel( - conversation.requesterIdentity, - conversation.requester, - ); + const owner = requesterLabel(conversation.requesterIdentity); if ( sameDisplayLabel(owner, conversation.conversationTitle) || sameDisplayLabel(owner, meaningfulConversationTitle(conversation)) || @@ -678,10 +664,7 @@ export function conversationIdentityMeta( /** Convert Slack channel ids and names into user-facing location labels. */ export function slackLocationLabel( - input: Pick< - Session, - "channel" | "channelName" | "requester" | "requesterIdentity" - >, + input: Pick, options: { includeId?: boolean } = {}, ): string | undefined { const channelId = input.channel; @@ -995,9 +978,7 @@ export function buildConversations(sessions: Session[]): Conversation[] { : sortedTurns.some(isFailedSession) ? "failed" : newest.status; - const requesterTurn = - sortedTurns.find((turn) => turn.requesterIdentity) ?? - sortedTurns.find((turn) => turn.requester); + const requesterTurn = sortedTurns.find((turn) => turn.requesterIdentity); const conversationTitle = sortedTurns.find( (turn) => turn.conversationTitle, )?.conversationTitle; @@ -1008,10 +989,6 @@ export function buildConversations(sessions: Session[]): Conversation[] { conversationTitle, id, lastSeenAt: newest.lastSeenAt, - requester: requesterLabel( - requesterTurn?.requesterIdentity, - requesterTurn?.requester, - ), requesterIdentity: requesterTurn?.requesterIdentity, sentryConversationUrl: newest.sentryConversationUrl, sentryTraceUrl: newest.sentryTraceUrl, diff --git a/packages/junior-dashboard/src/client/types.ts b/packages/junior-dashboard/src/client/types.ts index 7ccb59bff..881a4f11d 100644 --- a/packages/junior-dashboard/src/client/types.ts +++ b/packages/junior-dashboard/src/client/types.ts @@ -1,95 +1,39 @@ import type { BundledLanguage } from "shiki/bundle/web"; +import type { + DashboardConversationReport, + DashboardSessionFeed, + DashboardSessionReport, + DashboardTurnReport, + HealthReport, + PluginReport, + RuntimeInfoReport, + SkillReport, +} from "@sentry/junior/reporting"; -export type Health = { service: string; status: string; timestamp: string }; +export type Health = HealthReport; -export type Runtime = { - cwd: string; - descriptionText?: string; - homeDir: string; - packagedContent: { packageNames: string[] }; -}; +export type Runtime = RuntimeInfoReport; -export type Plugin = { name: string }; +export type Plugin = PluginReport; -export type Skill = { name: string; pluginProvider?: string }; +export type Skill = SkillReport; -export type RequesterIdentity = { - email?: string; - fullName?: string; - slackUserId?: string; - slackUserName?: string; -}; +export type RequesterIdentity = NonNullable< + DashboardSessionReport["requesterIdentity"] +>; -export type TurnUsage = { - cachedInputTokens?: number; - cacheCreationTokens?: number; - inputTokens?: number; - outputTokens?: number; - totalTokens?: number; -}; +export type TurnUsage = NonNullable; -export type Session = { - channel?: string; - channelName?: string; - completedAt?: string; - conversationId?: string; - conversationTitle?: string; - cumulativeDurationMs?: number; - cumulativeUsage?: TurnUsage; - id: string; - lastProgressAt?: string; - lastSeenAt?: string; - requester?: string; - requesterIdentity?: RequesterIdentity; - sentryConversationUrl?: string; - sentryTraceUrl?: string; - startedAt?: string; - status: string; - surface?: string; - title?: string; - traceId?: string; -}; +export type Session = DashboardSessionReport; -export type TranscriptPart = { - bytes?: number; - chars?: number; - id?: string; - input?: unknown; - inputKeys?: string[]; - inputSizeBytes?: number; - inputSizeChars?: number; - inputType?: string; - name?: string; - output?: unknown; - outputKeys?: string[]; - outputSizeBytes?: number; - outputSizeChars?: number; - outputType?: string; - redacted?: boolean; - text?: string; - type: string; -}; +export type TranscriptPart = + DashboardTurnReport["transcript"][number]["parts"][number]; -export type TranscriptMessage = { - parts: TranscriptPart[]; - role: string; - timestamp?: number; -}; +export type TranscriptMessage = DashboardTurnReport["transcript"][number]; -export type ConversationTurn = Session & { - transcript: TranscriptMessage[]; - transcriptAvailable: boolean; - transcriptMetadata?: TranscriptMessage[]; - transcriptMessageCount?: number; - transcriptRedacted?: boolean; - transcriptRedactionReason?: "non_public_conversation"; -}; +export type ConversationTurn = DashboardTurnReport; -export type ConversationDetailFeed = { - conversationId: string; - generatedAt: string; - turns: ConversationTurn[]; -}; +export type ConversationDetailFeed = DashboardConversationReport; export type Conversation = { channel?: string; @@ -98,7 +42,6 @@ export type Conversation = { id: string; lastProgressAt?: string; lastSeenAt?: string; - requester?: string; requesterIdentity?: RequesterIdentity; sentryConversationUrl?: string; sentryTraceUrl?: string; @@ -110,11 +53,7 @@ export type Conversation = { turns: Session[]; }; -export type SessionFeed = { - generatedAt?: string; - sessions: Session[]; - source: string; -}; +export type SessionFeed = DashboardSessionFeed; export type Identity = { user: { email?: string; hostedDomain?: string } }; diff --git a/packages/junior-dashboard/tests/format.test.ts b/packages/junior-dashboard/tests/format.test.ts index 22f183f15..a9eff226a 100644 --- a/packages/junior-dashboard/tests/format.test.ts +++ b/packages/junior-dashboard/tests/format.test.ts @@ -92,7 +92,7 @@ describe("dashboard token formatting", () => { it("summarizes tooltip metrics from visible transcripts", () => { const turn = { id: "turn-1", - requester: "alice", + requesterIdentity: { fullName: "alice" }, status: "completed", transcriptAvailable: true, transcript: [ @@ -212,6 +212,7 @@ describe("dashboard token formatting", () => { channel: "C1", conversationId: "slack:C1:123", id: "turn-1", + lastProgressAt: "2026-06-01T10:05:00.000Z", lastSeenAt: "2026-06-01T10:05:00.000Z", requesterIdentity: { slackUserId: "U1", @@ -233,10 +234,7 @@ describe("dashboard token formatting", () => { it("keeps Slack display names with spaces as requester labels", () => { expect( - requesterLabel( - { slackUserId: "U1", slackUserName: "Alice Reviewer" }, - "Alice Reviewer", - ), + requesterLabel({ slackUserId: "U1", slackUserName: "Alice Reviewer" }), ).toBe("Alice Reviewer"); }); @@ -247,6 +245,7 @@ describe("dashboard token formatting", () => { channelName: "engineering", conversationId: "slack:C1:123", id: "turn-1", + lastProgressAt: "2026-06-01T10:05:00.000Z", lastSeenAt: "2026-06-01T10:05:00.000Z", startedAt: "2026-06-01T10:00:00.000Z", status: "completed", @@ -268,6 +267,7 @@ describe("dashboard token formatting", () => { conversationId: "slack:C1:123", conversationTitle: "Alice", id: "turn-1", + lastProgressAt: "2026-06-01T10:05:00.000Z", lastSeenAt: "2026-06-01T10:05:00.000Z", requesterIdentity: { fullName: "alice", @@ -292,6 +292,7 @@ describe("dashboard token formatting", () => { { conversationId: "slack:C1:123", id: "turn-1", + lastProgressAt: "2026-06-01T10:02:29.000Z", lastSeenAt: "2026-06-01T10:02:29.000Z", startedAt: "2026-06-01T10:00:00.000Z", status: "completed", @@ -302,23 +303,20 @@ describe("dashboard token formatting", () => { expect(formatConversationDuration(conversation!)).toBe("2m"); }); - it("formats open-ended conversation spans from the supplied current time", () => { + it("does not invent conversation spans without a valid end time", () => { const [conversation] = buildConversations([ { conversationId: "slack:C1:123", id: "turn-1", + lastProgressAt: "2026-06-01T10:02:29.000Z", + lastSeenAt: "not-a-date", startedAt: "2026-06-01T10:00:00.000Z", status: "completed", title: "Turn turn-1", }, ]); - expect( - formatConversationDuration( - conversation!, - Date.parse("2026-06-01T10:02:29.000Z"), - ), - ).toBe("2m"); + expect(formatConversationDuration(conversation!)).toBe("none"); }); }); diff --git a/packages/junior/src/reporting.ts b/packages/junior/src/reporting.ts index e60d55164..52e79dc00 100644 --- a/packages/junior/src/reporting.ts +++ b/packages/junior/src/reporting.ts @@ -68,7 +68,6 @@ export interface DashboardSessionReport { completedAt?: string; surface?: "slack" | "api" | "scheduler" | "internal"; title?: string; - requester?: string; requesterIdentity?: AgentTurnRequester; channel?: string; channelName?: string; @@ -213,18 +212,6 @@ function titleFromSummary(summary: AgentTurnSessionSummary): string { return `Turn ${summary.sessionId}`; } -function requesterLabel( - requester: AgentTurnRequester | undefined, -): string | undefined { - if (!requester) return undefined; - return ( - requester.email ?? - requester.fullName ?? - requester.slackUserName ?? - requester.slackUserId - ); -} - function safePrivateLabel(summary: AgentTurnSessionSummary): string { const slackThread = parseSlackThreadId(summary.conversationId); if (slackThread?.channelId.startsWith("D")) { @@ -249,7 +236,6 @@ function sessionReportFromSummary( privacy !== "public" ? safePrivateLabel(summary) : undefined; const conversationTitle = privateLabel ?? summary.conversationTitle; const channelName = privateLabel ?? summary.channelName; - const requester = requesterLabel(summary.requester); const sentryConversationUrl = buildSentryConversationUrl( summary.conversationId, ); @@ -275,7 +261,6 @@ function sessionReportFromSummary( : {}), surface: surfaceFromConversationId(summary.conversationId), title: titleFromSummary(summary), - ...(requester ? { requester } : {}), ...(summary.requester ? { requesterIdentity: summary.requester } : {}), ...(slackThread ? { channel: slackThread.channelId } : {}), ...(channelName ? { channelName } : {}), diff --git a/packages/junior/tests/integration/dashboard-reporting.test.ts b/packages/junior/tests/integration/dashboard-reporting.test.ts index 2094fd8c1..4a8842728 100644 --- a/packages/junior/tests/integration/dashboard-reporting.test.ts +++ b/packages/junior/tests/integration/dashboard-reporting.test.ts @@ -370,6 +370,10 @@ describe("dashboard reporting", () => { conversationTitle: "Direct Message", channelName: "Direct Message", id: "turn-private", + requesterIdentity: { + email: "david@sentry.io", + slackUserId: "U1", + }, traceId: "0123456789abcdef0123456789abcdef", transcriptAvailable: false, transcriptMessageCount: 2, @@ -377,6 +381,7 @@ describe("dashboard reporting", () => { transcriptRedactionReason: "non_public_conversation", transcript: [], }); + expect(report.turns[0]).not.toHaveProperty("requester"); expect(JSON.stringify(report)).not.toContain("private question"); expect(JSON.stringify(report)).not.toContain("private answer"); expect(JSON.stringify(report)).not.toContain("private value"); From 80a8260061748059f4c3b677b1e0747d8a9923b2 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 2 Jun 2026 06:33:20 +0200 Subject: [PATCH 06/17] fix(dashboard): Tighten reporting contracts Expose dashboard-owned requester, usage, surface, status, and transcript projection types instead of leaking turn-session internals through the reporting API. Make grouped conversation and chart models rely on required report fields, and keep requester labels visible even when a generated conversation title has the same text. Co-Authored-By: GPT-5 Codex --- .../client/components/TurnDurationChart.tsx | 18 +-- .../junior-dashboard/src/client/format.ts | 60 +------- packages/junior-dashboard/src/client/types.ts | 16 +-- .../junior-dashboard/tests/format.test.ts | 8 +- packages/junior/src/reporting.ts | 134 +++++++++++++++--- 5 files changed, 138 insertions(+), 98 deletions(-) diff --git a/packages/junior-dashboard/src/client/components/TurnDurationChart.tsx b/packages/junior-dashboard/src/client/components/TurnDurationChart.tsx index 53b986c89..1ffdcbe8f 100644 --- a/packages/junior-dashboard/src/client/components/TurnDurationChart.tsx +++ b/packages/junior-dashboard/src/client/components/TurnDurationChart.tsx @@ -190,10 +190,10 @@ type DurationPoint = { conversationId: string; durationLabel: string; durationMs: number; - endedAt?: string; + endedAt: string; kind: DurationChartMode; session?: Session; - startedAt?: string; + startedAt: string; status: PlottedTurnStatus; subtitle: string; title: string; @@ -240,7 +240,7 @@ function plottedStatus(status: VisualStatus): PlottedTurnStatus | null { } function turnPoint(session: Session, timeZone: string): DurationPoint | null { - const startedAtMs = Date.parse(session.startedAt ?? ""); + const startedAtMs = Date.parse(session.startedAt); if (!Number.isFinite(startedAtMs)) { return null; } @@ -249,7 +249,7 @@ function turnPoint(session: Session, timeZone: string): DurationPoint | null { return null; } - const lastSeenAtMs = Date.parse(session.lastSeenAt ?? ""); + const lastSeenAtMs = Date.parse(session.lastSeenAt); const durationMs = session.cumulativeDurationMs ?? (Number.isFinite(lastSeenAtMs) @@ -279,7 +279,7 @@ function conversationPoint( conversation: Conversation, timeZone: string, ): DurationPoint | null { - const startedAtMs = Date.parse(conversation.startedAt ?? ""); + const startedAtMs = Date.parse(conversation.startedAt); if (!Number.isFinite(startedAtMs)) { return null; } @@ -287,7 +287,7 @@ function conversationPoint( if (!status) { return null; } - const lastSeenAtMs = Date.parse(conversation.lastSeenAt ?? ""); + const lastSeenAtMs = Date.parse(conversation.lastSeenAt); if (!Number.isFinite(lastSeenAtMs)) { return null; } @@ -486,11 +486,7 @@ function chartTooltipTurns( } function turnTooltipTitle(session: Session): string { - return ( - session.conversationTitle ?? - session.title ?? - conversationIdForSession(session) - ); + return session.conversationTitle ?? session.title; } function chartTooltipStatus(status: PlottedTurnStatus): ReactNode { diff --git a/packages/junior-dashboard/src/client/format.ts b/packages/junior-dashboard/src/client/format.ts index 441e2d9e8..62a12c0e1 100644 --- a/packages/junior-dashboard/src/client/format.ts +++ b/packages/junior-dashboard/src/client/format.ts @@ -504,10 +504,6 @@ export function summarizeUsage( return summary.totalTokens > 0 ? summary : undefined; } -function totalUsageTokens(usage: TurnUsage | undefined): number | undefined { - return summarizeUsage([usage])?.totalTokens; -} - /** Format a summarized token counter for compact metadata. */ export function formatTokenSummary( summary: TokenUsageSummary | undefined, @@ -525,32 +521,6 @@ export function formatUsageTotal(usages: Array): string { return formatTokenSummary(summarizeUsage(usages)); } -/** Format known token counters with available input/output detail. */ -export function formatUsage(usage: TurnUsage | undefined): string { - const summary = summarizeUsage([usage]); - if (!summary) return ""; - const pieces = [ - summary.inputTokens !== undefined - ? `${formatNumber(summary.inputTokens)} in` - : undefined, - summary.outputTokens !== undefined - ? `${formatNumber(summary.outputTokens)} out` - : undefined, - summary.cachedInputTokens !== undefined - ? `${formatNumber(summary.cachedInputTokens)} cached` - : undefined, - summary.cacheCreationTokens !== undefined - ? `${formatNumber(summary.cacheCreationTokens)} cache-write` - : undefined, - summary.providerTotalTokens !== undefined - ? `${formatNumber(summary.providerTotalTokens)} provider total` - : undefined, - ].filter(Boolean); - return pieces.length > 0 - ? `${formatNumber(summary.totalTokens)} tokens (${pieces.join(" / ")})` - : `${formatNumber(summary.totalTokens)} tokens`; -} - /** Format a conversation span from first turn start to latest activity. */ export function formatConversationDuration(conversation: Conversation): string { const start = parseTime(conversation.startedAt); @@ -565,7 +535,7 @@ export function formatConversationDuration(conversation: Conversation): string { /** Resolve the owning conversation id for a turn/session summary. */ export function conversationIdForSession(session: Session): string { - return session.conversationId || session.id; + return session.conversationId; } function compareTimeDesc(a: string | undefined, b: string | undefined): number { @@ -624,32 +594,11 @@ export function requesterLabel( return email ?? fullName ?? slackUserName ?? requester?.slackUserId; } -function sameDisplayLabel( - left: string | undefined, - right: string | undefined, -): boolean { - return Boolean( - left && right && left.trim().toLowerCase() === right.trim().toLowerCase(), - ); -} - -/** Suppress redundant conversation owners already represented by title or surface. */ +/** Derive the conversation owner label from structured requester identity. */ export function conversationRequesterLabel( conversation: Conversation | undefined, ): string | undefined { - if (!conversation) return undefined; - const owner = requesterLabel(conversation.requesterIdentity); - if ( - sameDisplayLabel(owner, conversation.conversationTitle) || - sameDisplayLabel(owner, meaningfulConversationTitle(conversation)) || - sameDisplayLabel( - owner, - slackLocationLabel(conversation, { includeId: false }), - ) - ) { - return undefined; - } - return owner; + return requesterLabel(conversation?.requesterIdentity); } /** Format the owner and permalink id line shared by conversation rows and headers. */ @@ -988,6 +937,7 @@ export function buildConversations(sessions: Session[]): Conversation[] { channelName: sortedTurns.find((turn) => turn.channelName)?.channelName, conversationTitle, id, + lastProgressAt: newest.lastProgressAt, lastSeenAt: newest.lastSeenAt, requesterIdentity: requesterTurn?.requesterIdentity, sentryConversationUrl: newest.sentryConversationUrl, @@ -995,7 +945,7 @@ export function buildConversations(sessions: Session[]): Conversation[] { startedAt: oldest.startedAt, status, surface: newest.surface, - title: newest.title || id, + title: newest.title, traceId: newest.traceId, turns: sortedTurns, }; diff --git a/packages/junior-dashboard/src/client/types.ts b/packages/junior-dashboard/src/client/types.ts index 881a4f11d..87233a276 100644 --- a/packages/junior-dashboard/src/client/types.ts +++ b/packages/junior-dashboard/src/client/types.ts @@ -1,9 +1,11 @@ import type { BundledLanguage } from "shiki/bundle/web"; import type { DashboardConversationReport, + DashboardRequesterIdentity, DashboardSessionFeed, DashboardSessionReport, DashboardTurnReport, + DashboardTurnUsage, HealthReport, PluginReport, RuntimeInfoReport, @@ -18,11 +20,9 @@ export type Plugin = PluginReport; export type Skill = SkillReport; -export type RequesterIdentity = NonNullable< - DashboardSessionReport["requesterIdentity"] ->; +export type RequesterIdentity = DashboardRequesterIdentity; -export type TurnUsage = NonNullable; +export type TurnUsage = DashboardTurnUsage; export type Session = DashboardSessionReport; @@ -40,14 +40,14 @@ export type Conversation = { channelName?: string; conversationTitle?: string; id: string; - lastProgressAt?: string; - lastSeenAt?: string; + lastProgressAt: string; + lastSeenAt: string; requesterIdentity?: RequesterIdentity; sentryConversationUrl?: string; sentryTraceUrl?: string; - startedAt?: string; + startedAt: string; status: Session["status"]; - surface?: string; + surface: Session["surface"]; title: string; traceId?: string; turns: Session[]; diff --git a/packages/junior-dashboard/tests/format.test.ts b/packages/junior-dashboard/tests/format.test.ts index a9eff226a..e477366d7 100644 --- a/packages/junior-dashboard/tests/format.test.ts +++ b/packages/junior-dashboard/tests/format.test.ts @@ -259,7 +259,7 @@ describe("dashboard token formatting", () => { ); }); - it("suppresses redundant conversation requester labels", () => { + it("keeps requester labels even when the title matches", () => { const sessions: Session[] = [ { channel: "C1", @@ -281,9 +281,9 @@ describe("dashboard token formatting", () => { ]; const [conversation] = buildConversations(sessions); - expect(conversationRequesterLabel(conversation)).toBeUndefined(); + expect(conversationRequesterLabel(conversation)).toBe("alice"); expect(conversationIdentityMeta(conversation, conversation?.id)).toBe( - "slack:C1:123", + "alice · slack:C1:123", ); }); @@ -296,6 +296,7 @@ describe("dashboard token formatting", () => { lastSeenAt: "2026-06-01T10:02:29.000Z", startedAt: "2026-06-01T10:00:00.000Z", status: "completed", + surface: "slack", title: "Turn turn-1", }, ]); @@ -312,6 +313,7 @@ describe("dashboard token formatting", () => { lastSeenAt: "not-a-date", startedAt: "2026-06-01T10:00:00.000Z", status: "completed", + surface: "slack", title: "Turn turn-1", }, ]); diff --git a/packages/junior/src/reporting.ts b/packages/junior/src/reporting.ts index 52e79dc00..788c0decd 100644 --- a/packages/junior/src/reporting.ts +++ b/packages/junior/src/reporting.ts @@ -55,20 +55,44 @@ export interface RuntimeInfoReport { packagedContent: ReturnType; } +export type DashboardSessionStatus = + | "active" + | "completed" + | "failed" + | "hung" + | "superseded"; + +export type DashboardSurface = "slack" | "api" | "scheduler" | "internal"; + +export interface DashboardTurnUsage { + inputTokens?: number; + outputTokens?: number; + cachedInputTokens?: number; + cacheCreationTokens?: number; + totalTokens?: number; +} + +export interface DashboardRequesterIdentity { + email?: string; + fullName?: string; + slackUserId?: string; + slackUserName?: string; +} + export interface DashboardSessionReport { conversationTitle?: string; cumulativeDurationMs?: number; - cumulativeUsage?: AgentTurnUsage; + cumulativeUsage?: DashboardTurnUsage; conversationId: string; id: string; - status: "active" | "completed" | "failed" | "hung" | "superseded"; + status: DashboardSessionStatus; startedAt: string; lastSeenAt: string; lastProgressAt: string; completedAt?: string; - surface?: "slack" | "api" | "scheduler" | "internal"; - title?: string; - requesterIdentity?: AgentTurnRequester; + surface: DashboardSurface; + title: string; + requesterIdentity?: DashboardRequesterIdentity; channel?: string; channelName?: string; sentryConversationUrl?: string; @@ -76,6 +100,13 @@ export interface DashboardSessionReport { traceId?: string; } +export type DashboardTranscriptPartType = + | "text" + | "thinking" + | "tool_call" + | "tool_result" + | "unknown"; + export interface DashboardTranscriptPart { bytes?: number; chars?: number; @@ -92,13 +123,22 @@ export interface DashboardTranscriptPart { outputSizeChars?: number; outputType?: string; redacted?: boolean; + sourceType?: string; text?: string; - type: string; + type: DashboardTranscriptPartType; } +export type DashboardTranscriptRole = + | "assistant" + | "system" + | "tool" + | "toolResult" + | "unknown" + | "user"; + export interface DashboardTranscriptMessage { parts: DashboardTranscriptPart[]; - role: string; + role: DashboardTranscriptRole; timestamp?: number; } @@ -199,9 +239,7 @@ function statusFromCheckpoint( return state; } -function surfaceFromConversationId( - conversationId: string, -): DashboardSessionReport["surface"] { +function surfaceFromConversationId(conversationId: string): DashboardSurface { return parseSlackThreadId(conversationId) ? "slack" : "internal"; } @@ -225,6 +263,49 @@ function safePrivateLabel(summary: AgentTurnSessionSummary): string { return "Private Channel"; } +function requesterIdentityReport( + requester: AgentTurnRequester | undefined, +): DashboardRequesterIdentity | undefined { + if (!requester) return undefined; + const identity: DashboardRequesterIdentity = { + ...(requester.email !== undefined ? { email: requester.email } : {}), + ...(requester.fullName !== undefined + ? { fullName: requester.fullName } + : {}), + ...(requester.slackUserId !== undefined + ? { slackUserId: requester.slackUserId } + : {}), + ...(requester.slackUserName !== undefined + ? { slackUserName: requester.slackUserName } + : {}), + }; + return Object.keys(identity).length > 0 ? identity : undefined; +} + +function turnUsageReport( + usage: AgentTurnUsage | undefined, +): DashboardTurnUsage | undefined { + if (!usage) return undefined; + const report: DashboardTurnUsage = { + ...(usage.inputTokens !== undefined + ? { inputTokens: usage.inputTokens } + : {}), + ...(usage.outputTokens !== undefined + ? { outputTokens: usage.outputTokens } + : {}), + ...(usage.cachedInputTokens !== undefined + ? { cachedInputTokens: usage.cachedInputTokens } + : {}), + ...(usage.cacheCreationTokens !== undefined + ? { cacheCreationTokens: usage.cacheCreationTokens } + : {}), + ...(usage.totalTokens !== undefined + ? { totalTokens: usage.totalTokens } + : {}), + }; + return Object.keys(report).length > 0 ? report : undefined; +} + function sessionReportFromSummary( summary: AgentTurnSessionSummary, ): DashboardSessionReport { @@ -242,6 +323,8 @@ function sessionReportFromSummary( const sentryTraceUrl = summary.traceId ? buildSentryTraceUrl(summary.traceId) : undefined; + const requesterIdentity = requesterIdentityReport(summary.requester); + const cumulativeUsage = turnUsageReport(summary.cumulativeUsage); return { conversationId: summary.conversationId, ...(conversationTitle ? { conversationTitle } : {}), @@ -256,12 +339,10 @@ function sessionReportFromSummary( ...(summary.cumulativeDurationMs !== undefined ? { cumulativeDurationMs: summary.cumulativeDurationMs } : {}), - ...(summary.cumulativeUsage - ? { cumulativeUsage: summary.cumulativeUsage } - : {}), + ...(cumulativeUsage ? { cumulativeUsage } : {}), surface: surfaceFromConversationId(summary.conversationId), title: titleFromSummary(summary), - ...(summary.requester ? { requesterIdentity: summary.requester } : {}), + ...(requesterIdentity ? { requesterIdentity } : {}), ...(slackThread ? { channel: slackThread.channelId } : {}), ...(channelName ? { channelName } : {}), ...(sentryConversationUrl ? { sentryConversationUrl } : {}), @@ -330,7 +411,8 @@ function normalizeTranscriptPart(part: unknown): DashboardTranscriptPart { } return { - type: rawType, + type: "unknown", + ...(rawType !== "unknown" ? { sourceType: rawType } : {}), output: part, }; } @@ -366,7 +448,7 @@ function normalizeTranscriptMessage( ): DashboardTranscriptMessage { const record = message as unknown as Record; const content = record.content; - const role = typeof record.role === "string" ? record.role : "unknown"; + const role = transcriptRole(record.role); return { role, ...(typeof record.timestamp === "number" @@ -381,6 +463,16 @@ function normalizeTranscriptMessage( }; } +function transcriptRole(role: unknown): DashboardTranscriptRole { + return role === "assistant" || + role === "system" || + role === "tool" || + role === "toolResult" || + role === "user" + ? role + : "unknown"; +} + function serializedChars(value: unknown): number { if (typeof value === "string") return value.length; return JSON.stringify(value)?.length ?? 0; @@ -453,8 +545,9 @@ function redactTranscriptPart( }; } return { - type: part.type, + type: "unknown", redacted: true, + ...(part.sourceType ? { sourceType: part.sourceType } : {}), ...redactedPayloadFields("output", part.output ?? part.input ?? part.text), }; } @@ -471,9 +564,8 @@ function redactTranscriptMessage( }; } -function isConversationMessageRole(role: string): boolean { - const normalized = role.toLowerCase(); - return normalized === "user" || normalized === "assistant"; +function isConversationMessageRole(role: DashboardTranscriptRole): boolean { + return role === "user" || role === "assistant"; } function hasTextPart(message: DashboardTranscriptMessage): boolean { @@ -486,7 +578,7 @@ function hasTextPart(message: DashboardTranscriptMessage): boolean { function isConversationMessage(message: DashboardTranscriptMessage): boolean { if (!isConversationMessageRole(message.role)) return false; - if (message.role.toLowerCase() === "assistant") return hasTextPart(message); + if (message.role === "assistant") return hasTextPart(message); return message.parts.length > 0; } From 1cd857333669396755bf689c4675bd71dc00b97b Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 2 Jun 2026 06:35:42 +0200 Subject: [PATCH 07/17] fix(dashboard): Align duration chart contracts Plot turn and conversation duration chart points as wall-clock spans so mode toggles use the same semantic duration. Remove the unused duration-point subtitle field and rely on required report timestamps when sorting grouped conversations. Co-Authored-By: GPT-5 Codex --- .../src/client/components/TurnDurationChart.tsx | 16 ++++------------ packages/junior-dashboard/src/client/format.ts | 5 +---- 2 files changed, 5 insertions(+), 16 deletions(-) diff --git a/packages/junior-dashboard/src/client/components/TurnDurationChart.tsx b/packages/junior-dashboard/src/client/components/TurnDurationChart.tsx index 1ffdcbe8f..678fead27 100644 --- a/packages/junior-dashboard/src/client/components/TurnDurationChart.tsx +++ b/packages/junior-dashboard/src/client/components/TurnDurationChart.tsx @@ -195,7 +195,6 @@ type DurationPoint = { session?: Session; startedAt: string; status: PlottedTurnStatus; - subtitle: string; title: string; tooltipLabel: string; x: number; @@ -250,11 +249,10 @@ function turnPoint(session: Session, timeZone: string): DurationPoint | null { } const lastSeenAtMs = Date.parse(session.lastSeenAt); - const durationMs = - session.cumulativeDurationMs ?? - (Number.isFinite(lastSeenAtMs) - ? Math.max(0, lastSeenAtMs - startedAtMs) - : 0); + if (!Number.isFinite(lastSeenAtMs)) { + return null; + } + const durationMs = Math.max(0, lastSeenAtMs - startedAtMs); return { conversationId: conversationIdForSession(session), durationLabel: formatMs(durationMs), @@ -264,9 +262,6 @@ function turnPoint(session: Session, timeZone: string): DurationPoint | null { session, startedAt: session.startedAt, status, - subtitle: new Date(startedAtMs).toLocaleString(undefined, { - timeZone, - }), title: turnTooltipTitle(session), tooltipLabel: new Date(startedAtMs).toLocaleString(undefined, { timeZone, @@ -302,9 +297,6 @@ function conversationPoint( kind: "conversations", startedAt: conversation.startedAt, status, - subtitle: new Date(startedAtMs).toLocaleString(undefined, { - timeZone, - }), title: conversationDisplayTitle(conversation), tooltipLabel: new Date(startedAtMs).toLocaleString(undefined, { timeZone, diff --git a/packages/junior-dashboard/src/client/format.ts b/packages/junior-dashboard/src/client/format.ts index 62a12c0e1..ef9c1df2c 100644 --- a/packages/junior-dashboard/src/client/format.ts +++ b/packages/junior-dashboard/src/client/format.ts @@ -909,10 +909,7 @@ export function buildConversations(sessions: Session[]): Conversation[] { compareTimeAsc(a.startedAt, b.startedAt), ); const newest = [...turns].sort((a, b) => - compareTimeDesc( - a.lastSeenAt ?? a.startedAt, - b.lastSeenAt ?? b.startedAt, - ), + compareTimeDesc(a.lastSeenAt, b.lastSeenAt), )[0]!; const oldest = sortedTurns.reduce((current, next) => (parseTime(next.startedAt) ?? Number.MAX_SAFE_INTEGER) < From 6e6a369862cc1bdae5674621c3c301cbddd8bd6d Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 2 Jun 2026 06:39:02 +0200 Subject: [PATCH 08/17] fix(dashboard): Clean up metric tooltips Remove native title attributes from metric tooltip triggers so browser tooltips do not overlap the dashboard tooltip. Render tooltip labels in normal case, with code-styled tool names and readable message authors instead of forcing content labels to uppercase. Co-Authored-By: GPT-5 Codex --- .../src/client/components/Metric.tsx | 19 ++++++++++--------- .../client/components/TelemetryMetrics.tsx | 2 ++ 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/junior-dashboard/src/client/components/Metric.tsx b/packages/junior-dashboard/src/client/components/Metric.tsx index 27dc5c74e..45a0587a8 100644 --- a/packages/junior-dashboard/src/client/components/Metric.tsx +++ b/packages/junior-dashboard/src/client/components/Metric.tsx @@ -10,6 +10,7 @@ import { cn } from "../styles"; export type MetricTooltipLine = { label?: string; + labelStyle?: "code" | "text"; value: string; }; @@ -18,12 +19,6 @@ export type MetricListItem = { key: string; }; -function titleText(lines: MetricTooltipLine[]): string { - return lines - .map((line) => (line.label ? `${line.label}: ${line.value}` : line.value)) - .join("\n"); -} - type TooltipPosition = { left: number; top: number; @@ -91,7 +86,6 @@ export function MetricValue(props: { onMouseLeave={hideTooltip} ref={triggerRef} tabIndex={0} - title={titleText(tooltip)} > {props.children} @@ -102,7 +96,7 @@ export function MetricValue(props: { role="tooltip" style={tooltipStyle} > - + {tooltip.map((line, index) => ( {line.label ? ( - + {line.label} ) : null} diff --git a/packages/junior-dashboard/src/client/components/TelemetryMetrics.tsx b/packages/junior-dashboard/src/client/components/TelemetryMetrics.tsx index a4b19b8e7..5cf1b115e 100644 --- a/packages/junior-dashboard/src/client/components/TelemetryMetrics.tsx +++ b/packages/junior-dashboard/src/client/components/TelemetryMetrics.tsx @@ -97,6 +97,7 @@ export function ToolCallsMetric(props: { if (!props.summary) return null; const tooltip = props.summary.items.map((item) => ({ label: item.name, + labelStyle: "code" as const, value: [ plural("call", item.count), item.totalDurationMs !== undefined @@ -123,6 +124,7 @@ export function MessagesMetric(props: { if (!props.summary) return null; const tooltip = props.summary.items.map((item) => ({ label: item.author, + labelStyle: "text" as const, value: formatBytes(item.bytes), })); return ( From 910f1d4cd5463fd56779a7b95ba470faf3e97c0e Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 2 Jun 2026 06:48:12 +0200 Subject: [PATCH 09/17] fix(dashboard): Simplify metric hover affordances Render message counts without a hover tooltip and remove the help cursor from remaining metric tooltip triggers. Co-Authored-By: GPT-5 Codex --- .../src/client/components/Metric.tsx | 5 ++--- .../src/client/components/TelemetryMetrics.tsx | 15 ++------------- .../src/client/components/TurnDurationChart.tsx | 6 +----- 3 files changed, 5 insertions(+), 21 deletions(-) diff --git a/packages/junior-dashboard/src/client/components/Metric.tsx b/packages/junior-dashboard/src/client/components/Metric.tsx index 45a0587a8..cc12aa99f 100644 --- a/packages/junior-dashboard/src/client/components/Metric.tsx +++ b/packages/junior-dashboard/src/client/components/Metric.tsx @@ -10,7 +10,7 @@ import { cn } from "../styles"; export type MetricTooltipLine = { label?: string; - labelStyle?: "code" | "text"; + labelStyle?: "code"; value: string; }; @@ -79,7 +79,7 @@ export function MetricValue(props: { {line.label} diff --git a/packages/junior-dashboard/src/client/components/TelemetryMetrics.tsx b/packages/junior-dashboard/src/client/components/TelemetryMetrics.tsx index 5cf1b115e..c68f90f29 100644 --- a/packages/junior-dashboard/src/client/components/TelemetryMetrics.tsx +++ b/packages/junior-dashboard/src/client/components/TelemetryMetrics.tsx @@ -1,5 +1,4 @@ import { - formatBytes, formatCompactNumber, formatMs, formatTime, @@ -114,22 +113,12 @@ export function ToolCallsMetric(props: { ); } -/** Render a conversational message count with per-message author and size. */ +/** Render a conversational message count. */ export function MessagesMetric(props: { - align?: "left" | "right"; loading?: boolean; summary: MessageSummary | undefined; }) { if (props.loading) return messages loading; if (!props.summary) return null; - const tooltip = props.summary.items.map((item) => ({ - label: item.author, - labelStyle: "text" as const, - value: formatBytes(item.bytes), - })); - return ( - - {plural("message", props.summary.total)} - - ); + return {plural("message", props.summary.total)}; } diff --git a/packages/junior-dashboard/src/client/components/TurnDurationChart.tsx b/packages/junior-dashboard/src/client/components/TurnDurationChart.tsx index 678fead27..ee1f4a92c 100644 --- a/packages/junior-dashboard/src/client/components/TurnDurationChart.tsx +++ b/packages/junior-dashboard/src/client/components/TurnDurationChart.tsx @@ -442,11 +442,7 @@ function chartTooltipRows( : null, [ "messages", - detail ? ( - - ) : ( - "loading" - ), + detail ? : "loading", ], [ "tool calls", From 7467dfc50392efdce6151576760c975cb0f16d4d Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 2 Jun 2026 06:59:23 +0200 Subject: [PATCH 10/17] fix(dashboard): Deemphasize transcript tool events Render thinking as standalone muted transcript events, lower the visual weight of tool rows, and collapse long consecutive tool-call runs. Co-Authored-By: GPT-5 Codex --- .../src/client/components/ToolFrame.tsx | 6 +- .../client/components/TranscriptToolView.tsx | 12 +- .../src/client/components/TranscriptTurn.tsx | 298 +++++++++++++++--- .../components/transcriptRenderModel.ts | 27 +- .../tests/transcriptRenderModel.test.ts | 44 +++ 5 files changed, 338 insertions(+), 49 deletions(-) create mode 100644 packages/junior-dashboard/tests/transcriptRenderModel.test.ts diff --git a/packages/junior-dashboard/src/client/components/ToolFrame.tsx b/packages/junior-dashboard/src/client/components/ToolFrame.tsx index df241315a..9b2031bbd 100644 --- a/packages/junior-dashboard/src/client/components/ToolFrame.tsx +++ b/packages/junior-dashboard/src/client/components/ToolFrame.tsx @@ -39,14 +39,14 @@ export function ToolFrame(props: { /** Provide the shared transcript tool-frame shell for nonstandard part views. */ export function toolFrameClass(): string { - return "border border-[#beaaff]/20 bg-[#111] transition-colors hover:border-[#beaaff]/45 hover:bg-[rgba(190,170,255,0.06)]"; + return "border-l border-[#beaaff]/20 pl-3 transition-colors hover:border-[#beaaff]/40"; } function toolHeaderClass(interactive: boolean): string { return cn( - "grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3 px-3 py-2 font-mono text-[0.84rem] leading-tight text-[#b8b8b8] max-md:grid-cols-1 max-md:gap-1", + "grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3 py-1.5 font-mono text-[0.82rem] leading-tight text-[#b8b8b8] max-md:grid-cols-1 max-md:gap-1", interactive - ? "cursor-pointer hover:bg-[rgba(190,170,255,0.07)]" + ? "cursor-pointer list-none transition-colors hover:text-[#d6d6d6] [&::-webkit-details-marker]:hidden" : "cursor-default", ); } diff --git a/packages/junior-dashboard/src/client/components/TranscriptToolView.tsx b/packages/junior-dashboard/src/client/components/TranscriptToolView.tsx index cbf21cdd9..ba35b95ec 100644 --- a/packages/junior-dashboard/src/client/components/TranscriptToolView.tsx +++ b/packages/junior-dashboard/src/client/components/TranscriptToolView.tsx @@ -51,7 +51,7 @@ export function TranscriptToolView(props: { meta={meta} raw signature={ - + {toolName} } @@ -74,7 +74,7 @@ export function TranscriptToolView(props: { meta={meta} signature={ <> - + {toolName} {isPreviewableValue(input) ? ( @@ -113,12 +113,12 @@ function ToolBodySection(props: { return (
{props.label ? ( -
+
{props.label}
) : null} @@ -173,7 +173,7 @@ function ToolArgEntry(props: { index: number; name: string; value: string }) { return ( {props.index > 0 ? , : null} - {props.name} + {props.name} : diff --git a/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx b/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx index a571c1bd4..ec17c270a 100644 --- a/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx +++ b/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx @@ -1,4 +1,9 @@ -import { useState, type ClipboardEventHandler, type ReactNode } from "react"; +import { + Fragment, + useState, + type ClipboardEventHandler, + type ReactNode, +} from "react"; import { HighlightedCode } from "../code"; import { @@ -50,6 +55,15 @@ import { } from "./transcriptStyles"; import { previewToolValue } from "./transcriptPreview"; +type TranscriptEntry = ReturnType[number]; +type TranscriptMessageEntry = Extract; +type TranscriptThinkingEntry = Extract; +type TranscriptToolEntry = Extract; + +const TOOL_RUN_COLLAPSE_THRESHOLD = 10; +const TOOL_RUN_HEAD_COUNT = 4; +const TOOL_RUN_TAIL_COUNT = 2; + /** Render one conversation turn as actor messages and tool events. */ export function TurnTranscript(props: { number: number; @@ -173,10 +187,28 @@ function TurnEvents(props: { view: TranscriptViewMode; }) { return ( -
+
{props.turn.transcriptAvailable ? ( - groupTranscriptMessages(props.turn.transcript).map((entry, index) => - entry.kind === "tool" ? ( + ( + + )} + renderThinking={(entry, index) => ( + + )} + renderTool={(entry, index) => ( - ) : ( - - ), - ) + )} + /> ) : props.turn.transcriptRedacted && props.turn.transcriptMetadata?.length ? ( @@ -206,31 +231,187 @@ function TurnEvents(props: { ); } -function RedactedTranscriptView(props: { turn: ConversationTurn }) { +function TranscriptEntryList(props: { + entries: TranscriptEntry[]; + keyPrefix: string; + renderMessage: (entry: TranscriptMessageEntry, index: number) => ReactNode; + renderThinking: (entry: TranscriptThinkingEntry, index: number) => ReactNode; + renderTool: (entry: TranscriptToolEntry, index: number) => ReactNode; +}) { + const rows: ReactNode[] = []; + + for (let index = 0; index < props.entries.length; ) { + const entry = props.entries[index]!; + + if (entry.kind === "tool") { + const startIndex = index; + const tools: TranscriptToolEntry[] = []; + while (props.entries[index]?.kind === "tool") { + tools.push(props.entries[index] as TranscriptToolEntry); + index += 1; + } + rows.push( + , + ); + continue; + } + + rows.push( + + {entry.kind === "thinking" + ? props.renderThinking(entry, index) + : props.renderMessage(entry, index)} + , + ); + index += 1; + } + + return <>{rows}; +} + +function ToolRunView(props: { + entries: TranscriptToolEntry[]; + keyPrefix: string; + renderTool: (entry: TranscriptToolEntry, index: number) => ReactNode; + startIndex: number; +}) { + const [expanded, setExpanded] = useState(false); + + if (props.entries.length < TOOL_RUN_COLLAPSE_THRESHOLD) { + return ( + <> + {renderToolEntries( + props.entries, + props.startIndex, + props.keyPrefix, + props.renderTool, + )} + + ); + } + + if (expanded) { + return ( + <> + {renderToolEntries( + props.entries, + props.startIndex, + props.keyPrefix, + props.renderTool, + )} + setExpanded(false)} + totalCount={props.entries.length} + /> + + ); + } + + const hiddenCount = + props.entries.length - TOOL_RUN_HEAD_COUNT - TOOL_RUN_TAIL_COUNT; + return ( <> - {groupTranscriptMessages(props.turn.transcriptMetadata ?? []).map( - (entry, index) => - entry.kind === "tool" ? ( - - ) : ( - - ), + {renderToolEntries( + props.entries.slice(0, TOOL_RUN_HEAD_COUNT), + props.startIndex, + props.keyPrefix, + props.renderTool, + )} + setExpanded(true)} + totalCount={props.entries.length} + /> + {renderToolEntries( + props.entries.slice(-TOOL_RUN_TAIL_COUNT), + props.startIndex + props.entries.length - TOOL_RUN_TAIL_COUNT, + props.keyPrefix, + props.renderTool, )} ); } +function renderToolEntries( + entries: TranscriptToolEntry[], + startIndex: number, + keyPrefix: string, + renderTool: (entry: TranscriptToolEntry, index: number) => ReactNode, +): ReactNode[] { + return entries.map((entry, offset) => { + const index = startIndex + offset; + return ( + + {renderTool(entry, index)} + + ); + }); +} + +function ToolRunToggle(props: { + expanded?: boolean; + hiddenCount?: number; + onClick: () => void; + totalCount: number; +}) { + const label = props.expanded + ? `collapse ${props.totalCount} tool calls` + : `show ${props.hiddenCount ?? 0} more tool calls`; + + return ( + + ); +} + +function RedactedTranscriptView(props: { turn: ConversationTurn }) { + return ( + ( + + )} + renderThinking={(entry, index) => ( + + )} + renderTool={(entry, index) => ( + + )} + /> + ); +} + function RedactedMessageView(props: { message: TranscriptMessage; turn: ConversationTurn; @@ -295,6 +476,29 @@ function RedactedMarker() { ); } +function RedactedThinkingView(props: { + timestamp?: number; + turn: ConversationTurn; +}) { + const offset = formatMessageOffset(props.turn, props.timestamp); + const meta = [ + props.timestamp ? formatMessageTimestamp(props.timestamp) : undefined, + offset, + ].filter(isString); + + return ( +
+
+ thinking + + + {meta.join(" · ")} + +
+
+ ); +} + function RedactedToolView(props: { call?: TranscriptPart; result?: TranscriptPart; @@ -325,7 +529,7 @@ function RedactedToolView(props: { raw signature={ <> - + {toolName} {props.call?.inputKeys?.length ? ( @@ -569,9 +773,9 @@ function TranscriptPartView(props: { const rendered = stringifyPartValue(value); return (
- + {part.type} - + {part.name ?? part.id ?? "unknown"} @@ -583,28 +787,44 @@ function TranscriptPartView(props: { ); } -function ThinkingPartView(props: { value: unknown }) { +function ThinkingPartView(props: { + timestamp?: number; + turn?: ConversationTurn; + value: unknown; +}) { const [open, setOpen] = useState(false); const rendered = stringifyPartValue(props.value); + const offset = props.turn + ? formatMessageOffset(props.turn, props.timestamp) + : undefined; + const meta = [ + props.timestamp ? formatMessageTimestamp(props.timestamp) : undefined, + offset, + ].filter(isString); return (
{ if (event.currentTarget !== event.target) return; setOpen(event.currentTarget.open); }} open={open} > - - thinking + + thinking {open ? null : ( {previewToolValue(props.value)} )} + {meta.length ? ( + + {meta.join(" · ")} + + ) : null} -
+
0; } @@ -138,6 +153,16 @@ export function groupTranscriptMessages( continue; } + if (isThinking(part)) { + flushMessage(); + entries.push({ + kind: "thinking", + part, + timestamp: message.timestamp, + }); + continue; + } + messageParts.push(part); } diff --git a/packages/junior-dashboard/tests/transcriptRenderModel.test.ts b/packages/junior-dashboard/tests/transcriptRenderModel.test.ts new file mode 100644 index 000000000..de6c52e11 --- /dev/null +++ b/packages/junior-dashboard/tests/transcriptRenderModel.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; + +import { groupTranscriptMessages } from "../src/client/components/transcriptRenderModel"; +import type { TranscriptMessage } from "../src/client/types"; + +describe("transcript render model", () => { + it("promotes thinking parts to standalone transcript events", () => { + const messages = [ + { + role: "assistant", + timestamp: 1_000, + parts: [ + { type: "text", text: "first" }, + { type: "thinking", output: "inspect the inputs" }, + { type: "text", text: "second" }, + ], + }, + ] as TranscriptMessage[]; + + expect(groupTranscriptMessages(messages)).toEqual([ + { + kind: "message", + message: { + role: "assistant", + timestamp: 1_000, + parts: [{ type: "text", text: "first" }], + }, + }, + { + kind: "thinking", + part: { type: "thinking", output: "inspect the inputs" }, + timestamp: 1_000, + }, + { + kind: "message", + message: { + role: "assistant", + timestamp: 1_000, + parts: [{ type: "text", text: "second" }], + }, + }, + ]); + }); +}); From 1bba2955682de3f4e93bbadcd947f576405b6134 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 2 Jun 2026 07:05:05 +0200 Subject: [PATCH 11/17] fix(dashboard): Refine transcript activity rendering Render thinking as muted italic thought text and align turn duration labels with chart duration points. Co-Authored-By: GPT-5 Codex --- .../src/client/components/TranscriptTurn.tsx | 29 +++++++++---------- .../client/components/TurnDurationChart.tsx | 16 ++++------ .../junior-dashboard/src/client/format.ts | 17 +++++++++++ .../junior-dashboard/tests/format.test.ts | 11 +++++++ 4 files changed, 48 insertions(+), 25 deletions(-) diff --git a/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx b/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx index ec17c270a..5d2fbc140 100644 --- a/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx +++ b/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx @@ -8,13 +8,13 @@ import { import { HighlightedCode } from "../code"; import { detectLanguage, - detectOutputLanguage, transcriptRoleKind, type TranscriptRoleKind, formatBytes, formatMessageOffset, formatMessageTimestamp, formatMs, + formatTurnDuration, requesterLabel, summarizeMessages, summarizeToolCalls, @@ -487,11 +487,11 @@ function RedactedThinkingView(props: { ].filter(isString); return ( -
+
- thinking + thought - + {meta.join(" · ")}
@@ -560,7 +560,7 @@ function turnMessageSummary(turn: ConversationTurn) { } function turnMeta(turn: ConversationTurn): MetricListItem[] { - const duration = formatMs(turn.cumulativeDurationMs); + const duration = formatTurnDuration(turn); const tokenSummary = summarizeUsage([turn.cumulativeUsage]); const toolSummary = summarizeToolCalls([turn]); const messageSummary = turnMessageSummary(turn); @@ -804,31 +804,30 @@ function ThinkingPartView(props: { return (
{ if (event.currentTarget !== event.target) return; setOpen(event.currentTarget.open); }} open={open} > - - thinking + + + thought + {open ? null : ( - + {previewToolValue(props.value)} )} {meta.length ? ( - + {meta.join(" · ")} ) : null} -
- +
+ {rendered || "{}"}
); diff --git a/packages/junior-dashboard/src/client/components/TurnDurationChart.tsx b/packages/junior-dashboard/src/client/components/TurnDurationChart.tsx index ee1f4a92c..685665d40 100644 --- a/packages/junior-dashboard/src/client/components/TurnDurationChart.tsx +++ b/packages/junior-dashboard/src/client/components/TurnDurationChart.tsx @@ -20,12 +20,13 @@ import { conversationPath, formatConversationDuration, formatDurationTick, - formatMs, + formatTurnDuration, requesterLabel, slackLocationLabel, summarizeMessages, summarizeToolCalls, summarizeUsage, + turnElapsedDurationMs, visualStatusForSession, visualStatusForConversation, } from "../format"; @@ -248,14 +249,13 @@ function turnPoint(session: Session, timeZone: string): DurationPoint | null { return null; } - const lastSeenAtMs = Date.parse(session.lastSeenAt); - if (!Number.isFinite(lastSeenAtMs)) { + const durationMs = turnElapsedDurationMs(session); + if (durationMs === undefined) { return null; } - const durationMs = Math.max(0, lastSeenAtMs - startedAtMs); return { conversationId: conversationIdForSession(session), - durationLabel: formatMs(durationMs), + durationLabel: formatTurnDuration(session), durationMs, endedAt: session.completedAt ?? session.lastSeenAt, kind: "turns", @@ -433,7 +433,7 @@ function chartTooltipRows( , ], @@ -458,10 +458,6 @@ function chartTooltipRows( return rows.filter((row): row is [string, ReactNode] => row !== null); } -function chartDurationLabel(point: DurationPoint): string { - return point.durationLabel; -} - function chartTooltipTurns( point: DurationPoint, detail: ConversationDetailFeed | undefined, diff --git a/packages/junior-dashboard/src/client/format.ts b/packages/junior-dashboard/src/client/format.ts index ef9c1df2c..7a7aac4b0 100644 --- a/packages/junior-dashboard/src/client/format.ts +++ b/packages/junior-dashboard/src/client/format.ts @@ -521,6 +521,23 @@ export function formatUsageTotal(usages: Array): string { return formatTokenSummary(summarizeUsage(usages)); } +/** Keep turn duration displays aligned on elapsed transcript time. */ +export function turnElapsedDurationMs( + turn: Pick, +): number | undefined { + const start = parseTime(turn.startedAt); + const end = parseTime(turn.completedAt ?? turn.lastSeenAt); + if (start == null || end == null || end < start) return undefined; + return Math.max(0, end - start); +} + +/** Format elapsed turn time for chart dots and transcript metadata. */ +export function formatTurnDuration( + turn: Pick, +): string { + return formatMs(turnElapsedDurationMs(turn)); +} + /** Format a conversation span from first turn start to latest activity. */ export function formatConversationDuration(conversation: Conversation): string { const start = parseTime(conversation.startedAt); diff --git a/packages/junior-dashboard/tests/format.test.ts b/packages/junior-dashboard/tests/format.test.ts index e477366d7..03d4d3dad 100644 --- a/packages/junior-dashboard/tests/format.test.ts +++ b/packages/junior-dashboard/tests/format.test.ts @@ -9,6 +9,7 @@ import { formatConversationDuration, formatDurationTotal, formatDurationTick, + formatTurnDuration, formatTokenTotal, formatUsageTotal, parseMarkdownBlocks, @@ -57,6 +58,16 @@ describe("dashboard token formatting", () => { expect(formatDurationTick(9 * 60_000 + 59_900)).toBe("10m"); }); + it("formats turn duration from start to completion time", () => { + expect( + formatTurnDuration({ + completedAt: "2026-01-01T00:02:00.000Z", + lastSeenAt: "2026-01-01T00:05:00.000Z", + startedAt: "2026-01-01T00:00:00.000Z", + }), + ).toBe("2m 0s"); + }); + it("counts conversational transcript messages instead of tool events", () => { const turn = { id: "turn-1", From 1b6a0fa457bad414e10de855a99bcd9b81c7a4c8 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 2 Jun 2026 07:12:08 +0200 Subject: [PATCH 12/17] fix(dashboard): Center collapsed system prompt Keep the collapsed system prompt disclosure aligned as a single row instead of inheriting expanded message spacing. Co-Authored-By: GPT-5 Codex --- .../src/client/components/TranscriptTurn.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx b/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx index 5d2fbc140..b3af31e03 100644 --- a/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx +++ b/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx @@ -625,15 +625,15 @@ function SystemMessageView(props: { return (
{ if (event.currentTarget !== event.target) return; setOpen(event.currentTarget.open); }} open={open} > - -
+ +
{transcriptRoleLabel(role, props.turn)} From af11c38db0ee4bc3eff7ea55e6b55372da81d591 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 2 Jun 2026 07:24:45 +0200 Subject: [PATCH 13/17] fix(dashboard): Clean up telemetry transcript types Remove dead dashboard formatting exports and stale imports. Keep tool-call/result grouping keyed by shared ids or names so the dashboard does not infer relationships from missing metadata. Co-Authored-By: GPT-5 Codex --- .../src/client/components/TranscriptTurn.tsx | 3 +- .../components/transcriptRenderModel.ts | 49 ++++++++++++---- .../junior-dashboard/src/client/format.ts | 19 +----- .../junior-dashboard/tests/format.test.ts | 41 +++++++++---- .../tests/transcriptRenderModel.test.ts | 58 ++++++++++++++++++- 5 files changed, 127 insertions(+), 43 deletions(-) diff --git a/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx b/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx index b3af31e03..08986d65b 100644 --- a/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx +++ b/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx @@ -9,7 +9,6 @@ import { HighlightedCode } from "../code"; import { detectLanguage, transcriptRoleKind, - type TranscriptRoleKind, formatBytes, formatMessageOffset, formatMessageTimestamp, @@ -549,7 +548,7 @@ function redactedMessageSize(part: TranscriptPart): string | undefined { } function turnActorLabel(turn: ConversationTurn): string { - return requesterLabel(turn.requesterIdentity) ?? "unknown actor"; + return requesterLabel(turn.requesterIdentity) ?? "User"; } function turnMessageSummary(turn: ConversationTurn) { diff --git a/packages/junior-dashboard/src/client/components/transcriptRenderModel.ts b/packages/junior-dashboard/src/client/components/transcriptRenderModel.ts index bd9bfb78a..9935b4238 100644 --- a/packages/junior-dashboard/src/client/components/transcriptRenderModel.ts +++ b/packages/junior-dashboard/src/client/components/transcriptRenderModel.ts @@ -6,9 +6,13 @@ import { } from "../format"; import type { TranscriptMessage, TranscriptPart } from "../types"; +type RenderedToolPart = + | { call: TranscriptPart; kind: "tool"; result?: TranscriptPart } + | { call?: undefined; kind: "tool"; result: TranscriptPart }; + export type RenderedTranscriptPart = | { kind: "part"; part: TranscriptPart } - | { kind: "tool"; call?: TranscriptPart; result?: TranscriptPart }; + | RenderedToolPart; type RenderedTranscriptEntry = | { kind: "message"; message: TranscriptMessage } @@ -21,13 +25,32 @@ type RenderedThinkingEntry = { timestamp?: number; }; -type RenderedToolEntry = { - call?: TranscriptPart; - kind: "tool"; - result?: TranscriptPart; - resultTimestamp?: number; - timestamp?: number; -}; +type RenderedToolEntry = + | { + call: TranscriptPart; + kind: "tool"; + result?: TranscriptPart; + resultTimestamp?: number; + timestamp?: number; + } + | { + call?: undefined; + kind: "tool"; + result: TranscriptPart; + resultTimestamp?: number; + timestamp?: never; + }; + +type RenderedToolCallEntry = Extract< + RenderedToolEntry, + { call: TranscriptPart } +>; + +function isRenderedToolCallEntry( + entry: RenderedTranscriptEntry, +): entry is RenderedToolCallEntry { + return entry.kind === "tool" && entry.call !== undefined; +} export type TranscriptViewMode = "raw" | "rich"; @@ -51,7 +74,9 @@ function sameToolInvocation( call: TranscriptPart, result: TranscriptPart, ): boolean { - if (call.id && result.id) return call.id === result.id; + if (call.id || result.id) { + return Boolean(call.id && result.id && call.id === result.id); + } if (call.name && result.name) return call.name === result.name; return false; } @@ -98,11 +123,11 @@ export function groupTranscriptParts( function findToolEntry( entries: RenderedTranscriptEntry[], result: TranscriptPart, -): RenderedToolEntry | undefined { +): RenderedToolCallEntry | undefined { for (let index = entries.length - 1; index >= 0; index -= 1) { const entry = entries[index]!; - if (entry.kind !== "tool" || entry.result) continue; - if (!entry.call || sameToolInvocation(entry.call, result)) { + if (!isRenderedToolCallEntry(entry) || entry.result) continue; + if (sameToolInvocation(entry.call, result)) { return entry; } } diff --git a/packages/junior-dashboard/src/client/format.ts b/packages/junior-dashboard/src/client/format.ts index 7a7aac4b0..1a6cb19f3 100644 --- a/packages/junior-dashboard/src/client/format.ts +++ b/packages/junior-dashboard/src/client/format.ts @@ -263,15 +263,6 @@ export function turnMessageCount(turn: ConversationTurn): number { return turn.transcriptMessageCount ?? 0; } -/** Count tool calls from visible transcripts or safe redacted metadata. */ -export function turnToolCallCount(turn: ConversationTurn): number { - return transcriptSource(turn).reduce((count, message) => { - return ( - count + message.parts.filter((part) => part.type === "tool_call").length - ); - }, 0); -} - export type ToolCallSummaryItem = { count: number; name: string; @@ -301,7 +292,7 @@ function sameToolPart( return Boolean(call.id && result.id && call.id === result.id); } if (call.name && result.name) return call.name === result.name; - return !call.id && !call.name && !result.id && !result.name; + return false; } function findPendingToolCallIndex( @@ -511,11 +502,6 @@ export function formatTokenSummary( return summary ? `${formatNumber(summary.totalTokens)} tokens` : ""; } -/** Format known token counters without estimating per-message usage. */ -export function formatTokenTotal(usage: TurnUsage | undefined): string { - return formatTokenSummary(summarizeUsage([usage])); -} - /** Format the aggregate token count across conversation turns. */ export function formatUsageTotal(usages: Array): string { return formatTokenSummary(summarizeUsage(usages)); @@ -623,8 +609,9 @@ export function conversationIdentityMeta( conversation: Conversation | undefined, conversationId: string | undefined, ): string { - const id = conversationId ?? "missing conversation id"; + const id = conversationId ?? conversation?.id; const owner = conversationRequesterLabel(conversation); + if (!id) return owner ?? ""; return owner ? `${owner} · ${id}` : id; } diff --git a/packages/junior-dashboard/tests/format.test.ts b/packages/junior-dashboard/tests/format.test.ts index 03d4d3dad..017ef0a88 100644 --- a/packages/junior-dashboard/tests/format.test.ts +++ b/packages/junior-dashboard/tests/format.test.ts @@ -10,7 +10,6 @@ import { formatDurationTotal, formatDurationTick, formatTurnDuration, - formatTokenTotal, formatUsageTotal, parseMarkdownBlocks, requesterLabel, @@ -37,17 +36,6 @@ describe("dashboard token formatting", () => { ).toBe("205 tokens"); }); - it("uses component counters for token totals when present", () => { - expect( - formatTokenTotal({ - cachedInputTokens: 10, - inputTokens: 20, - outputTokens: 30, - totalTokens: 999, - }), - ).toBe("60 tokens"); - }); - it("sums turn runtime when duration data exists", () => { expect(formatDurationTotal([1_000, 2_500, undefined])).toBe("3.5s"); }); @@ -217,6 +205,31 @@ describe("dashboard token formatting", () => { }); }); + it("does not infer tool durations for unnamed calls and results", () => { + const turn = { + id: "turn-1", + status: "completed", + transcriptAvailable: true, + transcript: [ + { + role: "assistant", + timestamp: 1_000, + parts: [{ type: "tool_call" }], + }, + { + role: "toolResult", + timestamp: 2_000, + parts: [{ type: "tool_result" }], + }, + ], + } as ConversationTurn; + + expect(summarizeToolCalls([turn])).toEqual({ + items: [{ count: 1, name: "unknown" }], + total: 1, + }); + }); + it("does not synthesize conversation titles from requester display names", () => { const sessions: Session[] = [ { @@ -243,6 +256,10 @@ describe("dashboard token formatting", () => { ); }); + it("does not render a fake identity line before route data exists", () => { + expect(conversationIdentityMeta(undefined, undefined)).toBe(""); + }); + it("keeps Slack display names with spaces as requester labels", () => { expect( requesterLabel({ slackUserId: "U1", slackUserName: "Alice Reviewer" }), diff --git a/packages/junior-dashboard/tests/transcriptRenderModel.test.ts b/packages/junior-dashboard/tests/transcriptRenderModel.test.ts index de6c52e11..76fa8651b 100644 --- a/packages/junior-dashboard/tests/transcriptRenderModel.test.ts +++ b/packages/junior-dashboard/tests/transcriptRenderModel.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; -import { groupTranscriptMessages } from "../src/client/components/transcriptRenderModel"; +import { + groupTranscriptMessages, + groupTranscriptParts, +} from "../src/client/components/transcriptRenderModel"; import type { TranscriptMessage } from "../src/client/types"; describe("transcript render model", () => { @@ -41,4 +44,57 @@ describe("transcript render model", () => { }, ]); }); + + it("matches tool results by id before falling back to tool name", () => { + const messages = [ + { + role: "assistant", + timestamp: 1_000, + parts: [{ type: "tool_call", id: "call-1", name: "search" }], + }, + { + role: "assistant", + timestamp: 1_100, + parts: [{ type: "tool_call", id: "call-2", name: "search" }], + }, + { + role: "toolResult", + timestamp: 2_000, + parts: [{ type: "tool_result", id: "call-2", name: "search" }], + }, + ] as TranscriptMessage[]; + + expect(groupTranscriptMessages(messages)).toEqual([ + { + call: { type: "tool_call", id: "call-1", name: "search" }, + kind: "tool", + timestamp: 1_000, + }, + { + call: { type: "tool_call", id: "call-2", name: "search" }, + kind: "tool", + result: { type: "tool_result", id: "call-2", name: "search" }, + resultTimestamp: 2_000, + timestamp: 1_100, + }, + ]); + }); + + it("does not group inline same-name tool parts with mismatched ids", () => { + expect( + groupTranscriptParts([ + { type: "tool_call", id: "call-1", name: "search" }, + { type: "tool_result", id: "call-2", name: "search" }, + ]), + ).toEqual([ + { + call: { type: "tool_call", id: "call-1", name: "search" }, + kind: "tool", + }, + { + kind: "tool", + result: { type: "tool_result", id: "call-2", name: "search" }, + }, + ]); + }); }); From 903448966d4baa5da0ea52b464eaf7f6342628e2 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 2 Jun 2026 08:25:38 +0200 Subject: [PATCH 14/17] fix(dashboard): Share tool invocation matching Move the dashboard tool call/result matching rule into one helper so transcript rendering and telemetry summaries cannot drift. Co-Authored-By: GPT-5 Codex --- .../client/components/transcriptRenderModel.ts | 12 +----------- packages/junior-dashboard/src/client/format.ts | 14 ++------------ .../src/client/toolInvocations.ts | 16 ++++++++++++++++ 3 files changed, 19 insertions(+), 23 deletions(-) create mode 100644 packages/junior-dashboard/src/client/toolInvocations.ts diff --git a/packages/junior-dashboard/src/client/components/transcriptRenderModel.ts b/packages/junior-dashboard/src/client/components/transcriptRenderModel.ts index 9935b4238..98edef4a4 100644 --- a/packages/junior-dashboard/src/client/components/transcriptRenderModel.ts +++ b/packages/junior-dashboard/src/client/components/transcriptRenderModel.ts @@ -4,6 +4,7 @@ import { stringifyPartValue, transcriptRoleKind, } from "../format"; +import { sameToolInvocation } from "../toolInvocations"; import type { TranscriptMessage, TranscriptPart } from "../types"; type RenderedToolPart = @@ -70,17 +71,6 @@ function isString(value: string | undefined): value is string { return typeof value === "string" && value.length > 0; } -function sameToolInvocation( - call: TranscriptPart, - result: TranscriptPart, -): boolean { - if (call.id || result.id) { - return Boolean(call.id && result.id && call.id === result.id); - } - if (call.name && result.name) return call.name === result.name; - return false; -} - /** Group inline transcript parts so matching tool calls/results render together. */ export function groupTranscriptParts( parts: TranscriptPart[], diff --git a/packages/junior-dashboard/src/client/format.ts b/packages/junior-dashboard/src/client/format.ts index 1a6cb19f3..057232b92 100644 --- a/packages/junior-dashboard/src/client/format.ts +++ b/packages/junior-dashboard/src/client/format.ts @@ -13,6 +13,7 @@ import type { TurnUsage, VisualStatus, } from "./types"; +import { sameToolInvocation } from "./toolInvocations"; let dashboardTimeZone = "America/Los_Angeles"; @@ -284,23 +285,12 @@ function toolCallName(part: TranscriptPart): string { return part.name ?? part.id ?? "unknown"; } -function sameToolPart( - call: Pick, - result: Pick, -): boolean { - if (call.id || result.id) { - return Boolean(call.id && result.id && call.id === result.id); - } - if (call.name && result.name) return call.name === result.name; - return false; -} - function findPendingToolCallIndex( calls: PendingToolCall[], result: TranscriptPart, ): number { for (let index = calls.length - 1; index >= 0; index -= 1) { - if (sameToolPart(calls[index]!, result)) { + if (sameToolInvocation(calls[index]!, result)) { return index; } } diff --git a/packages/junior-dashboard/src/client/toolInvocations.ts b/packages/junior-dashboard/src/client/toolInvocations.ts new file mode 100644 index 000000000..673529363 --- /dev/null +++ b/packages/junior-dashboard/src/client/toolInvocations.ts @@ -0,0 +1,16 @@ +type ToolInvocationRef = { + id?: string; + name?: string; +}; + +/** Match tool call/result refs without inferring relationships from missing metadata. */ +export function sameToolInvocation( + left: ToolInvocationRef, + right: ToolInvocationRef, +): boolean { + if (left.id || right.id) { + return Boolean(left.id && right.id && left.id === right.id); + } + if (left.name && right.name) return left.name === right.name; + return false; +} From 2f185e8f15f8dbdce282a8a1c4a47545950320b3 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 2 Jun 2026 08:35:50 +0200 Subject: [PATCH 15/17] fix(dashboard): Label conversation row runtime Restore the runtime noun in conversation row stats so duration values keep the same scan context as turns and token counts. Co-Authored-By: GPT-5 Codex --- .../src/client/components/ConversationRowStats.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/junior-dashboard/src/client/components/ConversationRowStats.tsx b/packages/junior-dashboard/src/client/components/ConversationRowStats.tsx index 3b0750e40..269f51496 100644 --- a/packages/junior-dashboard/src/client/components/ConversationRowStats.tsx +++ b/packages/junior-dashboard/src/client/components/ConversationRowStats.tsx @@ -19,7 +19,7 @@ export function ConversationRowStats(props: { const primaryStats = [ `${props.conversation.turns.length} turns`, tokens, - runtime, + runtime ? `${runtime} runtime` : "", ].filter(Boolean); const secondaryStats = [ props.timeLabel, From a26c9dd70b267946c3ee924ece604d6856ff9d91 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 2 Jun 2026 08:47:13 +0200 Subject: [PATCH 16/17] fix(dashboard): Preserve telemetry header context Restore per-turn Sentry trace links in transcript headers and avoid empty tool-call metric rows for zero-count summaries. Co-Authored-By: GPT-5 Codex --- .../client/components/TelemetryMetrics.tsx | 2 +- .../src/client/components/TranscriptTurn.tsx | 15 +++++++ .../client/components/TurnDurationChart.tsx | 18 +++++---- .../tests/telemetry-components.test.tsx | 39 +++++++++++++++++++ 4 files changed, 65 insertions(+), 9 deletions(-) create mode 100644 packages/junior-dashboard/tests/telemetry-components.test.tsx diff --git a/packages/junior-dashboard/src/client/components/TelemetryMetrics.tsx b/packages/junior-dashboard/src/client/components/TelemetryMetrics.tsx index c68f90f29..fc0d3b829 100644 --- a/packages/junior-dashboard/src/client/components/TelemetryMetrics.tsx +++ b/packages/junior-dashboard/src/client/components/TelemetryMetrics.tsx @@ -93,7 +93,7 @@ export function ToolCallsMetric(props: { summary: ToolCallSummary | undefined; }) { if (props.loading) return tool calls loading; - if (!props.summary) return null; + if (!props.summary || props.summary.total <= 0) return null; const tooltip = props.summary.items.map((item) => ({ label: item.name, labelStyle: "code" as const, diff --git a/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx b/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx index 08986d65b..679b1a665 100644 --- a/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx +++ b/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx @@ -594,6 +594,21 @@ function turnMeta(turn: ConversationTurn): MetricListItem[] { key: "tools", } : undefined, + turn.sentryTraceUrl + ? { + content: ( + + View in Sentry + + ), + key: "sentry", + } + : undefined, ]; return items.filter((item): item is MetricListItem => Boolean(item)); diff --git a/packages/junior-dashboard/src/client/components/TurnDurationChart.tsx b/packages/junior-dashboard/src/client/components/TurnDurationChart.tsx index 685665d40..ec64952d6 100644 --- a/packages/junior-dashboard/src/client/components/TurnDurationChart.tsx +++ b/packages/junior-dashboard/src/client/components/TurnDurationChart.tsx @@ -444,14 +444,16 @@ function chartTooltipRows( "messages", detail ? : "loading", ], - [ - "tool calls", - detail ? ( - - ) : ( - "loading" - ), - ], + !detail || (toolSummary && toolSummary.total > 0) + ? [ + "tool calls", + detail ? ( + + ) : ( + "loading" + ), + ] + : null, requester ? ["requester", requester] : null, location ? ["surface", location] : null, ]; diff --git a/packages/junior-dashboard/tests/telemetry-components.test.tsx b/packages/junior-dashboard/tests/telemetry-components.test.tsx new file mode 100644 index 000000000..41b1f65ef --- /dev/null +++ b/packages/junior-dashboard/tests/telemetry-components.test.tsx @@ -0,0 +1,39 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import { ToolCallsMetric } from "../src/client/components/TelemetryMetrics"; +import { TurnTranscript } from "../src/client/components/TranscriptTurn"; +import type { ConversationTurn } from "../src/client/types"; + +describe("dashboard telemetry components", () => { + it("keeps the per-turn Sentry trace link in transcript headers", () => { + const turn = { + conversationId: "conversation-1", + id: "turn-1", + lastProgressAt: "2026-01-01T00:00:00.000Z", + lastSeenAt: "2026-01-01T00:00:00.000Z", + sentryTraceUrl: "https://sentry.example/trace/abc", + startedAt: "2026-01-01T00:00:00.000Z", + status: "completed", + surface: "slack", + title: "Turn turn-1", + transcript: [], + transcriptAvailable: true, + } as ConversationTurn; + + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain("View in Sentry"); + expect(html).toContain("https://sentry.example/trace/abc"); + }); + + it("omits empty tool-call summaries", () => { + expect( + renderToStaticMarkup( + , + ), + ).toBe(""); + }); +}); From d716e5c3e311f27fc535ccc8a53d01b83e917c32 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Tue, 2 Jun 2026 09:06:41 +0200 Subject: [PATCH 17/17] fix(dashboard): Tighten telemetry metric rendering Omit the conversation-level tool-call metric slot after detail loads with no tool calls, so metric separators do not render around empty content. Preserve valid zero timestamps in transcript tool and thinking metadata, and prefer the newest available conversation title when grouping turn summaries. Co-Authored-By: GPT-5 Codex --- .../components/ConversationRowStats.tsx | 3 +- .../client/components/TranscriptToolView.tsx | 4 +- .../src/client/components/TranscriptTurn.tsx | 12 +- .../junior-dashboard/src/client/format.ts | 9 +- .../src/client/pages/ConversationPage.tsx | 17 +-- .../junior-dashboard/tests/format.test.ts | 29 +++++ .../tests/telemetry-components.test.tsx | 113 +++++++++++++++++- 7 files changed, 169 insertions(+), 18 deletions(-) diff --git a/packages/junior-dashboard/src/client/components/ConversationRowStats.tsx b/packages/junior-dashboard/src/client/components/ConversationRowStats.tsx index 269f51496..f73a0c097 100644 --- a/packages/junior-dashboard/src/client/components/ConversationRowStats.tsx +++ b/packages/junior-dashboard/src/client/components/ConversationRowStats.tsx @@ -16,8 +16,9 @@ export function ConversationRowStats(props: { const runtime = formatDurationTotal( props.conversation.turns.map((turn) => turn.cumulativeDurationMs), ); + const turnCount = props.conversation.turns.length; const primaryStats = [ - `${props.conversation.turns.length} turns`, + `${turnCount} ${turnCount === 1 ? "turn" : "turns"}`, tokens, runtime ? `${runtime} runtime` : "", ].filter(Boolean); diff --git a/packages/junior-dashboard/src/client/components/TranscriptToolView.tsx b/packages/junior-dashboard/src/client/components/TranscriptToolView.tsx index ba35b95ec..df76d4414 100644 --- a/packages/junior-dashboard/src/client/components/TranscriptToolView.tsx +++ b/packages/junior-dashboard/src/client/components/TranscriptToolView.tsx @@ -38,7 +38,9 @@ export function TranscriptToolView(props: { ? formatMs(props.resultTimestamp - props.timestamp) : undefined; const meta = [ - props.timestamp ? formatMessageTimestamp(props.timestamp) : undefined, + typeof props.timestamp === "number" + ? formatMessageTimestamp(props.timestamp) + : undefined, duration, props.result ? formatBytes(outputBytes) : undefined, props.result ? undefined : "missing result", diff --git a/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx b/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx index 679b1a665..3d3437946 100644 --- a/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx +++ b/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx @@ -481,7 +481,9 @@ function RedactedThinkingView(props: { }) { const offset = formatMessageOffset(props.turn, props.timestamp); const meta = [ - props.timestamp ? formatMessageTimestamp(props.timestamp) : undefined, + typeof props.timestamp === "number" + ? formatMessageTimestamp(props.timestamp) + : undefined, offset, ].filter(isString); @@ -517,7 +519,9 @@ function RedactedToolView(props: { ? formatMs(props.resultTimestamp - props.timestamp) : undefined; const meta = [ - props.timestamp ? formatMessageTimestamp(props.timestamp) : undefined, + typeof props.timestamp === "number" + ? formatMessageTimestamp(props.timestamp) + : undefined, duration, props.result ? undefined : "missing result", ].filter(isString); @@ -812,7 +816,9 @@ function ThinkingPartView(props: { ? formatMessageOffset(props.turn, props.timestamp) : undefined; const meta = [ - props.timestamp ? formatMessageTimestamp(props.timestamp) : undefined, + typeof props.timestamp === "number" + ? formatMessageTimestamp(props.timestamp) + : undefined, offset, ].filter(isString); diff --git a/packages/junior-dashboard/src/client/format.ts b/packages/junior-dashboard/src/client/format.ts index 057232b92..33e4a177b 100644 --- a/packages/junior-dashboard/src/client/format.ts +++ b/packages/junior-dashboard/src/client/format.ts @@ -902,9 +902,10 @@ export function buildConversations(sessions: Session[]): Conversation[] { const sortedTurns = [...turns].sort((a, b) => compareTimeAsc(a.startedAt, b.startedAt), ); - const newest = [...turns].sort((a, b) => + const recentTurns = [...turns].sort((a, b) => compareTimeDesc(a.lastSeenAt, b.lastSeenAt), - )[0]!; + ); + const newest = recentTurns[0]!; const oldest = sortedTurns.reduce((current, next) => (parseTime(next.startedAt) ?? Number.MAX_SAFE_INTEGER) < (parseTime(current.startedAt) ?? Number.MAX_SAFE_INTEGER) @@ -919,13 +920,13 @@ export function buildConversations(sessions: Session[]): Conversation[] { ? "failed" : newest.status; const requesterTurn = sortedTurns.find((turn) => turn.requesterIdentity); - const conversationTitle = sortedTurns.find( + const conversationTitle = recentTurns.find( (turn) => turn.conversationTitle, )?.conversationTitle; return { channel: newest.channel, - channelName: sortedTurns.find((turn) => turn.channelName)?.channelName, + channelName: recentTurns.find((turn) => turn.channelName)?.channelName, conversationTitle, id, lastProgressAt: newest.lastProgressAt, diff --git a/packages/junior-dashboard/src/client/pages/ConversationPage.tsx b/packages/junior-dashboard/src/client/pages/ConversationPage.tsx index 6adb4c8a2..6a4732a93 100644 --- a/packages/junior-dashboard/src/client/pages/ConversationPage.tsx +++ b/packages/junior-dashboard/src/client/pages/ConversationPage.tsx @@ -129,6 +129,7 @@ function ConversationStats(props: { includeId: false, }); const durationLabel = formatConversationDuration(props.conversation); + const turnCount = props.conversation.turns.length; const rawStats: Array = [ location ? { @@ -137,7 +138,7 @@ function ConversationStats(props: { } : undefined, { - content: `${props.conversation.turns.length} turns`, + content: `${turnCount} ${turnCount === 1 ? "turn" : "turns"}`, key: "turns", }, { @@ -146,12 +147,14 @@ function ConversationStats(props: { ), key: "messages", }, - { - content: ( - - ), - key: "tools", - }, + !props.detail || (toolSummary && toolSummary.total > 0) + ? { + content: ( + + ), + key: "tools", + } + : undefined, tokenSummary ? { content: , diff --git a/packages/junior-dashboard/tests/format.test.ts b/packages/junior-dashboard/tests/format.test.ts index 017ef0a88..eb14241bb 100644 --- a/packages/junior-dashboard/tests/format.test.ts +++ b/packages/junior-dashboard/tests/format.test.ts @@ -287,6 +287,35 @@ describe("dashboard token formatting", () => { ); }); + it("uses the newest available conversation title", () => { + const [conversation] = buildConversations([ + { + conversationId: "slack:C1:123", + conversationTitle: "Older title", + id: "turn-1", + lastProgressAt: "2026-06-01T10:05:00.000Z", + lastSeenAt: "2026-06-01T10:05:00.000Z", + startedAt: "2026-06-01T10:00:00.000Z", + status: "completed", + surface: "slack", + title: "Turn turn-1", + }, + { + conversationId: "slack:C1:123", + conversationTitle: "Newer title", + id: "turn-2", + lastProgressAt: "2026-06-01T11:05:00.000Z", + lastSeenAt: "2026-06-01T11:05:00.000Z", + startedAt: "2026-06-01T11:00:00.000Z", + status: "completed", + surface: "slack", + title: "Turn turn-2", + }, + ]); + + expect(conversationDisplayTitle(conversation)).toBe("Newer title"); + }); + it("keeps requester labels even when the title matches", () => { const sessions: Session[] = [ { diff --git a/packages/junior-dashboard/tests/telemetry-components.test.tsx b/packages/junior-dashboard/tests/telemetry-components.test.tsx index 41b1f65ef..8487e8911 100644 --- a/packages/junior-dashboard/tests/telemetry-components.test.tsx +++ b/packages/junior-dashboard/tests/telemetry-components.test.tsx @@ -1,9 +1,72 @@ import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it } from "vitest"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter, Route, Routes } from "react-router"; +import { afterEach, describe, expect, it } from "vitest"; import { ToolCallsMetric } from "../src/client/components/TelemetryMetrics"; +import { TranscriptToolView } from "../src/client/components/TranscriptToolView"; import { TurnTranscript } from "../src/client/components/TranscriptTurn"; -import type { ConversationTurn } from "../src/client/types"; +import { client } from "../src/client/api"; +import { ConversationPage } from "../src/client/pages/ConversationPage"; +import type { + ConversationDetailFeed, + ConversationTurn, + DashboardData, + Session, +} from "../src/client/types"; + +afterEach(() => { + client.clear(); +}); + +function dashboardData(sessions: Session[]): DashboardData { + return { + config: { + allowedEmailCount: 0, + allowedGoogleDomainCount: 0, + authPath: "/api/auth", + authRequired: false, + basePath: "/", + sentryConversationLinks: false, + timeZone: "UTC", + }, + health: { + service: "junior", + status: "ok", + timestamp: "2026-01-01T00:00:00.000Z", + }, + me: { user: {} }, + plugins: [], + runtime: { + cwd: "/repo", + homeDir: "/home", + packagedContent: {}, + providers: [], + skills: [], + }, + sessions: { + generatedAt: "2026-01-01T00:00:00.000Z", + sessions, + source: "turn_session_records", + }, + skills: [], + } as DashboardData; +} + +function renderConversationPage(data: DashboardData): string { + return renderToStaticMarkup( + + + + } + path="/conversations/:conversationId" + /> + + + , + ); +} describe("dashboard telemetry components", () => { it("keeps the per-turn Sentry trace link in transcript headers", () => { @@ -36,4 +99,50 @@ describe("dashboard telemetry components", () => { ), ).toBe(""); }); + + it("omits the conversation tool-call metric slot when the loaded detail has no tool calls", () => { + const session = { + conversationId: "conversation-1", + id: "turn-1", + lastProgressAt: "2026-01-01T00:00:00.000Z", + lastSeenAt: "not-a-date", + startedAt: "2026-01-01T00:00:00.000Z", + status: "completed", + surface: "internal", + title: "Turn turn-1", + } satisfies Session; + const detail = { + conversationId: "conversation-1", + generatedAt: "2026-01-01T00:00:00.000Z", + turns: [ + { + ...session, + transcript: [], + transcriptAvailable: true, + }, + ], + } satisfies ConversationDetailFeed; + client.setQueryData(["conversation", "conversation-1"], detail); + + const html = renderConversationPage(dashboardData([session])); + + expect(html).toContain("1 turn"); + expect(html).not.toContain("tool call"); + expect(html.match(/·/g) ?? []).toHaveLength(2); + }); + + it("keeps zero timestamps in tool metadata", () => { + const html = renderToStaticMarkup( + + + , + ); + + expect(html.match(/·/g) ?? []).toHaveLength(2); + }); });