Skip to content

Commit 0f2942d

Browse files
MariefayTrigger.dev RepoOps
authored andcommitted
feat(webapp): select runs and spans in the session Trace tab, with deep links
Behind a feature flag, selecting a run or span in the session Trace tab shows it in the side panel, and the selection is kept in the URL so it can be shared. Links to older runs open with that run expanded and the span selected. Mono-RevId: a5c0191b681a71dbf8f9b91ef514a5f1402f6737
1 parent ec37e4d commit 0f2942d

19 files changed

Lines changed: 1614 additions & 137 deletions

‎apps/webapp/app/components/primitives/TreeView/TreeView.tsx‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,8 @@ type TreeStateHookProps<TData, TFilterValue> = {
162162
filter?: Filter<TData, TFilterValue>;
163163
/** Nodes that start collapsed when they first appear, initially or as the tree grows. */
164164
isCollapsedByDefault?: (node: FlatTreeItem<TData>) => boolean;
165+
/** Rows arrow keys can land on; others are skipped. Every row when unset. */
166+
canSelect?: (node: FlatTreeItem<TData>) => boolean;
165167
/** Whether a node can be expanded or collapsed. Defaults to having children. */
166168
canExpand?: (node: FlatTreeItem<TData>) => boolean;
167169
/** Called when a single node is expanded or collapsed (click, keyboard, toggle). */
@@ -200,7 +202,7 @@ export type UseTreeStateOutput = {
200202
selectNextVisibleNode: (scrollToNode?: boolean) => void;
201203
selectPreviousVisibleNode: (scrollToNode?: boolean) => void;
202204
selectParentNode: (scrollToNode?: boolean) => void;
203-
scrollToNode: (id: string) => void;
205+
scrollToNode: (id: string, align?: "auto" | "center") => void;
204206
};
205207

206208
export function useTree<TData, TFilterValue>({
@@ -212,6 +214,7 @@ export function useTree<TData, TFilterValue>({
212214
estimatedRowHeight,
213215
filter,
214216
isCollapsedByDefault,
217+
canSelect,
215218
canExpand = (node) => node.hasChildren,
216219
onExpandedChange,
217220
structureKey,
@@ -344,11 +347,11 @@ export function useTree<TData, TFilterValue>({
344347
overscan: 50,
345348
});
346349

347-
const scrollToNodeFn = (id: string) => {
350+
const scrollToNodeFn = (id: string, align: "auto" | "center" = "auto") => {
348351
const itemIndex = state.visibleNodeIds.findIndex((nodeId) => nodeId === id);
349352

350353
if (itemIndex !== -1) {
351-
virtualizer.scrollToIndex(itemIndex, { align: "auto" });
354+
virtualizer.scrollToIndex(itemIndex, { align });
352355
}
353356
};
354357

@@ -396,35 +399,35 @@ export function useTree<TData, TFilterValue>({
396399
const selectFirstVisibleNode = (scrollToNode = true) => {
397400
dispatch({
398401
type: "SELECT_FIRST_VISIBLE_NODE",
399-
payload: { scrollToNode, scrollToNodeFn },
402+
payload: { scrollToNode, scrollToNodeFn, canSelect },
400403
});
401404
};
402405

403406
const selectLastVisibleNode = (scrollToNode = true) => {
404407
dispatch({
405408
type: "SELECT_LAST_VISIBLE_NODE",
406-
payload: { scrollToNode, scrollToNodeFn },
409+
payload: { scrollToNode, scrollToNodeFn, canSelect },
407410
});
408411
};
409412

410413
const selectNextVisibleNode = (scrollToNode = true) => {
411414
dispatch({
412415
type: "SELECT_NEXT_VISIBLE_NODE",
413-
payload: { scrollToNode, scrollToNodeFn },
416+
payload: { scrollToNode, scrollToNodeFn, canSelect },
414417
});
415418
};
416419

417420
const selectPreviousVisibleNode = (scrollToNode = true) => {
418421
dispatch({
419422
type: "SELECT_PREVIOUS_VISIBLE_NODE",
420-
payload: { scrollToNode, scrollToNodeFn },
423+
payload: { scrollToNode, scrollToNodeFn, canSelect },
421424
});
422425
};
423426

424427
const selectParentNode = (scrollToNode = true) => {
425428
dispatch({
426429
type: "SELECT_PARENT_NODE",
427-
payload: { scrollToNode, scrollToNodeFn },
430+
payload: { scrollToNode, scrollToNodeFn, canSelect },
428431
});
429432
};
430433

‎apps/webapp/app/components/primitives/TreeView/reducer.ts‎

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ type WithScrollToNode = {
4040
scrollToNodeFn: ScrollToNodeFn;
4141
};
4242

43+
/** Rows the visible-node moves can land on; every row when unset. */
44+
type CanSelectNode = (node: FlatTree<any>[number]) => boolean;
45+
46+
type WithCanSelect = { canSelect?: CanSelectNode };
47+
4348
type UpdateTreeAction = {
4449
type: "UPDATE_TREE";
4550
payload: {
@@ -132,27 +137,27 @@ type ToggleExpandLevelAction = {
132137

133138
type SelectFirstVisibleNodeAction = {
134139
type: "SELECT_FIRST_VISIBLE_NODE";
135-
payload: {} & WithScrollToNode;
140+
payload: {} & WithScrollToNode & WithCanSelect;
136141
};
137142

138143
type SelectLastVisibleNodeAction = {
139144
type: "SELECT_LAST_VISIBLE_NODE";
140-
payload: {} & WithScrollToNode;
145+
payload: {} & WithScrollToNode & WithCanSelect;
141146
};
142147

143148
type SelectNextVisibleNodeAction = {
144149
type: "SELECT_NEXT_VISIBLE_NODE";
145-
payload: {} & WithScrollToNode;
150+
payload: {} & WithScrollToNode & WithCanSelect;
146151
};
147152

148153
type SelectPreviousVisibleNodeAction = {
149154
type: "SELECT_PREVIOUS_VISIBLE_NODE";
150-
payload: {} & WithScrollToNode;
155+
payload: {} & WithScrollToNode & WithCanSelect;
151156
};
152157

153158
type SelectParentNodeAction = {
154159
type: "SELECT_PARENT_NODE";
155-
payload: {} & WithScrollToNode;
160+
payload: {} & WithScrollToNode & WithCanSelect;
156161
};
157162

158163
type UpdateFilterAction = {
@@ -409,7 +414,10 @@ export function reducer(state: TreeState, action: Action): TreeState {
409414
});
410415
}
411416
case "SELECT_FIRST_VISIBLE_NODE": {
412-
const node = firstVisibleNode(state.tree, state.filteredNodes);
417+
const { canSelect } = action.payload;
418+
const node = canSelect
419+
? visibleNodes(state.tree, state.filteredNodes).find(canSelect)
420+
: firstVisibleNode(state.tree, state.filteredNodes);
413421
if (node) {
414422
return reducer(state, {
415423
type: "SELECT_NODE",
@@ -424,7 +432,10 @@ export function reducer(state: TreeState, action: Action): TreeState {
424432
return state;
425433
}
426434
case "SELECT_LAST_VISIBLE_NODE": {
427-
const node = lastVisibleNode(state.tree, state.filteredNodes);
435+
const { canSelect } = action.payload;
436+
const node = canSelect
437+
? findLastNode(visibleNodes(state.tree, state.filteredNodes), canSelect)
438+
: lastVisibleNode(state.tree, state.filteredNodes);
428439
if (node) {
429440
return reducer(state, {
430441
type: "SELECT_NODE",
@@ -446,13 +457,17 @@ export function reducer(state: TreeState, action: Action): TreeState {
446457
payload: {
447458
scrollToNode: action.payload.scrollToNode,
448459
scrollToNodeFn: action.payload.scrollToNodeFn,
460+
canSelect: action.payload.canSelect,
449461
},
450462
});
451463
}
452464

453465
const visible = visibleNodes(state.tree, state.filteredNodes);
454466
const selectedIndex = visible.findIndex((node) => node.id === selected);
455-
const nextNode = visible[selectedIndex + 1];
467+
const { canSelect } = action.payload;
468+
const nextNode = canSelect
469+
? visible.slice(selectedIndex + 1).find(canSelect)
470+
: visible[selectedIndex + 1];
456471
if (nextNode) {
457472
return reducer(state, {
458473
type: "SELECT_NODE",
@@ -475,13 +490,20 @@ export function reducer(state: TreeState, action: Action): TreeState {
475490
payload: {
476491
scrollToNode: action.payload.scrollToNode,
477492
scrollToNodeFn: action.payload.scrollToNodeFn,
493+
canSelect: action.payload.canSelect,
478494
},
479495
});
480496
}
481497

482498
const visible = visibleNodes(state.tree, state.filteredNodes);
483499
const selectedIndex = visible.findIndex((node) => node.id === selected);
484-
const previousNode = visible[Math.max(0, selectedIndex - 1)];
500+
const { canSelect } = action.payload;
501+
// A hidden selection (its parent collapsed) starts again from the top, as without canSelect.
502+
const previousNode = canSelect
503+
? selectedIndex === -1
504+
? visible.find(canSelect)
505+
: findLastNode(visible.slice(0, selectedIndex), canSelect)
506+
: visible[Math.max(0, selectedIndex - 1)];
485507
if (previousNode) {
486508
return reducer(state, {
487509
type: "SELECT_NODE",
@@ -504,6 +526,7 @@ export function reducer(state: TreeState, action: Action): TreeState {
504526
payload: {
505527
scrollToNode: action.payload.scrollToNode,
506528
scrollToNodeFn: action.payload.scrollToNodeFn,
529+
canSelect: action.payload.canSelect,
507530
},
508531
});
509532
}
@@ -513,7 +536,13 @@ export function reducer(state: TreeState, action: Action): TreeState {
513536
return state;
514537
}
515538

516-
const parentNode = state.tree.find((node) => node.id === selectedNode.parentId);
539+
// With canSelect, skip parents that can't be selected.
540+
const { canSelect } = action.payload;
541+
let parentNode = state.tree.find((node) => node.id === selectedNode.parentId);
542+
while (parentNode && canSelect && !canSelect(parentNode)) {
543+
const parentId: string | undefined = parentNode.parentId;
544+
parentNode = state.tree.find((node) => node.id === parentId);
545+
}
517546
if (parentNode) {
518547
return reducer(state, {
519548
type: "SELECT_NODE",
@@ -556,3 +585,10 @@ export function reducer(state: TreeState, action: Action): TreeState {
556585

557586
throw new Error(`Unhandled action type: ${(action as any).type}`);
558587
}
588+
589+
function findLastNode(nodes: FlatTree<any>, canSelect: CanSelectNode) {
590+
for (let i = nodes.length - 1; i >= 0; i--) {
591+
if (canSelect(nodes[i])) return nodes[i];
592+
}
593+
return undefined;
594+
}

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

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { useProject } from "~/hooks/useProject";
99
import type { RunTraceStart } from "~/presenters/v3/TraceChunkPresenter.server";
1010
import { v3RunTraceChunkPath } from "~/utils/pathBuilder";
1111
import { isFinalRunStatus } from "~/v3/taskStatus";
12+
import type { RunPanelData } from "./sessionPanelStore";
1213
import type { SessionRunLoad, SessionRunLoadOutcome } from "./sessionRunLoads";
1314
import type { SessionTraceRun } from "./sessionTraceTypes";
1415

@@ -28,15 +29,18 @@ type RunTraceSourceProps = {
2829
run: SessionTraceRun & { status: TaskRunStatus };
2930
load: SessionRunLoad;
3031
live: boolean;
32+
/** A deep-linked span: the first chunk includes it and its parents even past the first page. */
33+
selectedSpanId?: string;
3134
onSettle: (runFriendlyId: string, loadId: number, outcome: SessionRunLoadOutcome) => void;
3235
onSnapshot: (runFriendlyId: string, snapshot: RunTraceSnapshot | undefined) => void;
3336
onSpansChanged: (runFriendlyId: string, hasSpans: boolean) => void;
37+
onPanelData: (runFriendlyId: string, data: RunPanelData | undefined) => void;
3438
onRunDataStale: () => void;
3539
};
3640

3741
/** Loads one run's trace for the session tree. Renders nothing. */
3842
export function RunTraceSource(props: RunTraceSourceProps) {
39-
const { run, load, onSettle } = props;
43+
const { run, load, selectedSpanId, onSettle } = props;
4044
const organization = useOrganization();
4145
const project = useProject();
4246
const environment = useEnvironment();
@@ -49,7 +53,7 @@ export function RunTraceSource(props: RunTraceSourceProps) {
4953
useEffect(() => {
5054
if (fetchLoadId === undefined) return;
5155
const controller = new AbortController();
52-
void fetchTraceStart(chunkPath, controller.signal).then((result) => {
56+
void fetchTraceStart(chunkPath, selectedSpanId, controller.signal).then((result) => {
5357
if (controller.signal.aborted) return;
5458
if (result.kind === "progressive" || result.kind === "empty") {
5559
// Same JSON payload the run page loader sends; the hook reads its dates as strings.
@@ -59,7 +63,7 @@ export function RunTraceSource(props: RunTraceSourceProps) {
5963
onSettle(run.friendlyId, fetchLoadId, toOutcome(result));
6064
});
6165
return () => controller.abort();
62-
}, [fetchLoadId, chunkPath, run.friendlyId, onSettle]);
66+
}, [fetchLoadId, chunkPath, selectedSpanId, run.friendlyId, onSettle]);
6367

6468
if (!started) return null;
6569
return <RunTraceLive {...props} started={started} />;
@@ -73,6 +77,7 @@ function RunTraceLive({
7377
onSettle,
7478
onSnapshot,
7579
onSpansChanged,
80+
onPanelData,
7681
onRunDataStale,
7782
}: RunTraceSourceProps & { started: StartedTrace }) {
7883
const trace = useRunTrace({
@@ -102,6 +107,16 @@ function RunTraceLive({
102107

103108
useEffect(() => () => onSnapshot(run.friendlyId, undefined), [run.friendlyId, onSnapshot]);
104109

110+
const { inspectorRefreshKey, overridesBySpanId, linkedRunIdBySpanId } = trace;
111+
useEffect(() => {
112+
onPanelData(run.friendlyId, {
113+
refreshKey: inspectorRefreshKey,
114+
overridesBySpanId,
115+
linkedRunIdBySpanId,
116+
});
117+
}, [run.friendlyId, inspectorRefreshKey, overridesBySpanId, linkedRunIdBySpanId, onPanelData]);
118+
useEffect(() => () => onPanelData(run.friendlyId, undefined), [run.friendlyId, onPanelData]);
119+
105120
// The run's root span is hidden, so only spans below it count as on screen.
106121
const hasSpans = trace.events.some((event) => event.level > 0);
107122
useEffect(() => {
@@ -139,9 +154,14 @@ export function settledOutcome(
139154

140155
type TraceStartResult = RunTraceStart | { kind: "failed"; message: string };
141156

142-
async function fetchTraceStart(chunkPath: string, signal: AbortSignal): Promise<TraceStartResult> {
157+
async function fetchTraceStart(
158+
chunkPath: string,
159+
selectedSpanId: string | undefined,
160+
signal: AbortSignal
161+
): Promise<TraceStartResult> {
143162
const url = new URL(chunkPath, window.location.origin);
144163
url.searchParams.set("start", "1");
164+
if (selectedSpanId) url.searchParams.set("span", selectedSpanId);
145165

146166
const [error, response] = await tryCatch(fetch(url, { signal }));
147167
if (error) return { kind: "failed", message: "Couldn't load this run's trace." };

0 commit comments

Comments
 (0)