Skip to content

Commit e8e8d1b

Browse files
phodalclaude
andcommitted
feat(studio): line Performance intervals up with the retained conversation
The Conversation pane shipped in dd71048 without its stylesheet or the Sessions-side reuse, so it rendered unstyled and the two surfaces still drew the same event list twice. Restore both, and make the pairing real. Correspondence was keyed on the recorded invocation id, which does not survive into a Session projection: tool-activity.mjs reduces every call to a step index (A1, A2, ...) on purpose, so the timing and dialogue readings shared no key and every call rendered unlinked. Both readings do take a call's start instant from the same record, so carry that instant through the projection as startedAtMs and pair on it. Verified against a live workspace: 257 retained calls, 257 exact millisecond matches, and 291/291 calls linked in the pane. Selecting a call reveals its interval and its source records; selecting an interval scrolls the conversation to the call that produced it. A Session with timing but no retained conversation states that absence instead of reading as a failed request. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 26beecc commit e8e8d1b

7 files changed

Lines changed: 58 additions & 39 deletions

File tree

‎packages/harness-studio/scripts/inspector-workspace-provider.mjs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,11 @@ function debuggerProjection(summary, identity) {
209209
input: safeDetail(call.detail) || "Input not retained in the privacy-safe Inspector projection.",
210210
output: safeDetail(call.output) || (call.status === "failed" ? "Inspector observed a failed call." : "Result payload not retained in the summary projection."),
211211
duration: Number.isFinite(call.durationMs) ? `${call.durationMs} ms` : "not retained",
212+
// Invocation ids are deliberately reduced to a step index before they
213+
// reach this projection, so the observed start instant is the only key
214+
// that still identifies this call in another reading of the same
215+
// evidence. It is the record's own timestamp, not new information.
216+
...(Number.isFinite(call.startedAt) ? { startedAtMs: Math.round(call.startedAt) } : {}),
212217
...(resource === undefined ? {} : { resource }),
213218
}));
214219
events.push(debuggerEvent({

‎packages/harness-studio/src/app/App.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { MemoryView } from "./MemoryView.js";
1818
import { ExperimentView } from "./experiment/ExperimentView.js";
1919
import { GitHistoryView } from "./GitHistoryView.js";
2020
import { RunView } from "./run/RunView.js";
21+
import { SessionTranscript } from "./session/SessionTranscript.js";
2122
import {
2223
isArtifactCatalogResponse,
2324
type ArtifactDescriptor,
@@ -1152,7 +1153,7 @@ function SessionDetail({ session, artifactContext }: { session: DebuggerSession;
11521153
onDoubleClick={() => openArtifact(artifact)}
11531154
><span><strong>{artifact.label}</strong><small>{artifact.format.toUpperCase()} · {t("detail.exactRevision", { id: artifact.revision.id.slice(0, 18) })}</small></span><em>{artifact.renderer.status === "ready" ? artifact.renderer.label : t("detail.previewUnavailable")}</em></button></li>)}</ul>}
11541155
</section>
1155-
<ol className="session-event-rows">{session.events.map((event) => <li key={event.id}><time>{event.timestamp}</time><span><strong>{event.phase} · {event.title}</strong><small>{event.summary}</small></span>{event.toolCalls && <em>{event.toolCalls.map((tool) => tool.name).join(", ")}</em>}</li>)}</ol>
1156+
<SessionTranscript events={session.events} />
11561157
</section>
11571158
<aside className="session-artifact-preview" aria-label={t("detail.artifactViewAria")}>
11581159
{activeArtifact === undefined || artifactContext == null

‎packages/harness-studio/src/app/performance/SessionPerformanceWorkspace.tsx‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -98,14 +98,14 @@ export default function SessionPerformanceWorkspace({ config, dateRange }: { con
9898
// The recorded invocation id is the only key both projections share, so it is
9999
// what makes a timing interval and a retained call the same observation.
100100
const spansByCall = useMemo(() => {
101-
const index = new Map<string, string>();
101+
const index = new Map<number, string>();
102102
for (const span of detail?.spans ?? []) {
103-
const call = span.facts.toolCallId;
104-
if (typeof call === 'string' && call !== '' && !index.has(call)) index.set(call, span.id);
103+
if (!['tool', 'shell', 'subagent'].includes(span.kind) || span.startMs === null) continue;
104+
if (!index.has(span.startMs)) index.set(span.startMs, span.id);
105105
}
106106
return index;
107107
}, [detail]);
108-
const activeCallId = typeof selectedSpan?.facts.toolCallId === 'string' ? selectedSpan.facts.toolCallId : undefined;
108+
const activeCallStartMs = selectedSpan?.startMs ?? undefined;
109109
const toggleTranscript = (): void => setTranscriptOpen(open => { saveFilter('transcript', open ? '' : 'open'); return !open; });
110110
const start = selectedTurn?.startMs ?? detail?.session.firstSeenMs ?? 0;
111111
const end = selectedTurn?.endMs ?? detail?.session.lastActivityMs ?? detail?.session.lastSeenMs ?? start;
@@ -159,9 +159,9 @@ export default function SessionPerformanceWorkspace({ config, dateRange }: { con
159159
</main>
160160
{transcriptShown && <SessionTranscriptPane
161161
sessionId={selectedId}
162-
{...(activeCallId === undefined ? {} : { activeToolCallId: activeCallId })}
163-
linkedToolCallIds={new Set(spansByCall.keys())}
164-
onSelectToolCall={id => { const span = spansByCall.get(id); if (span) selectSpan(span); }}
162+
{...(activeCallStartMs === undefined ? {} : { activeCallStartMs })}
163+
linkedCallStartMs={new Set(spansByCall.keys())}
164+
onSelectCall={at => { const span = spansByCall.get(at); if (span) selectSpan(span); }}
165165
onClose={toggleTranscript}
166166
/>}
167167
{selectedSpan && <aside className="performance-evidence" aria-label={t('evidence')} tabIndex={-1} ref={evidenceRef} onKeyDown={event => { if (event.key === 'Escape') { event.preventDefault(); closeEvidence(); } }}>

‎packages/harness-studio/src/app/performance/SessionTranscriptPane.tsx‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ import type { DebuggerSession } from '../../contracts/debugger-session.js';
1212
*/
1313
export function SessionTranscriptPane(props: {
1414
sessionId: string;
15-
activeToolCallId?: string;
16-
linkedToolCallIds: ReadonlySet<string>;
17-
onSelectToolCall: (toolCallId: string) => void;
15+
activeCallStartMs?: number;
16+
linkedCallStartMs: ReadonlySet<number>;
17+
onSelectCall: (startedAtMs: number) => void;
1818
onClose: () => void;
1919
}): React.JSX.Element {
2020
const { t } = useTranslation('performance');
@@ -45,9 +45,9 @@ export function SessionTranscriptPane(props: {
4545
<p className="performance-note">{t('transcriptSummary', { agent: session.agent, events: session.events.length })}</p>
4646
<SessionTranscript
4747
events={session.events}
48-
{...(props.activeToolCallId === undefined ? {} : { activeToolCallId: props.activeToolCallId })}
49-
linkedToolCallIds={props.linkedToolCallIds}
50-
onSelectToolCall={props.onSelectToolCall}
48+
{...(props.activeCallStartMs === undefined ? {} : { activeCallStartMs: props.activeCallStartMs })}
49+
linkedCallStartMs={props.linkedCallStartMs}
50+
onSelectCall={props.onSelectCall}
5151
/>
5252
</>}
5353
</aside>;

‎packages/harness-studio/src/app/session/SessionTranscript.tsx‎

Lines changed: 17 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,52 +7,45 @@ import type { DebuggerEvent } from "../../contracts/debugger-session.js";
77
*
88
* Timing and dialogue are two projections of one Session, so this list is the
99
* single place either surface renders it. A call the reader selected elsewhere
10-
* is addressed by the invocation id both projections recorded, which is why a
11-
* timing interval and the message that produced it line up exactly instead of
12-
* being matched by position or by a clock.
10+
* is addressed by the instant the evidence recorded for it: invocation ids are
11+
* reduced to a step index before a Session is projected, while both readings
12+
* take the start instant from the same record, so it lines a call up with its
13+
* interval exactly rather than by position or by tool name.
1314
*/
1415
export function SessionTranscript(props: {
1516
events: DebuggerEvent[];
16-
/** Recorded invocation id to reveal; scrolled into view when it changes. */
17-
activeToolCallId?: string;
18-
onSelectToolCall?: (toolCallId: string) => void;
19-
/** Invocation ids this reader can follow back to a timing interval. */
20-
linkedToolCallIds?: ReadonlySet<string>;
17+
/** Recorded start instant to reveal; scrolled into view when it changes. */
18+
activeCallStartMs?: number;
19+
onSelectCall?: (startedAtMs: number) => void;
20+
/** Start instants this reader can follow back to a timing interval. */
21+
linkedCallStartMs?: ReadonlySet<number>;
2122
}): React.JSX.Element {
2223
const { t } = useTranslation("sessions");
2324
const active = useRef<HTMLLIElement>(null);
2425
useEffect(() => {
25-
if (props.activeToolCallId === undefined) return;
26+
if (props.activeCallStartMs === undefined) return;
2627
active.current?.scrollIntoView({ block: "center", behavior: "smooth" });
27-
}, [props.activeToolCallId]);
28+
}, [props.activeCallStartMs]);
2829
return <ol className="session-event-rows">{props.events.map((event) => {
2930
const calls = event.toolCalls ?? [];
30-
const matched = calls.some((call) => toolCallIds(call).includes(props.activeToolCallId ?? ""));
31-
return <li key={event.id} ref={matched ? active : undefined} aria-current={matched ? "true" : undefined} className={matched ? "matched" : undefined}>
31+
const matched = calls.some((call) => call.startedAtMs !== undefined && call.startedAtMs === props.activeCallStartMs);
32+
return <li key={event.id} ref={matched ? active : undefined} aria-current={matched ? "true" : undefined}>
3233
<time>{event.timestamp}</time>
3334
<span><strong>{event.phase} · {event.title}</strong><small>{event.summary}</small></span>
3435
{calls.length > 0 && <em className="session-event-calls">{calls.map((call) => {
35-
const ids = toolCallIds(call);
36-
const linked = props.onSelectToolCall !== undefined && ids.some((id) => props.linkedToolCallIds?.has(id) ?? false);
37-
const current = ids.includes(props.activeToolCallId ?? "");
36+
const at = call.startedAtMs;
37+
const linked = props.onSelectCall !== undefined && at !== undefined && (props.linkedCallStartMs?.has(at) ?? false);
3838
return linked
3939
? <button
4040
key={call.id}
4141
type="button"
4242
className="session-event-call"
43-
aria-pressed={current}
43+
aria-pressed={at === props.activeCallStartMs}
4444
aria-label={t("transcript.revealTiming", { name: call.name })}
45-
onClick={() => props.onSelectToolCall?.(ids.find((id) => props.linkedToolCallIds?.has(id)) ?? call.id)}
45+
onClick={() => props.onSelectCall?.(at)}
4646
>{call.name}</button>
4747
: <span key={call.id} className="session-event-call">{call.name}</span>;
4848
})}</em>}
4949
</li>;
5050
})}</ol>;
5151
}
52-
53-
/** A projection may keep the recorded id, a per-resource copy of it, or both. */
54-
export function toolCallIds(call: { id: string; sourceCallId?: string }): string[] {
55-
return call.sourceCallId === undefined || call.sourceCallId === call.id
56-
? [call.id]
57-
: [call.sourceCallId, call.id];
58-
}

‎packages/harness-studio/src/app/styles/performance.css‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,22 @@ body.performance-resizing, body.performance-resizing * { user-select: none; }
6363
.performance-evidence pre { white-space: pre-wrap; overflow-wrap: anywhere; }
6464
.performance-state { padding: var(--space-xl); color: var(--color-text-muted); }
6565

66-
@media (max-width: 1200px) { .performance-catalog { flex-basis: 230px; } .performance-has-evidence .performance-catalog { display: none; } .performance-totals { grid-template-columns: repeat(2,minmax(0,1fr)); } }
66+
/* The retained conversation beside its own timing. It is a reading surface, not
67+
a second control column, so it keeps the Session list's own row rhythm and
68+
marks the matched call rather than restyling the list around it. */
69+
.performance-transcript { flex: 0 0 420px; min-width: 0; display: flex; flex-direction: column; overflow: auto; border-left: 1px solid var(--color-border); background: var(--color-panel); }
70+
.performance-transcript > .performance-toolbar { position: sticky; top: 0; z-index: 1; padding: var(--space-md); border-bottom: 1px solid var(--color-border); background: var(--color-panel); }
71+
.performance-transcript > .performance-note { margin: 0; padding: var(--space-sm) var(--space-md); border-bottom: 1px solid var(--color-border); }
72+
.performance-transcript .session-event-rows { flex: 1; }
73+
.performance-transcript .session-event-rows li[aria-current="true"] { background: var(--color-surface-selected); box-shadow: inset 2px 0 var(--color-primary); }
74+
.session-event-calls { display: flex; flex-wrap: wrap; gap: var(--space-xs); justify-content: flex-end; }
75+
.session-event-call { padding: 0 var(--space-xs); font-size: var(--type-meta-size); color: var(--color-text-muted); background: transparent; border: 1px solid transparent; border-radius: var(--radius-xs); }
76+
button.session-event-call { cursor: pointer; color: var(--color-text); border-color: var(--color-border-strong); }
77+
button.session-event-call:hover { background: var(--color-surface-hover); }
78+
button.session-event-call[aria-pressed="true"] { color: var(--color-primary); border-color: var(--color-primary); }
79+
80+
@media (max-width: 1400px) { .performance-has-transcript.performance-has-evidence .performance-catalog { display: none; } }
81+
@media (max-width: 1200px) { .performance-catalog { flex-basis: 230px; } .performance-has-evidence .performance-catalog { display: none; } .performance-totals { grid-template-columns: repeat(2,minmax(0,1fr)); } .performance-transcript { flex-basis: 340px; } }
6782
@media (max-width: 700px) {
6883
.performance-sash { display: none; }
6984
.performance-header { padding: var(--space-md); align-items: start; }
@@ -75,6 +90,9 @@ body.performance-resizing, body.performance-resizing * { user-select: none; }
7590
.performance-has-session .performance-analysis { display: block; }
7691
.performance-has-evidence .performance-catalog, .performance-has-evidence .performance-analysis { display: none; }
7792
.performance-evidence { flex: 1; border: 0; }
93+
.performance-has-transcript .performance-catalog { display: none; }
94+
.performance-has-transcript.performance-has-evidence .performance-transcript { display: none; }
95+
.performance-transcript { flex: 1; border: 0; }
7896
.performance-back, .performance-evidence-back { display: inline-flex; margin-bottom: var(--space-md); }
7997
.performance-span { grid-template-columns: minmax(90px,1fr) minmax(40px,0.7fr) 65px; }
8098
.performance-pager { flex-wrap: wrap; }

‎packages/harness-studio/src/contracts/debugger-session.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ export interface DebuggerToolCall {
1313
input: string;
1414
output: string;
1515
duration: string;
16+
/** Observed start instant, when the evidence recorded one. */
17+
startedAtMs?: number;
1618
resource?: string;
1719
}
1820

0 commit comments

Comments
 (0)