Skip to content

Commit 01b2bad

Browse files
MariefayTrigger.dev RepoOps
authored andcommitted
fix(webapp): keep both session tabs mounted so Conversation keeps every turn
Behind a feature flag, switching between the session Trace and Conversation tabs keeps each tab as it was: the conversation no longer misses answers sent while Trace was shown, and Trace keeps its expanded runs, scroll and search without loading them again. Mono-RevId: 304506709757f6483a0d4d25f90cf508b35b0ab7
1 parent a1e84f0 commit 01b2bad

9 files changed

Lines changed: 220 additions & 50 deletions

File tree

‎apps/webapp/app/components/runs/v3/agent/AgentView.tsx‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,14 @@ const INITIAL_PAYLOAD_TIMESTAMP = 0;
8080
* Intended to be mounted inside a scrollable container — the component
8181
* does not own its own scrollbar.
8282
*/
83-
export function AgentView({ agentView }: { agentView: AgentViewAuth }) {
83+
export function AgentView({
84+
agentView,
85+
paused = false,
86+
}: {
87+
agentView: AgentViewAuth;
88+
/** Hidden: the streams keep reading, but the messages render once it's shown again. */
89+
paused?: boolean;
90+
}) {
8491
const organization = useOrganization();
8592
const project = useProject();
8693
const environment = useEnvironment();
@@ -93,6 +100,7 @@ export function AgentView({ agentView }: { agentView: AgentViewAuth }) {
93100
envSlug: environment.slug,
94101
initialMessages: agentView.initialMessages,
95102
transcriptSeed: agentView.transcriptSeed,
103+
paused,
96104
});
97105

98106
// Sticky-bottom auto-scroll: walks up to find the inspector's scroll
@@ -245,6 +253,7 @@ function useAgentSessionMessages({
245253
envSlug,
246254
initialMessages,
247255
transcriptSeed,
256+
paused,
248257
}: {
249258
sessionId: string;
250259
apiOrigin: string;
@@ -253,6 +262,7 @@ function useAgentSessionMessages({
253262
envSlug: string;
254263
initialMessages: UIMessage[];
255264
transcriptSeed?: TranscriptSeed;
265+
paused: boolean;
256266
}): {
257267
messages: UIMessage[];
258268
hasEarlier: boolean;
@@ -316,8 +326,14 @@ function useAgentSessionMessages({
316326
const lastFlushAtRef = useRef<number>(0);
317327
const pendingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
318328
const scheduleFlush = useRef<() => void>(() => {});
329+
const pausedRef = useRef(paused);
330+
const flushOnResumeRef = useRef(false);
319331

320332
scheduleFlush.current = () => {
333+
if (pausedRef.current) {
334+
flushOnResumeRef.current = true;
335+
return;
336+
}
321337
if (pendingTimerRef.current !== null) return; // already scheduled
322338
const now = Date.now();
323339
const sinceLast = now - lastFlushAtRef.current;
@@ -329,6 +345,13 @@ function useAgentSessionMessages({
329345
}, delay);
330346
};
331347

348+
useEffect(() => {
349+
pausedRef.current = paused;
350+
if (paused || !flushOnResumeRef.current) return;
351+
flushOnResumeRef.current = false;
352+
scheduleFlush.current();
353+
}, [paused]);
354+
332355
useEffect(() => {
333356
const abort = new AbortController();
334357

‎apps/webapp/app/components/runs/v3/trace/useRunTrace.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ export function useRunTrace({
8080
canFetchPage,
8181
onLoadedSpans,
8282
retryKey,
83+
paused = false,
8384
}: {
8485
run: RunTraceRun;
8586
trace: RunTraceInput;
@@ -119,6 +120,7 @@ export function useRunTrace({
119120
canFetchPage,
120121
onLoadedSpans,
121122
retryKey,
123+
paused,
122124
});
123125

124126
const traceIsTruncated = isTruncated || progressiveIsTruncated;
@@ -166,8 +168,10 @@ export function useRunTrace({
166168

167169
// The callback's identity is unstable, so effects read it from a ref.
168170
const onRunDataStaleRef = useRef(onRunDataStale);
171+
const pausedRef = useRef(paused);
169172
useEffect(() => {
170173
onRunDataStaleRef.current = onRunDataStale;
174+
pausedRef.current = paused;
171175
});
172176

173177
const streamedEvents = useEventSource(
@@ -207,7 +211,7 @@ export function useRunTrace({
207211
useRunStatusBackstop({
208212
enabled: live && liveTailEnabled && runDataUnfinished,
209213
statusPath,
210-
shouldSkip: () => document.visibilityState === "hidden",
214+
shouldSkip: () => document.visibilityState === "hidden" || pausedRef.current,
211215
onStatus: (data) => {
212216
if (data.isFinished || data.completedAt !== null) {
213217
// The reload alone won't refresh the tree if the span count didn't change.

‎apps/webapp/app/components/sessions/v1/trace/RunTraceSource.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ type RunTraceSourceProps = {
3030
run: SessionTraceRun & { status: TaskRunStatus };
3131
load: SessionRunLoad;
3232
live: boolean;
33+
/** The tab is hidden: live tails wait until it's shown again. */
34+
paused: boolean;
3335
/** Errors only is on: a run that isn't fully loaded also reads its errors from the server. */
3436
errorsOnly: boolean;
3537
/** A deep-linked span: the first chunk includes it and its parents even past the first page. */
@@ -80,6 +82,7 @@ function RunTraceLive({
8082
run,
8183
load,
8284
live,
85+
paused,
8386
errorsOnly,
8487
started,
8588
onSettle,
@@ -118,6 +121,7 @@ function RunTraceLive({
118121
onRunDataStale,
119122
readContext: "session",
120123
live,
124+
paused,
121125
canFetchPage: (loaded) => askPage(run.friendlyId, loaded),
122126
onLoadedSpans: (loaded) => {
123127
if (mounted.current) onLoadedSpans(run.friendlyId, loaded);

‎apps/webapp/app/components/sessions/v1/trace/SessionTraceTab.tsx‎

Lines changed: 67 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import {
66
} from "@heroicons/react/20/solid";
77
import { formatDurationMilliseconds } from "@trigger.dev/core/v3";
88
import {
9+
createContext,
910
useCallback,
11+
useContext,
1012
useEffect,
1113
useLayoutEffect,
1214
useMemo,
@@ -108,6 +110,7 @@ import {
108110
RUNS_FILL_MAX,
109111
RUNS_PAGE_SIZE,
110112
type LoadedRuns,
113+
type NewestPageReason,
111114
useSessionRunPages,
112115
} from "./useSessionRunPages";
113116
import { useSessionTraceSelection } from "./useSessionTraceSelection";
@@ -120,13 +123,19 @@ function isScrolledToBottom(element: HTMLElement) {
120123
// Like the run page: arrowing through rows doesn't write the URL on every step.
121124
const SELECTION_URL_DEBOUNCE_MS = 250;
122125

126+
// Rows' clocks stop while the tab is hidden.
127+
const TraceTabActiveContext = createContext(true);
128+
123129
/** The session's runs as one tree, newest page first, with earlier pages on request. */
124130
export function SessionTraceTab({
125131
runsPath,
126132
treeSnapshot,
133+
active,
127134
}: {
128135
runsPath: string;
129136
treeSnapshot: ResizableSnapshot | undefined;
137+
/** False while the Conversation tab is shown: the tab stays mounted but makes no requests. */
138+
active: boolean;
130139
}) {
131140
// A run in the URL when the tab opens is a deep link: its page is read with the newest one.
132141
const { selection, setSelection } = useSessionTraceSelection();
@@ -149,24 +158,33 @@ export function SessionTraceTab({
149158
// New runs: re-read the newest page while the tab is visible (focus also refreshes it). It
150159
// lives here, not in the tree, so a session with no runs yet still picks up its first one.
151160
const [checkedAtMs, setCheckedAtMs] = useState(() => Date.now());
161+
const { refreshNewest } = pages;
162+
const checkNewest = useCallback(
163+
(reason: NewestPageReason) => {
164+
setCheckedAtMs(Date.now());
165+
refreshNewest(reason);
166+
},
167+
[refreshNewest]
168+
);
152169
useInterval({
153170
interval: newRunsPollIntervalMs(pages.runs ?? [], pages.totalCount, checkedAtMs),
154-
disabled: pages.state.status !== "ready",
171+
disabled: pages.state.status !== "ready" || !active,
155172
onLoad: false,
156173
onFocus: false,
157-
callback: () => {
158-
setCheckedAtMs(Date.now());
159-
pages.refreshNewest("poll");
160-
},
174+
callback: () => checkNewest("poll"),
161175
});
162176
useInterval({
163-
disabled: pages.state.status !== "ready",
177+
disabled: pages.state.status !== "ready" || !active,
164178
onLoad: false,
165-
callback: () => {
166-
setCheckedAtMs(Date.now());
167-
pages.refreshNewest("focus");
168-
},
179+
callback: () => checkNewest("focus"),
169180
});
181+
// Shown again after Conversation: check at once instead of waiting for the next poll.
182+
const wasActive = useRef(active);
183+
useEffect(() => {
184+
const shown = active && !wasActive.current;
185+
wasActive.current = active;
186+
if (shown && pages.state.status === "ready") checkNewest("focus");
187+
}, [active, pages.state.status, checkNewest]);
170188

171189
if (pages.state.status === "loading") {
172190
return (
@@ -199,23 +217,26 @@ export function SessionTraceTab({
199217
}
200218

201219
return (
202-
<SessionTraceTree
203-
runs={pages.runs}
204-
view={pages.view}
205-
totalCount={pages.totalCount}
206-
canLoadEarlier={pages.canLoadEarlier}
207-
loadingEarlier={pages.loadingEarlier}
208-
earlierError={pages.earlierError}
209-
onLoadEarlier={pages.loadEarlier}
210-
olderLoadBusy={pages.olderLoadBusy}
211-
fillingRunFriendlyId={pages.fillingRunFriendlyId}
212-
fillFailedRunFriendlyId={pages.fillFailedRunFriendlyId}
213-
onFillHole={pages.fillHole}
214-
onRunDataStale={pages.refreshNewest}
215-
checkedAtMs={checkedAtMs}
216-
treeSnapshot={treeSnapshot}
217-
notice={showRunNotFound ? "That run isn't in this session." : undefined}
218-
/>
220+
<TraceTabActiveContext.Provider value={active}>
221+
<SessionTraceTree
222+
runs={pages.runs}
223+
view={pages.view}
224+
totalCount={pages.totalCount}
225+
canLoadEarlier={pages.canLoadEarlier}
226+
loadingEarlier={pages.loadingEarlier}
227+
earlierError={pages.earlierError}
228+
onLoadEarlier={pages.loadEarlier}
229+
olderLoadBusy={pages.olderLoadBusy}
230+
fillingRunFriendlyId={pages.fillingRunFriendlyId}
231+
fillFailedRunFriendlyId={pages.fillFailedRunFriendlyId}
232+
onFillHole={pages.fillHole}
233+
onRunDataStale={pages.refreshNewest}
234+
checkedAtMs={checkedAtMs}
235+
treeSnapshot={treeSnapshot}
236+
notice={showRunNotFound ? "That run isn't in this session." : undefined}
237+
active={active}
238+
/>
239+
</TraceTabActiveContext.Provider>
219240
);
220241
}
221242

@@ -238,6 +259,7 @@ function SessionTraceTree({
238259
checkedAtMs,
239260
treeSnapshot,
240261
notice,
262+
active,
241263
}: {
242264
runs: SessionTraceRun[];
243265
view: LoadedRuns["view"];
@@ -255,6 +277,7 @@ function SessionTraceTree({
255277
checkedAtMs: number;
256278
treeSnapshot: ResizableSnapshot | undefined;
257279
notice: string | undefined;
280+
active: boolean;
258281
}) {
259282
const containerRef = useRef<HTMLDivElement>(null);
260283
const parentRef = useRef<HTMLDivElement>(null);
@@ -335,6 +358,9 @@ function SessionTraceTree({
335358
loadsRef.current = sessionRunLoadsReducer(loadsRef.current, action);
336359
reactDispatchLoads(action);
337360
}, []);
361+
useEffect(() => {
362+
dispatchLoads({ type: "paused", paused: !active });
363+
}, [active, dispatchLoads]);
338364
const onSettle = useCallback(
339365
(runFriendlyId: string, loadId: number, outcome: SessionRunLoadOutcome) =>
340366
dispatchLoads({ type: "settle", runFriendlyId, loadId, outcome }),
@@ -411,7 +437,11 @@ function SessionTraceTree({
411437
// Only the live run's scale moves with the clock; the rest are fixed at page load.
412438
const [openedAtMs] = useState(() => Date.now());
413439
const [nowMs, setNowMs] = useState(openedAtMs);
414-
useInterval({ interval: 1_000, disabled: !liveRunId, callback: () => setNowMs(Date.now()) });
440+
useInterval({
441+
interval: 1_000,
442+
disabled: !liveRunId || !active,
443+
callback: () => setNowMs(Date.now()),
444+
});
415445
const settledScales = useMemo(() => {
416446
const scales = new Map<string, TraceTimelineScale>();
417447
for (const run of runs) {
@@ -869,6 +899,7 @@ function SessionTraceTree({
869899
run={{ ...run, status: run.status }}
870900
load={load}
871901
live={run.friendlyId === newestRunId}
902+
paused={!active}
872903
errorsOnly={filter.errorsOnly}
873904
selectedSpanId={
874905
deepLink?.kind === "span" && deepLink.runFriendlyId === run.friendlyId
@@ -1018,8 +1049,9 @@ function SessionTraceTree({
10181049
</ResizablePanelGroup>
10191050
<div className="flex items-center justify-between gap-2 border-t border-grid-dimmed px-4">
10201051
<div className="flex items-center gap-4 overflow-hidden">
1052+
{/* Shortcuts listen on the document, so `inert` on the hidden tab doesn't stop them. */}
10211053
<KeyboardShortcuts
1022-
enabled
1054+
enabled={active}
10231055
onExpandAll={expandAll}
10241056
onCollapseAll={collapseAll}
10251057
onToggleLevel={toggleLevel}
@@ -1450,8 +1482,13 @@ function RunLoadStatus({
14501482

14511483
// Only the live run counts up; an older unfinished run shows when it started instead.
14521484
function RunDurationLabel({ run, isLive }: { run: SessionTraceRun; isLive: boolean }) {
1485+
const active = useContext(TraceTabActiveContext);
14531486
const [nowMs, setNowMs] = useState(() => Date.now());
1454-
useInterval({ interval: 1_000, disabled: !isLive, callback: () => setNowMs(Date.now()) });
1487+
useInterval({
1488+
interval: 1_000,
1489+
disabled: !isLive || !active,
1490+
callback: () => setNowMs(Date.now()),
1491+
});
14551492

14561493
const duration = runDuration(run, nowMs);
14571494
if (!duration) return run.status && !isFinalRunStatus(run.status) ? <>Queued</> : null;

‎apps/webapp/app/components/sessions/v1/trace/sessionRunLoads.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ export type SessionRunLoads = {
6262
budget: number;
6363
/** How often the budget freed collapsed runs or cut one off, for analytics. */
6464
budgetHits: { freed: number; cutOff: number };
65+
/** The tab is hidden: queued runs wait, runs already loading finish. */
66+
paused: boolean;
6567
};
6668

6769
export type SessionRunLoadsAction =
@@ -87,7 +89,9 @@ export type SessionRunLoadsAction =
8789
/** Retry a failed run: in place when some of its spans are on screen, else from the start. */
8890
| { type: "retry"; runFriendlyId: string }
8991
/** Frees runs back to idle, cancelling a fetch in flight (its late settle won't match). */
90-
| { type: "release"; runFriendlyIds: string[]; forBudget?: boolean };
92+
| { type: "release"; runFriendlyIds: string[]; forBudget?: boolean }
93+
/** The tab was hidden or shown again. */
94+
| { type: "paused"; paused: boolean };
9195

9296
/** What to do before a run fetches its next page. */
9397
type PageDecision =
@@ -174,6 +178,7 @@ export function createSessionRunLoads(
174178
newestRunId: undefined,
175179
budget,
176180
budgetHits: { freed: 0, cutOff: 0 },
181+
paused: false,
177182
};
178183
}
179184

@@ -343,6 +348,11 @@ export function sessionRunLoadsReducer(
343348
action.forBudget ? freeForBudget(current, released) : withoutLoads(current, released)
344349
);
345350
}
351+
case "paused": {
352+
if (current.paused === action.paused) return current;
353+
const next = { ...current, paused: action.paused };
354+
return action.paused ? next : startQueued(next);
355+
}
346356
}
347357
}
348358

@@ -464,6 +474,7 @@ function withoutLoads(current: SessionRunLoads, runFriendlyIds: string[]): Sessi
464474
}
465475

466476
function startQueued(current: SessionRunLoads): SessionRunLoads {
477+
if (current.paused) return current;
467478
let inFlight = inFlightCount(current);
468479
if (inFlight >= MAX_CONCURRENT_RUN_LOADS || current.queue.length === 0) return current;
469480

0 commit comments

Comments
 (0)