Skip to content

Commit 111d816

Browse files
MariefayTrigger.dev RepoOps
authored andcommitted
feat(webapp): open new session runs expanded in the Trace tab
Behind a feature flag, a run that starts while the session Trace tab is open now appears expanded, streams its spans, and is followed if the view is at the bottom. Mono-RevId: 5834176f5fb78dd84b2cae389b0f184e0416a0fb
1 parent fba8db6 commit 111d816

5 files changed

Lines changed: 216 additions & 27 deletions

File tree

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

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import {
5858
pageCheck,
5959
runLoad,
6060
runLoadId,
61+
runOpensExpanded,
6162
runSpanCount,
6263
sessionRunLoadsReducer,
6364
type SessionRunLoad,
@@ -239,7 +240,7 @@ function SessionTraceTree({
239240
}
240241
const intended = local ?? selection;
241242

242-
// Captured once: runs added later (earlier pages, new runs) start collapsed.
243+
// Captured once. Runs added later start collapsed, apart from a new newest run (the reducer).
243244
const [openRunId] = useState(() => initiallyExpandedRunFriendlyId(runs, totalCount));
244245
// A selection in the URL at mount whose run loaded: open there instead of at the bottom.
245246
const [deepLink] = useState(() => {
@@ -382,10 +383,10 @@ function SessionTraceTree({
382383
estimatedRowHeight: () => ROW_HEIGHT_PX,
383384
// Arrow keys, Home and End skip the gap, "not loaded" and "Load earlier" rows.
384385
canSelect: (node) => isSelectableRow(node.data),
386+
// The load reducer owns which runs are expanded: the opening run, a deep link, a new run.
385387
isCollapsedByDefault: (node) =>
386388
node.data.kind === "run" &&
387-
node.data.run.friendlyId !== openRunId &&
388-
node.data.run.friendlyId !== deepLink?.runFriendlyId,
389+
!runOpensExpanded(loadsRef.current, node.data.run.friendlyId, newestRunId),
389390
canExpand: (node) => node.data.kind === "run" || node.hasChildren,
390391
structureKey,
391392
// Derived, never set by code: a span not in the tree yet highlights its run.
@@ -522,10 +523,11 @@ function SessionTraceTree({
522523
onLoadEarlier();
523524
};
524525

525-
// Follow the newest run's new spans, but only if the view was at the bottom when they arrived.
526-
// `atBottom` is tracked on scroll, so rows added without a scroll (a new run) keep it true.
526+
// Follow a new run and the newest run's new spans, but only if the view was at the bottom when
527+
// they arrived. `atBottom` is tracked on scroll, so rows added without a scroll keep it true.
527528
// The tree takes the new rows a render later, so scroll once its node state has changed.
528529
const newestSpanCount = newestRunId ? (snapshots[newestRunId]?.events.length ?? 0) : 0;
530+
const followKey = `${newestRunId}:${newestSpanCount}`;
529531
const pendingFollow = useRef<{ nodes: typeof nodes } | null>(null);
530532
const latestNodes = useRef(nodes);
531533
useLayoutEffect(() => {
@@ -537,13 +539,13 @@ function SessionTraceTree({
537539
const scrollElement = parentRef.current;
538540
if (scrollElement && atBottom.current) scrollElement.scrollTop = scrollElement.scrollHeight;
539541
}, [nodes]);
540-
const followedSpanCount = useRef(newestSpanCount);
542+
const followedKey = useRef(followKey);
541543
useLayoutEffect(() => {
542-
if (followedSpanCount.current === newestSpanCount) return;
543-
followedSpanCount.current = newestSpanCount;
544+
if (followedKey.current === followKey) return;
545+
followedKey.current = followKey;
544546
pendingFollow.current =
545547
atBottom.current && !earlierLoad.current ? { nodes: latestNodes.current } : null;
546-
}, [newestSpanCount]);
548+
}, [followKey]);
547549

548550
// Run rows always expand and collapse one by one, so the load reducer hears about each.
549551
// Expand all tells it first, newest first, so the newest runs load first; the per-row

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

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,34 @@ export function isRunTraceMounted(load: SessionRunLoad): boolean {
118118
}
119119
}
120120

121+
/**
122+
* A run that just became the newest, replacing a known one, opens expanded, unless the user has
123+
* already expanded or collapsed it. Several new runs at once open only the newest.
124+
*/
125+
function newRunToOpen(
126+
current: SessionRunLoads,
127+
newestRunId: string | undefined
128+
): string | undefined {
129+
const previous = current.newestRunId;
130+
if (!previous || !newestRunId || previous === newestRunId) return undefined;
131+
if (current.expanded.has(newestRunId) || current.collapsedOrder.includes(newestRunId)) {
132+
return undefined;
133+
}
134+
return newestRunId;
135+
}
136+
137+
/**
138+
* Whether a run row opens expanded when it first appears in the tree. Asks the same question as
139+
* `runsChanged`, so the answer doesn't depend on whether that event has been handled yet.
140+
*/
141+
export function runOpensExpanded(
142+
loads: SessionRunLoads,
143+
runFriendlyId: string,
144+
newestRunId: string | undefined
145+
): boolean {
146+
return loads.expanded.has(runFriendlyId) || newRunToOpen(loads, newestRunId) === runFriendlyId;
147+
}
148+
121149
/** The starting state, with the run that opens expanded. */
122150
export function createSessionRunLoads(
123151
expandedRunIds: string[] = [],
@@ -154,10 +182,12 @@ function runLoadedSpans(loads: SessionRunLoads, runFriendlyId: string): number {
154182
* Every decision about loading a run's trace lives here:
155183
* - An expanded run that is in the run store and idle loads, whenever that becomes true.
156184
* - Expanding a failed run reads it again, but only if none of its spans are on screen.
157-
* - At most 3 runs load at once; a run holds its slot until all its pages are in.
185+
* - At most 3 runs load at once; a run holds its slot until all its pages are in. The newest run
186+
* starts first when it's queued.
158187
* - A run that settled with no spans becomes loaded when its first span arrives while tailing.
159-
* - When a newer run takes over, the previous newest run is read afresh once if it was still empty.
160-
* - A deep-linked run expands first, so it's the first to load once it's in the run store.
188+
* - When a newer run takes over, it expands; the previous newest run keeps its state, and is read
189+
* afresh once if it was still empty.
190+
* - A deep-linked run expands first, so it loads ahead of every run but the newest.
161191
* - The span budget: a run starts or fetches a page only within it, after freeing the least
162192
* recently collapsed runs if that's enough; the newest run is never held back. A run that would
163193
* fit with its own page waits while others load, rather than being cut off.
@@ -209,6 +239,8 @@ export function sessionRunLoadsReducer(
209239
) {
210240
next = withoutLoads(next, [previous]);
211241
}
242+
const opened = newRunToOpen(current, action.newestRunId);
243+
if (opened) next = { ...next, expanded: new Set([...next.expanded, opened]) };
212244
return loadWanted(next);
213245
}
214246
case "spanCount": {
@@ -392,11 +424,13 @@ function startQueued(current: SessionRunLoads): SessionRunLoads {
392424
let inFlight = inFlightCount(current);
393425
if (inFlight >= MAX_CONCURRENT_RUN_LOADS || current.queue.length === 0) return current;
394426

395-
let next: SessionRunLoads = {
396-
...current,
397-
loads: { ...current.loads },
398-
queue: [...current.queue],
399-
};
427+
// The newest run never waits behind older runs: it's the one being watched.
428+
const newest = current.newestRunId;
429+
const queue =
430+
newest && current.queue.includes(newest)
431+
? [newest, ...current.queue.filter((id) => id !== newest)]
432+
: [...current.queue];
433+
let next: SessionRunLoads = { ...current, loads: { ...current.loads }, queue };
400434
// Runs that would fit once the others finish stay queued, in order.
401435
const waiting: string[] = [];
402436
while (inFlight < MAX_CONCURRENT_RUN_LOADS) {

‎apps/webapp/app/presenters/v3/SessionRunsPresenter.server.ts‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -141,22 +141,25 @@ export class SessionRunsPresenter {
141141
: undefined;
142142
if (anchor === null) return null;
143143

144-
const [rows, totalCount] = await Promise.all([
144+
// The newest page's top row is the newest run, so its number is the total. A separate count
145+
// can see a run the rows don't have yet, and the newest run would look not loaded.
146+
const isNewestPage = !query.after && !anchor;
147+
const [rows, countedTotal] = await Promise.all([
145148
findSessionRunsPage(replica, {
146149
sessionId,
147150
before: query.after,
148151
atOrBefore: anchor,
149152
take: query.pageSize + 1,
150153
}),
151-
countSessionRuns(replica, { sessionId }),
154+
isNewestPage ? undefined : countSessionRuns(replica, { sessionId }),
152155
]);
153156

154157
const pageRows = rows.slice(0, query.pageSize);
155158
const olderRow = rows.at(query.pageSize);
156159
const oldestRow = pageRows.at(-1);
157160

158161
if (!oldestRow) {
159-
return { data: [], pagination: {}, totalCount, view: {} };
162+
return { data: [], pagination: {}, totalCount: countedTotal ?? 0, view: {} };
160163
}
161164

162165
const [oldestNumber, runsById] = await Promise.all([
@@ -177,7 +180,7 @@ export class SessionRunsPresenter {
177180
return {
178181
data,
179182
pagination: olderRow ? { next: encodeSessionRunCursor(oldestRow) } : {},
180-
totalCount,
183+
totalCount: countedTotal ?? oldestNumber + pageRows.length - 1,
181184
view: this.#view(pageRows, runsById, query.showDeletedLogs),
182185
};
183186
}

‎apps/webapp/test/sessionRunsPresenter.test.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,36 @@ describe("SessionRunsPresenter", () => {
230230
expect(second?.totalCount).toBe(24);
231231
});
232232

233+
postgresTest(
234+
"a run added after the page read doesn't change the newest page's total",
235+
async ({ prisma }) => {
236+
const { deps, query, addRun } = await seedSessionWithRuns(prisma, 23);
237+
// A run is triggered right after the page rows are read, and any other read waits for it:
238+
// a count would then see 24 runs while the rows end at 23.
239+
let runAdded!: () => void;
240+
const added = new Promise<void>((resolve) => (runAdded = resolve));
241+
const replica = new Proxy(prisma, {
242+
get(target, prop, receiver) {
243+
if (prop !== "$queryRaw") return Reflect.get(target, prop, receiver);
244+
return async (sql: { sql: string }, ...values: unknown[]) => {
245+
if (!sql.sql.includes("LIMIT")) {
246+
await added;
247+
return target.$queryRaw(sql as never, ...values);
248+
}
249+
const rows = await target.$queryRaw(sql as never, ...values);
250+
await addRun(24);
251+
runAdded();
252+
return rows;
253+
};
254+
},
255+
});
256+
257+
const page = await new SessionRunsPresenter({ ...deps, replica }).call(query);
258+
expect(page?.data[0]?.number).toBe(23);
259+
expect(page?.totalCount).toBe(23);
260+
}
261+
);
262+
233263
postgresTest("afterCrash sees a crashed run on the previous page", async ({ prisma }) => {
234264
const { deps, query } = await seedSessionWithRuns(prisma, 23, (n) =>
235265
n === 3 ? { status: "CRASHED" } : {}

‎apps/webapp/test/sessionTraceModel.test.ts‎

Lines changed: 126 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
pageCheck,
1616
runLoad,
1717
runLoadId,
18+
runOpensExpanded,
1819
runSpanCount,
1920
sessionRunLoadsReducer,
2021
type SessionRunLoadOutcome,
@@ -1041,6 +1042,124 @@ describe("sessionRunLoadsReducer: what loads when", () => {
10411042
});
10421043
});
10431044

1045+
describe("sessionRunLoadsReducer: new runs", () => {
1046+
type Action = SessionRunLoadsAction;
1047+
const run = (loads: SessionRunLoads, ...actions: Action[]) =>
1048+
actions.reduce<SessionRunLoads>(sessionRunLoadsReducer, loads);
1049+
const runsChanged = (withStatus: string[], newestRunId?: string): Action => ({
1050+
type: "runsChanged",
1051+
withStatus,
1052+
newestRunId,
1053+
});
1054+
const expand = (id: string, expanded = true): Action => ({
1055+
type: "expanded",
1056+
runFriendlyId: id,
1057+
expanded,
1058+
});
1059+
const opened = () => run(createSessionRunLoads(["run_25"]), runsChanged(["run_25"], "run_25"));
1060+
1061+
it("expands a new newest run and loads it", () => {
1062+
const loads = run(opened(), runsChanged(["run_25", "run_26"], "run_26"));
1063+
expect(loads.expanded.has("run_26")).toBe(true);
1064+
expect(runLoad(loads, "run_26").state).toBe("loading");
1065+
});
1066+
1067+
it("leaves the previous run as it was, expanded or collapsed", () => {
1068+
const stillOpen = run(opened(), runsChanged(["run_25", "run_26"], "run_26"));
1069+
expect(stillOpen.expanded.has("run_25")).toBe(true);
1070+
1071+
const collapsed = run(
1072+
opened(),
1073+
expand("run_25", false),
1074+
runsChanged(["run_25", "run_26"], "run_26")
1075+
);
1076+
expect(collapsed.expanded.has("run_25")).toBe(false);
1077+
expect(collapsed.expanded.has("run_26")).toBe(true);
1078+
});
1079+
1080+
it("expands only the newest of several new runs", () => {
1081+
const loads = run(opened(), runsChanged(["run_25", "run_26", "run_27", "run_28"], "run_28"));
1082+
expect([...loads.expanded].sort()).toEqual(["run_25", "run_28"]);
1083+
expect(runLoad(loads, "run_26").state).toBe("idle");
1084+
expect(runLoad(loads, "run_27").state).toBe("idle");
1085+
});
1086+
1087+
it("expands nothing on the first read or when older runs are added", () => {
1088+
const first = run(createSessionRunLoads(), runsChanged(["run_24", "run_25"], "run_25"));
1089+
expect(first.expanded.size).toBe(0);
1090+
1091+
const earlier = run(opened(), runsChanged(["run_5", "run_6", "run_25"], "run_25"));
1092+
expect([...earlier.expanded]).toEqual(["run_25"]);
1093+
});
1094+
1095+
it("keeps a new run without a status expanded until it reaches the run store", () => {
1096+
let loads = run(opened(), runsChanged(["run_25"], "run_26"));
1097+
expect(loads.expanded.has("run_26")).toBe(true);
1098+
expect(runLoad(loads, "run_26").state).toBe("idle");
1099+
loads = run(loads, runsChanged(["run_25", "run_26"], "run_26"));
1100+
expect(runLoad(loads, "run_26").state).toBe("loading");
1101+
});
1102+
1103+
it("starts a new run at the next free slot, ahead of older queued runs", () => {
1104+
const older = Array.from({ length: 20 }, (_, i) => `run_${i + 1}`);
1105+
let loads = run(createSessionRunLoads(), runsChanged(older, "run_20"));
1106+
loads = run(loads, { type: "expandedMany", runFriendlyIds: [...older].reverse() });
1107+
expect(loads.queue).toHaveLength(17);
1108+
1109+
// The new run reaches the run store a poll after it appears.
1110+
loads = run(loads, runsChanged(older, "run_21"), runsChanged([...older, "run_21"], "run_21"));
1111+
expect(runLoad(loads, "run_21").state).toBe("queued");
1112+
1113+
const loading = runLoad(loads, "run_20");
1114+
if (loading.state !== "loading") throw new Error("run_20 isn't loading");
1115+
loads = run(loads, {
1116+
type: "settle",
1117+
runFriendlyId: "run_20",
1118+
loadId: loading.loadId,
1119+
outcome: { state: "progressive" },
1120+
});
1121+
expect(runLoad(loads, "run_21").state).toBe("loading");
1122+
});
1123+
1124+
it("doesn't reopen a run the user collapsed if it becomes the newest again", () => {
1125+
const loads = run(
1126+
opened(),
1127+
runsChanged(["run_25", "run_26"], "run_26"),
1128+
expand("run_26", false),
1129+
runsChanged(["run_25", "run_26"], "run_25"),
1130+
runsChanged(["run_25", "run_26"], "run_26")
1131+
);
1132+
expect(loads.expanded.has("run_26")).toBe(false);
1133+
});
1134+
1135+
describe("runOpensExpanded", () => {
1136+
it("gives the tree the same answer before and after runsChanged is handled", () => {
1137+
const before = opened();
1138+
const after = run(before, runsChanged(["run_25", "run_26"], "run_26"));
1139+
for (const loads of [before, after]) {
1140+
expect(runOpensExpanded(loads, "run_26", "run_26")).toBe(true);
1141+
expect(runOpensExpanded(loads, "run_25", "run_26")).toBe(true);
1142+
}
1143+
});
1144+
1145+
it("opens the starting and deep-linked runs, and nothing else", () => {
1146+
const loads = run(createSessionRunLoads(["run_25"]), {
1147+
type: "deepLinked",
1148+
runFriendlyId: "run_3",
1149+
});
1150+
expect(runOpensExpanded(loads, "run_25", undefined)).toBe(true);
1151+
expect(runOpensExpanded(loads, "run_3", undefined)).toBe(true);
1152+
expect(runOpensExpanded(loads, "run_4", undefined)).toBe(false);
1153+
// The first read of the newest run isn't a new run.
1154+
expect(runOpensExpanded(loads, "run_24", "run_25")).toBe(false);
1155+
});
1156+
1157+
it("doesn't open older runs added by Load earlier", () => {
1158+
expect(runOpensExpanded(opened(), "run_5", "run_25")).toBe(false);
1159+
});
1160+
});
1161+
});
1162+
10441163
describe("sessionRunLoadsReducer: deep links", () => {
10451164
const deepLinked = (id: string): SessionRunLoadsAction => ({
10461165
type: "deepLinked",
@@ -1052,22 +1171,23 @@ describe("sessionRunLoadsReducer: deep links", () => {
10521171
newestRunId,
10531172
});
10541173

1055-
it("expands the linked run and loads it ahead of the live run", () => {
1174+
it("expands the linked run and loads it with the live run", () => {
10561175
let loads = sessionRunLoadsReducer(createSessionRunLoads(["run_50"]), deepLinked("run_3"));
10571176
expect([...loads.expanded]).toEqual(["run_3", "run_50"]);
10581177

10591178
loads = sessionRunLoadsReducer(loads, runsChanged(["run_3", "run_50"], "run_50"));
1060-
expect(runLoad(loads, "run_3")).toEqual({ state: "loading", loadId: 1 });
1061-
expect(runLoad(loads, "run_50")).toEqual({ state: "loading", loadId: 2 });
1179+
expect(runLoad(loads, "run_3").state).toBe("loading");
1180+
expect(runLoad(loads, "run_50").state).toBe("loading");
10621181
});
10631182

10641183
it("moves a queued linked run to the front", () => {
10651184
const ids = ["run_1", "run_2", "run_3", "run_4", "run_5"];
10661185
let loads = sessionRunLoadsReducer(createSessionRunLoads(ids), runsChanged(ids, "run_5"));
1067-
expect(loads.queue).toEqual(["run_4", "run_5"]);
1186+
// The newest run starts first.
1187+
expect(loads.queue).toEqual(["run_3", "run_4"]);
10681188

1069-
loads = sessionRunLoadsReducer(loads, deepLinked("run_5"));
1070-
expect(loads.queue).toEqual(["run_5", "run_4"]);
1189+
loads = sessionRunLoadsReducer(loads, deepLinked("run_4"));
1190+
expect(loads.queue).toEqual(["run_4", "run_3"]);
10711191
});
10721192

10731193
it("waits for the linked run to reach the run store", () => {

0 commit comments

Comments
 (0)