diff --git a/packages/junior-dashboard/src/client/components/ConversationRowStats.tsx b/packages/junior-dashboard/src/client/components/ConversationRowStats.tsx index bc39f14d..f73a0c09 100644 --- a/packages/junior-dashboard/src/client/components/ConversationRowStats.tsx +++ b/packages/junior-dashboard/src/client/components/ConversationRowStats.tsx @@ -16,10 +16,11 @@ 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` : undefined, + runtime ? `${runtime} 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 00000000..cc12aa99 --- /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; + labelStyle?: "code"; + value: string; +}; + +export type MetricListItem = { + content: ReactNode; + key: string; +}; + +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 00000000..fc0d3b82 --- /dev/null +++ b/packages/junior-dashboard/src/client/components/TelemetryMetrics.tsx @@ -0,0 +1,124 @@ +import { + 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 || props.summary.total <= 0) return null; + const tooltip = props.summary.items.map((item) => ({ + label: item.name, + labelStyle: "code" as const, + 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. */ +export function MessagesMetric(props: { + loading?: boolean; + summary: MessageSummary | undefined; +}) { + if (props.loading) return messages loading; + if (!props.summary) return null; + return {plural("message", props.summary.total)}; +} diff --git a/packages/junior-dashboard/src/client/components/ToolFrame.tsx b/packages/junior-dashboard/src/client/components/ToolFrame.tsx index df241315..9b2031bb 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 cbf21cdd..df76d441 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", @@ -51,7 +53,7 @@ export function TranscriptToolView(props: { meta={meta} raw signature={ - + {toolName} } @@ -74,7 +76,7 @@ export function TranscriptToolView(props: { meta={meta} signature={ <> - + {toolName} {isPreviewableValue(input) ? ( @@ -113,12 +115,12 @@ function ToolBodySection(props: { return (
{props.label ? ( -
+
{props.label}
) : null} @@ -173,7 +175,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 dcf3f1da..3d343794 100644 --- a/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx +++ b/packages/junior-dashboard/src/client/components/TranscriptTurn.tsx @@ -1,17 +1,24 @@ -import { useState, type ClipboardEventHandler, type ReactNode } from "react"; +import { + Fragment, + useState, + type ClipboardEventHandler, + type ReactNode, +} from "react"; import { HighlightedCode } from "../code"; import { detectLanguage, - detectOutputLanguage, transcriptRoleKind, - type TranscriptRoleKind, formatBytes, formatMessageOffset, formatMessageTimestamp, formatMs, - formatUsage, + formatTurnDuration, requesterLabel, + summarizeMessages, + summarizeToolCalls, + summarizeUsage, + turnMessageCount, stringifyPartValue, unavailableTranscriptLabel, visualStatusForSession, @@ -24,6 +31,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 { @@ -40,6 +54,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; @@ -148,22 +171,10 @@ function TurnHeader(props: { number: number; turn: ConversationTurn }) {
Turn {props.number}
-
- {turnMeta(props.turn).join(" · ")} - {props.turn.sentryTraceUrl ? ( - <> - {" · "} - - View in Sentry - - - ) : null} -
+
@@ -175,10 +186,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 ? ( @@ -208,31 +230,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; @@ -297,6 +475,31 @@ function RedactedMarker() { ); } +function RedactedThinkingView(props: { + timestamp?: number; + turn: ConversationTurn; +}) { + const offset = formatMessageOffset(props.turn, props.timestamp); + const meta = [ + typeof props.timestamp === "number" + ? formatMessageTimestamp(props.timestamp) + : undefined, + offset, + ].filter(isString); + + return ( +
+
+ thought + + + {meta.join(" · ")} + +
+
+ ); +} + function RedactedToolView(props: { call?: TranscriptPart; result?: TranscriptPart; @@ -316,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); @@ -327,7 +532,7 @@ function RedactedToolView(props: { raw signature={ <> - + {toolName} {props.call?.inputKeys?.length ? ( @@ -347,16 +552,70 @@ function redactedMessageSize(part: TranscriptPart): string | undefined { } function turnActorLabel(turn: ConversationTurn): string { - return ( - requesterLabel(turn.requesterIdentity, turn.requester) ?? "unknown actor" - ); + return requesterLabel(turn.requesterIdentity) ?? "User"; +} + +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): string[] { - return [ - formatMs(turn.cumulativeDurationMs), - formatUsage(turn.cumulativeUsage), - ].filter((value) => value && value !== "none"); +function turnMeta(turn: ConversationTurn): MetricListItem[] { + const duration = formatTurnDuration(turn); + 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, + turn.sentryTraceUrl + ? { + content: ( + + View in Sentry + + ), + key: "sentry", + } + : undefined, + ]; + + return items.filter((item): item is MetricListItem => Boolean(item)); } /** @@ -384,15 +643,15 @@ function SystemMessageView(props: { return (
{ if (event.currentTarget !== event.target) return; setOpen(event.currentTarget.open); }} open={open} > - -
+ +
{transcriptRoleLabel(role, props.turn)} @@ -532,9 +791,9 @@ function TranscriptPartView(props: { const rendered = stringifyPartValue(value); return (
- + {part.type} - + {part.name ?? part.id ?? "unknown"} @@ -546,32 +805,49 @@ 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 = [ + typeof props.timestamp === "number" + ? formatMessageTimestamp(props.timestamp) + : undefined, + offset, + ].filter(isString); 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 7ce7393a..ec64952d 100644 --- a/packages/junior-dashboard/src/client/components/TurnDurationChart.tsx +++ b/packages/junior-dashboard/src/client/components/TurnDurationChart.tsx @@ -1,3 +1,4 @@ +import { useMemo, useState, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; import { useNavigate } from "react-router"; import { @@ -12,20 +13,41 @@ import { import { readConversationData } from "../api"; import { + buildConversations, + conversationDisplayTitle, + conversationRequesterLabel, conversationIdForSession, conversationPath, - formatMs, - formatTokenTotal, + formatConversationDuration, + formatDurationTick, + formatTurnDuration, requesterLabel, slackLocationLabel, - turnToolCallCount, + summarizeMessages, + summarizeToolCalls, + summarizeUsage, + turnElapsedDurationMs, 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 +55,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 +63,18 @@ 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 = useMemo( + () => (mode === "conversations" ? buildConversations(props.sessions) : []), + [mode, 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 +93,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 +143,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 +152,7 @@ export function TurnDurationChart(props: { }} tickLine={false} type="number" + width={48} /> } @@ -131,7 +168,7 @@ export function TurnDurationChart(props: {
- {totals.total} turns / {totals.hung} hung / {totals.failed} errors + {totals.total} {modeLabel} / {totals.hung} hung / {totals.failed} errors
); @@ -147,38 +184,120 @@ function ChartLegendItem(props: { className: string; label: string }) { } type PlottedTurnStatus = Exclude; +type DurationChartMode = "conversations" | "turns"; -type TurnDurationPoint = { +type DurationPoint = { + conversation?: Conversation; + conversationId: string; + durationLabel: string; durationMs: number; - tooltipLabel: string; - session: Session; + endedAt: string; + kind: DurationChartMode; + session?: Session; + startedAt: string; status: PlottedTurnStatus; + title: string; + tooltipLabel: string; x: number; }; -function turnPoint( - session: Session, - timeZone: string, -): TurnDurationPoint | null { - const startedAtMs = Date.parse(session.startedAt ?? ""); +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; } - const lastSeenAtMs = Date.parse(session.lastSeenAt ?? ""); - const durationMs = - session.cumulativeDurationMs ?? - (Number.isFinite(lastSeenAtMs) - ? Math.max(0, lastSeenAtMs - startedAtMs) - : 0); + const durationMs = turnElapsedDurationMs(session); + if (durationMs === undefined) { + return null; + } return { + conversationId: conversationIdForSession(session), + durationLabel: formatTurnDuration(session), durationMs, + endedAt: session.completedAt ?? session.lastSeenAt, + kind: "turns", session, + startedAt: session.startedAt, status, + 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); + if (!Number.isFinite(lastSeenAtMs)) { + return null; + } + const durationMs = Math.max(0, lastSeenAtMs - startedAtMs); + + return { + conversation, + conversationId: conversation.id, + durationLabel: formatConversationDuration(conversation), + durationMs, + endedAt: conversation.lastSeenAt, + kind: "conversations", + startedAt: conversation.startedAt, + status, + title: conversationDisplayTitle(conversation), tooltipLabel: new Date(startedAtMs).toLocaleString(undefined, { timeZone, }), @@ -189,13 +308,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 +321,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 +370,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,51 +405,90 @@ function TurnDurationTooltip(props: { ); } -function turnTooltipRows( - point: TurnDurationPoint, +function chartTooltipRows( + point: DurationPoint, detail: ConversationDetailFeed | undefined, -): Array<[string, string]> { - const session = point.session; - const requester = requesterLabel( - session.requesterIdentity, - session.requester, +): Array<[string, ReactNode]> { + const session = point.session ?? point.conversation; + const requester = + point.kind === "conversations" + ? conversationRequesterLabel(point.conversation) + : requesterLabel(session?.requesterIdentity); + 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 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 messageSummary = detail ? summarizeMessages(turns) : undefined; + const toolSummary = detail ? summarizeToolCalls(turns) : undefined; + const rows: Array<[string, ReactNode] | null> = [ + [ + "duration", + , + ], + tokenSummary + ? ["tokens", ] + : null, + [ + "messages", + detail ? : "loading", + ], + !detail || (toolSummary && toolSummary.total > 0) + ? [ + "tool calls", + detail ? ( + + ) : ( + "loading" + ), + ] + : null, 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 { - return ( - session.conversationTitle ?? - session.title ?? - conversationIdForSession(session) - ); + return session.conversationTitle ?? session.title; } -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/components/transcriptRenderModel.ts b/packages/junior-dashboard/src/client/components/transcriptRenderModel.ts index 758534df..98edef4a 100644 --- a/packages/junior-dashboard/src/client/components/transcriptRenderModel.ts +++ b/packages/junior-dashboard/src/client/components/transcriptRenderModel.ts @@ -1,23 +1,58 @@ import { countStructuredBlockChildren } from "../code"; -import { parseMarkdownBlocks, stringifyPartValue, transcriptRoleKind } from "../format"; +import { + parseMarkdownBlocks, + stringifyPartValue, + transcriptRoleKind, +} from "../format"; +import { sameToolInvocation } from "../toolInvocations"; 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 } + | RenderedThinkingEntry | RenderedToolEntry; -type RenderedToolEntry = { - call?: TranscriptPart; - kind: "tool"; - result?: TranscriptPart; - resultTimestamp?: number; +type RenderedThinkingEntry = { + kind: "thinking"; + part: TranscriptPart; 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"; function isToolCall(part: TranscriptPart): boolean { @@ -28,17 +63,12 @@ function isToolResult(part: TranscriptPart): boolean { return part.type === "tool_result"; } -function isString(value: string | undefined): value is string { - return typeof value === "string" && value.length > 0; +function isThinking(part: TranscriptPart): boolean { + return part.type === "thinking"; } -function sameToolInvocation( - call: TranscriptPart, - result: TranscriptPart, -): boolean { - if (call.id && result.id) return call.id === result.id; - if (call.name && result.name) return call.name === result.name; - return false; +function isString(value: string | undefined): value is string { + return typeof value === "string" && value.length > 0; } /** Group inline transcript parts so matching tool calls/results render together. */ @@ -83,11 +113,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; } } @@ -138,6 +168,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/src/client/format.ts b/packages/junior-dashboard/src/client/format.ts index 8064a937..33e4a177 100644 --- a/packages/junior-dashboard/src/client/format.ts +++ b/packages/junior-dashboard/src/client/format.ts @@ -8,9 +8,12 @@ import type { RequesterIdentity, Session, SessionFilter, + TranscriptMessage, + TranscriptPart, TurnUsage, VisualStatus, } from "./types"; +import { sameToolInvocation } from "./toolInvocations"; let dashboardTimeZone = "America/Los_Angeles"; @@ -24,7 +27,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. */ @@ -99,11 +102,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, @@ -250,66 +264,261 @@ 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; + 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 totalUsageTokens(usage: TurnUsage | undefined): number | undefined { - if (!usage) return undefined; - return ( - getUsageComponentTotal(usage) ?? getFiniteTokenCount(usage.totalTokens) - ); +function findPendingToolCallIndex( + calls: PendingToolCall[], + result: TranscriptPart, +): number { + for (let index = calls.length - 1; index >= 0; index -= 1) { + if (sameToolInvocation(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) ?? "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); } -/** 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`; +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; +} + +/** Format a summarized token counter for compact metadata. */ +export function formatTokenSummary( + summary: TokenUsageSummary | undefined, +): string { + return summary ? `${formatNumber(summary.totalTokens)} tokens` : ""; } /** 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`; -} - -/** 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 pieces = [ - usage?.inputTokens !== undefined - ? `${formatNumber(usage.inputTokens)} in` - : undefined, - usage?.outputTokens !== undefined - ? `${formatNumber(usage.outputTokens)} out` - : undefined, - usage?.cachedInputTokens !== undefined - ? `${formatNumber(usage.cachedInputTokens)} cached` - : undefined, - usage?.cacheCreationTokens !== undefined - ? `${formatNumber(usage.cacheCreationTokens)} cache-write` - : undefined, - ].filter(Boolean); - return pieces.length > 0 - ? `${formatNumber(total)} tokens (${pieces.join(" / ")})` - : `${formatNumber(total)} tokens`; + 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); - const end = parseTime(conversation.lastSeenAt) ?? Date.now(); - 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); @@ -319,7 +528,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 { @@ -330,7 +539,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 }) ?? @@ -351,15 +580,18 @@ 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 { - return ( - requester?.email ?? - requester?.slackUserName ?? - requester?.fullName ?? - fallback ?? - requester?.slackUserId - ); + const email = requester?.email?.trim() || undefined; + const fullName = requester?.fullName?.trim() || undefined; + const slackUserName = requester?.slackUserName?.trim() || undefined; + return email ?? fullName ?? slackUserName ?? requester?.slackUserId; +} + +/** Derive the conversation owner label from structured requester identity. */ +export function conversationRequesterLabel( + conversation: Conversation | undefined, +): string | undefined { + return requesterLabel(conversation?.requesterIdentity); } /** Format the owner and permalink id line shared by conversation rows and headers. */ @@ -367,20 +599,15 @@ export function conversationIdentityMeta( conversation: Conversation | undefined, conversationId: string | undefined, ): string { - const id = conversationId ?? "missing conversation id"; - const owner = requesterLabel( - conversation?.requesterIdentity, - conversation?.requester, - ); + const id = conversationId ?? conversation?.id; + const owner = conversationRequesterLabel(conversation); + if (!id) return owner ?? ""; return owner ? `${owner} · ${id}` : id; } /** 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; @@ -675,12 +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) => - compareTimeDesc( - a.lastSeenAt ?? a.startedAt, - b.lastSeenAt ?? b.startedAt, - ), - )[0]!; + const recentTurns = [...turns].sort((a, b) => + compareTimeDesc(a.lastSeenAt, b.lastSeenAt), + ); + const newest = recentTurns[0]!; const oldest = sortedTurns.reduce((current, next) => (parseTime(next.startedAt) ?? Number.MAX_SAFE_INTEGER) < (parseTime(current.startedAt) ?? Number.MAX_SAFE_INTEGER) @@ -694,28 +919,25 @@ 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 = recentTurns.find( + (turn) => turn.conversationTitle, + )?.conversationTitle; return { channel: newest.channel, - channelName: sortedTurns.find((turn) => turn.channelName)?.channelName, - conversationTitle: sortedTurns.find((turn) => turn.conversationTitle) - ?.conversationTitle, + channelName: recentTurns.find((turn) => turn.channelName)?.channelName, + conversationTitle, id, + lastProgressAt: newest.lastProgressAt, lastSeenAt: newest.lastSeenAt, - requester: requesterLabel( - requesterTurn?.requesterIdentity, - requesterTurn?.requester, - ), requesterIdentity: requesterTurn?.requesterIdentity, sentryConversationUrl: newest.sentryConversationUrl, sentryTraceUrl: newest.sentryTraceUrl, 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/pages/ConversationPage.tsx b/packages/junior-dashboard/src/client/pages/ConversationPage.tsx index 29fdd127..6a4732a9 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,78 @@ 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 turnCount = props.conversation.turns.length; + const rawStats: Array = [ + location + ? { + content: location, + key: "location", + } + : undefined, + { + content: `${turnCount} ${turnCount === 1 ? "turn" : "turns"}`, + key: "turns", + }, + { + content: ( + + ), + key: "messages", + }, + !props.detail || (toolSummary && toolSummary.total > 0) + ? { + content: ( + + ), + key: "tools", + } + : undefined, + 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/toolInvocations.ts b/packages/junior-dashboard/src/client/toolInvocations.ts new file mode 100644 index 00000000..67352936 --- /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; +} diff --git a/packages/junior-dashboard/src/client/types.ts b/packages/junior-dashboard/src/client/types.ts index 56c9ede4..87233a27 100644 --- a/packages/junior-dashboard/src/client/types.ts +++ b/packages/junior-dashboard/src/client/types.ts @@ -1,119 +1,59 @@ import type { BundledLanguage } from "shiki/bundle/web"; +import type { + DashboardConversationReport, + DashboardRequesterIdentity, + DashboardSessionFeed, + DashboardSessionReport, + DashboardTurnReport, + DashboardTurnUsage, + 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 = DashboardRequesterIdentity; -export type TurnUsage = { - cachedInputTokens?: number; - cacheCreationTokens?: number; - inputTokens?: number; - outputTokens?: number; - totalTokens?: number; -}; +export type TurnUsage = DashboardTurnUsage; -export type Session = { - channel?: string; - channelName?: 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; channelName?: string; conversationTitle?: string; id: string; - lastProgressAt?: string; - lastSeenAt?: string; - requester?: 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[]; }; -export type SessionFeed = { - generatedAt?: string; - sessions: Session[]; - source: string; -}; +export type SessionFeed = DashboardSessionFeed; export type Identity = { user: { email?: string; hostedDomain?: string } }; @@ -141,7 +81,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 5c86c62a..eb14241b 100644 --- a/packages/junior-dashboard/tests/format.test.ts +++ b/packages/junior-dashboard/tests/format.test.ts @@ -1,14 +1,24 @@ import { describe, expect, it } from "vitest"; import { + buildConversations, canRenderStructuredMarkup, + conversationDisplayTitle, + conversationIdentityMeta, + conversationRequesterLabel, + formatConversationDuration, formatDurationTotal, - formatTokenTotal, + formatDurationTick, + formatTurnDuration, formatUsageTotal, parseMarkdownBlocks, + requesterLabel, + 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", () => { @@ -26,21 +36,26 @@ 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"); }); + 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("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", @@ -72,6 +87,296 @@ describe("dashboard token formatting", () => { expect(turnMessageCount(turn)).toBe(2); }); + + it("summarizes tooltip metrics from visible transcripts", () => { + const turn = { + id: "turn-1", + requesterIdentity: { fullName: "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("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 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[] = [ + { + 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", + slackUserName: "Alice Reviewer", + }, + startedAt: "2026-06-01T10:00:00.000Z", + status: "completed", + surface: "slack", + title: "Turn turn-1", + }, + ]; + const [conversation] = buildConversations(sessions); + + expect(conversationDisplayTitle(conversation)).toBe("Public Channel"); + expect(conversationIdentityMeta(conversation, conversation?.id)).toBe( + "Alice Reviewer · slack:C1:123", + ); + }); + + 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" }), + ).toBe("Alice Reviewer"); + }); + + it("keeps meaningful conversation titles that start with turn", () => { + const [conversation] = buildConversations([ + { + channel: "C1", + 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", + surface: "slack", + title: "Turn around the API design", + }, + ]); + + expect(conversationDisplayTitle(conversation)).toBe( + "Turn around the API design", + ); + }); + + 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[] = [ + { + channel: "C1", + channelName: "alice", + 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", + slackUserId: "U1", + }, + startedAt: "2026-06-01T10:00:00.000Z", + status: "completed", + surface: "slack", + title: "Turn turn-1", + }, + ]; + const [conversation] = buildConversations(sessions); + + expect(conversationRequesterLabel(conversation)).toBe("alice"); + expect(conversationIdentityMeta(conversation, conversation?.id)).toBe( + "alice · slack:C1:123", + ); + }); + + it("formats conversation spans with the compact conversation duration rules", () => { + const [conversation] = buildConversations([ + { + 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", + surface: "slack", + title: "Turn turn-1", + }, + ]); + + expect(formatConversationDuration(conversation!)).toBe("2m"); + }); + + 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", + surface: "slack", + title: "Turn turn-1", + }, + ]); + + expect(formatConversationDuration(conversation!)).toBe("none"); + }); }); describe("parseMarkdownBlocks prose language detection", () => { 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 00000000..8487e891 --- /dev/null +++ b/packages/junior-dashboard/tests/telemetry-components.test.tsx @@ -0,0 +1,148 @@ +import { renderToStaticMarkup } from "react-dom/server"; +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 { 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", () => { + 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(""); + }); + + 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); + }); +}); diff --git a/packages/junior-dashboard/tests/transcriptRenderModel.test.ts b/packages/junior-dashboard/tests/transcriptRenderModel.test.ts new file mode 100644 index 00000000..76fa8651 --- /dev/null +++ b/packages/junior-dashboard/tests/transcriptRenderModel.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; + +import { + groupTranscriptMessages, + groupTranscriptParts, +} 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" }], + }, + }, + ]); + }); + + 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" }, + }, + ]); + }); +}); diff --git a/packages/junior/src/reporting.ts b/packages/junior/src/reporting.ts index cd34acc3..788c0dec 100644 --- a/packages/junior/src/reporting.ts +++ b/packages/junior/src/reporting.ts @@ -55,21 +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; - requester?: string; - requesterIdentity?: AgentTurnRequester; + surface: DashboardSurface; + title: string; + requesterIdentity?: DashboardRequesterIdentity; channel?: string; channelName?: string; sentryConversationUrl?: string; @@ -77,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; @@ -93,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; } @@ -200,9 +239,7 @@ function statusFromCheckpoint( return state; } -function surfaceFromConversationId( - conversationId: string, -): DashboardSessionReport["surface"] { +function surfaceFromConversationId(conversationId: string): DashboardSurface { return parseSlackThreadId(conversationId) ? "slack" : "internal"; } @@ -213,18 +250,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.slackUserName ?? - requester.fullName ?? - requester.slackUserId - ); -} - function safePrivateLabel(summary: AgentTurnSessionSummary): string { const slackThread = parseSlackThreadId(summary.conversationId); if (slackThread?.channelId.startsWith("D")) { @@ -238,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 { @@ -249,13 +317,14 @@ 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, ); const sentryTraceUrl = summary.traceId ? buildSentryTraceUrl(summary.traceId) : undefined; + const requesterIdentity = requesterIdentityReport(summary.requester); + const cumulativeUsage = turnUsageReport(summary.cumulativeUsage); return { conversationId: summary.conversationId, ...(conversationTitle ? { conversationTitle } : {}), @@ -270,13 +339,10 @@ function sessionReportFromSummary( ...(summary.cumulativeDurationMs !== undefined ? { cumulativeDurationMs: summary.cumulativeDurationMs } : {}), - ...(summary.cumulativeUsage - ? { cumulativeUsage: summary.cumulativeUsage } - : {}), + ...(cumulativeUsage ? { cumulativeUsage } : {}), surface: surfaceFromConversationId(summary.conversationId), title: titleFromSummary(summary), - ...(requester ? { requester } : {}), - ...(summary.requester ? { requesterIdentity: summary.requester } : {}), + ...(requesterIdentity ? { requesterIdentity } : {}), ...(slackThread ? { channel: slackThread.channelId } : {}), ...(channelName ? { channelName } : {}), ...(sentryConversationUrl ? { sentryConversationUrl } : {}), @@ -345,7 +411,8 @@ function normalizeTranscriptPart(part: unknown): DashboardTranscriptPart { } return { - type: rawType, + type: "unknown", + ...(rawType !== "unknown" ? { sourceType: rawType } : {}), output: part, }; } @@ -381,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" @@ -396,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; @@ -468,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), }; } @@ -486,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 { @@ -501,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; } diff --git a/packages/junior/tests/integration/dashboard-reporting.test.ts b/packages/junior/tests/integration/dashboard-reporting.test.ts index 2094fd8c..4a884272 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");