mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-10 03:30:59 +00:00
feat(web): add Thread DNA strip to agent stage tabs
Renders a Gantt-style strip below the Thread toolbar on agent and prompt stages only. Bar position encodes start time and width encodes duration, so empty stretches in the strip are real dead time (idle waits, sandbox spin-up, time waiting on a human steer). Categories collapse to five colors: system/agent/tool/user/interrupt. Tool/command bars use their explicit durationMs; tool groups span first child's start to last child's end so dead time between grouped tools is honored; assistant bars span the gap from the previous activity's end to the message ts as an approximation of "thinking" time; system/steer/interrupt are 4 px instant markers. Hover shows kind/label/elapsed/duration in a portaled popover; click selects the matching turn or tool group and shares state with the existing event list and side panel. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4f202303bc
commit
74862bdbaf
3 changed files with 547 additions and 9 deletions
|
|
@ -487,3 +487,203 @@ function DnaPopover({
|
|||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
export type ThreadCategory = "system" | "agent" | "tool" | "user" | "interrupt";
|
||||
|
||||
const THREAD_CATEGORY_LABEL: Record<ThreadCategory, string> = {
|
||||
system: "System",
|
||||
agent: "Agent",
|
||||
tool: "Tool",
|
||||
user: "User",
|
||||
interrupt: "Interrupt",
|
||||
};
|
||||
|
||||
const THREAD_CATEGORY_COLOR: Record<ThreadCategory, string> = {
|
||||
system: "var(--color-amber)",
|
||||
agent: "var(--color-teal-500)",
|
||||
tool: "var(--color-mint)",
|
||||
user: "var(--color-ice-300)",
|
||||
interrupt: "var(--color-coral)",
|
||||
};
|
||||
|
||||
export type ThreadDnaSelection =
|
||||
| { kind: "single"; turnIndex: number }
|
||||
| { kind: "group"; childTurnIndices: number[] };
|
||||
|
||||
export interface ThreadDnaItem {
|
||||
category: ThreadCategory;
|
||||
label: string;
|
||||
startMs: number;
|
||||
durationMs: number;
|
||||
selection: ThreadDnaSelection;
|
||||
}
|
||||
|
||||
const INSTANT_MARKER_PX = 4;
|
||||
const MIN_DURATION_PX = 3;
|
||||
|
||||
function selectionKey(s: ThreadDnaSelection): string {
|
||||
return s.kind === "single"
|
||||
? `s:${s.turnIndex}`
|
||||
: `g:${s.childTurnIndices.join(",")}`;
|
||||
}
|
||||
|
||||
function selectionsEqual(
|
||||
a: ThreadDnaSelection,
|
||||
b: ThreadDnaSelection | null,
|
||||
): boolean {
|
||||
if (b == null) return false;
|
||||
if (a.kind === "single" && b.kind === "single") {
|
||||
return a.turnIndex === b.turnIndex;
|
||||
}
|
||||
if (a.kind === "group" && b.kind === "group") {
|
||||
if (a.childTurnIndices.length !== b.childTurnIndices.length) return false;
|
||||
return a.childTurnIndices.every((v, i) => v === b.childTurnIndices[i]);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function formatThreadElapsed(ms: number): string {
|
||||
const total = Math.max(0, Math.floor(ms / 1000));
|
||||
const hours = Math.floor(total / 3600);
|
||||
const minutes = Math.floor((total % 3600) / 60);
|
||||
const seconds = total % 60;
|
||||
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function formatThreadDuration(ms: number): string {
|
||||
if (ms < 1000) return `${Math.max(0, Math.round(ms))} ms`;
|
||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)} s`;
|
||||
const minutes = Math.floor(ms / 60000);
|
||||
const seconds = Math.round((ms % 60000) / 1000);
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function ThreadDnaStrip({
|
||||
items,
|
||||
selection,
|
||||
onSelect,
|
||||
}: {
|
||||
items: ThreadDnaItem[];
|
||||
selection: ThreadDnaSelection | null;
|
||||
onSelect: (s: ThreadDnaSelection) => void;
|
||||
}) {
|
||||
const [hover, setHover] = useState<{ key: string; rect: DOMRect } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const totalMs = useMemo(() => {
|
||||
let max = 0;
|
||||
for (const item of items) {
|
||||
const end = item.startMs + Math.max(0, item.durationMs);
|
||||
if (end > max) max = end;
|
||||
}
|
||||
return Math.max(1, max);
|
||||
}, [items]);
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-md bg-overlay"
|
||||
style={{ height: STRIP_HEIGHT }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const hoveredItem =
|
||||
hover != null
|
||||
? items.find((it) => selectionKey(it.selection) === hover.key) ?? null
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative rounded-md bg-overlay px-1.5 py-[5px]"
|
||||
style={{ height: STRIP_HEIGHT }}
|
||||
>
|
||||
<div className="relative h-full">
|
||||
{items.map((item) => {
|
||||
const key = selectionKey(item.selection);
|
||||
const isInstant = item.durationMs <= 0;
|
||||
const isSelected = selectionsEqual(item.selection, selection);
|
||||
const isHovered = hover?.key === key;
|
||||
const leftPct = (item.startMs / totalMs) * 100;
|
||||
const baseColor = THREAD_CATEGORY_COLOR[item.category];
|
||||
|
||||
const style: React.CSSProperties = isInstant
|
||||
? {
|
||||
left: `calc(${leftPct}% - ${INSTANT_MARKER_PX / 2}px)`,
|
||||
width: INSTANT_MARKER_PX,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
background: baseColor,
|
||||
opacity: 1,
|
||||
boxShadow: isSelected
|
||||
? "0 0 0 1px rgba(255,255,255,0.55)"
|
||||
: "none",
|
||||
}
|
||||
: {
|
||||
left: `${leftPct}%`,
|
||||
width: `max(${MIN_DURATION_PX}px, ${(item.durationMs / totalMs) * 100}%)`,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
background: baseColor,
|
||||
opacity: isSelected || isHovered ? 1 : 0.9,
|
||||
boxShadow: isSelected
|
||||
? "0 0 0 1px rgba(255,255,255,0.55)"
|
||||
: "none",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
aria-label={`${THREAD_CATEGORY_LABEL[item.category]} · ${item.label}`}
|
||||
aria-pressed={isSelected}
|
||||
onMouseEnter={(e) =>
|
||||
setHover({
|
||||
key,
|
||||
rect: e.currentTarget.getBoundingClientRect(),
|
||||
})
|
||||
}
|
||||
onMouseLeave={() =>
|
||||
setHover((cur) => (cur?.key === key ? null : cur))
|
||||
}
|
||||
onClick={() => onSelect(item.selection)}
|
||||
className="absolute cursor-pointer rounded-[2px] transition-all duration-100 ease-out"
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{hoveredItem != null && hover != null && (
|
||||
<ThreadDnaPopover item={hoveredItem} anchorRect={hover.rect} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThreadDnaPopover({
|
||||
item,
|
||||
anchorRect,
|
||||
}: {
|
||||
item: ThreadDnaItem;
|
||||
anchorRect: DOMRect;
|
||||
}) {
|
||||
if (typeof document === "undefined") return null;
|
||||
const left = anchorRect.left + anchorRect.width / 2;
|
||||
const top = anchorRect.top;
|
||||
const elapsed = formatThreadElapsed(item.startMs);
|
||||
const duration =
|
||||
item.durationMs > 0 ? formatThreadDuration(item.durationMs) : "instant";
|
||||
return createPortal(
|
||||
<div
|
||||
role="tooltip"
|
||||
style={{ left, top }}
|
||||
className="pointer-events-none fixed z-50 -translate-x-1/2 -translate-y-[calc(100%+8px)] whitespace-nowrap rounded-md bg-panel-alt px-2.5 py-1 text-xs text-fg shadow-lg outline-1 -outline-offset-1 outline-line-strong"
|
||||
>
|
||||
{`${THREAD_CATEGORY_LABEL[item.category]} · ${item.label} · ${elapsed} · ${duration}`}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
|
|||
import type { EventEnvelope } from "@qltysh/fabro-api-client";
|
||||
|
||||
import {
|
||||
buildThreadDnaItems,
|
||||
eventsTabLabel,
|
||||
eventsToActivity,
|
||||
extractStageModel,
|
||||
|
|
@ -621,3 +622,184 @@ describe("eventsTabLabel", () => {
|
|||
expect(eventsTabLabel("primary", "summary")).toBe("Summary");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildThreadDnaItems", () => {
|
||||
const RUN_START = "2026-04-09T12:00:00Z";
|
||||
|
||||
function singleSystem(turnIndex: number, ts: string, content = "prompt") {
|
||||
return {
|
||||
kind: "single" as const,
|
||||
turnIndex,
|
||||
turn: { kind: "system" as const, ts, content },
|
||||
};
|
||||
}
|
||||
|
||||
function singleAssistant(turnIndex: number, ts: string) {
|
||||
return {
|
||||
kind: "single" as const,
|
||||
turnIndex,
|
||||
turn: {
|
||||
kind: "assistant" as const,
|
||||
ts,
|
||||
content: "hi",
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function singleTool(
|
||||
turnIndex: number,
|
||||
ts: string,
|
||||
toolName: string,
|
||||
durationMs: number,
|
||||
) {
|
||||
return {
|
||||
kind: "single" as const,
|
||||
turnIndex,
|
||||
turn: {
|
||||
kind: "tool" as const,
|
||||
ts,
|
||||
toolName,
|
||||
input: "",
|
||||
result: "",
|
||||
isError: false,
|
||||
durationMs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function singleSteer(turnIndex: number, ts: string) {
|
||||
return {
|
||||
kind: "single" as const,
|
||||
turnIndex,
|
||||
turn: { kind: "steer" as const, ts, content: "do this" },
|
||||
};
|
||||
}
|
||||
|
||||
test("empty input returns empty output", () => {
|
||||
expect(buildThreadDnaItems([], RUN_START)).toEqual([]);
|
||||
});
|
||||
|
||||
test("system prompt at runStart is an instant marker at startMs=0", () => {
|
||||
const items = buildThreadDnaItems([singleSystem(0, RUN_START)], RUN_START);
|
||||
expect(items).toEqual([
|
||||
{
|
||||
category: "system",
|
||||
label: "stage.prompt",
|
||||
startMs: 0,
|
||||
durationMs: 0,
|
||||
selection: { kind: "single", turnIndex: 0 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("assistant turn duration is gap from previous activity end to its ts", () => {
|
||||
// system at 0s, assistant at 8s → bar starts at 0, lasts 8s.
|
||||
const items = buildThreadDnaItems(
|
||||
[
|
||||
singleSystem(0, "2026-04-09T12:00:00Z"),
|
||||
singleAssistant(1, "2026-04-09T12:00:08Z"),
|
||||
],
|
||||
RUN_START,
|
||||
);
|
||||
expect(items[1]).toEqual({
|
||||
category: "agent",
|
||||
label: "agent.message",
|
||||
startMs: 0,
|
||||
durationMs: 8000,
|
||||
selection: { kind: "single", turnIndex: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
test("tool uses explicit durationMs and advances prevEnd by that duration", () => {
|
||||
// assistant at 8s, tool starting at 8.5s for 30s → next assistant at 39s
|
||||
// should be a 500ms agent bar starting at 38500ms.
|
||||
const items = buildThreadDnaItems(
|
||||
[
|
||||
singleAssistant(0, "2026-04-09T12:00:08Z"),
|
||||
singleTool(1, "2026-04-09T12:00:08.500Z", "shell", 30_000),
|
||||
singleAssistant(2, "2026-04-09T12:00:39Z"),
|
||||
],
|
||||
RUN_START,
|
||||
);
|
||||
expect(items[1]).toMatchObject({
|
||||
category: "tool",
|
||||
startMs: 8500,
|
||||
durationMs: 30_000,
|
||||
});
|
||||
expect(items[2]).toMatchObject({
|
||||
category: "agent",
|
||||
startMs: 38_500,
|
||||
durationMs: 500,
|
||||
});
|
||||
});
|
||||
|
||||
test("user steer is an instant marker categorised as user", () => {
|
||||
const items = buildThreadDnaItems(
|
||||
[singleSteer(0, "2026-04-09T12:00:30Z")],
|
||||
RUN_START,
|
||||
);
|
||||
expect(items[0]).toEqual({
|
||||
category: "user",
|
||||
label: "user.steer",
|
||||
startMs: 30_000,
|
||||
durationMs: 0,
|
||||
selection: { kind: "single", turnIndex: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
test("tool group spans first child's start to last child's end", () => {
|
||||
const child1 = {
|
||||
turnIndex: 0,
|
||||
turn: {
|
||||
kind: "tool" as const,
|
||||
ts: "2026-04-09T12:00:10Z",
|
||||
toolName: "shell",
|
||||
input: "",
|
||||
result: "",
|
||||
isError: false,
|
||||
durationMs: 1000,
|
||||
},
|
||||
};
|
||||
const child2 = {
|
||||
turnIndex: 1,
|
||||
turn: {
|
||||
kind: "tool" as const,
|
||||
ts: "2026-04-09T12:00:12Z",
|
||||
toolName: "shell",
|
||||
input: "",
|
||||
result: "",
|
||||
isError: false,
|
||||
durationMs: 2000,
|
||||
},
|
||||
};
|
||||
const group = {
|
||||
kind: "group" as const,
|
||||
toolName: "shell",
|
||||
ts: "2026-04-09T12:00:10Z",
|
||||
durationMs: 3000,
|
||||
children: [child1, child2],
|
||||
};
|
||||
const items = buildThreadDnaItems([group], RUN_START);
|
||||
// span = 12s + 2s − 10s = 4s, not the summed 3s.
|
||||
expect(items[0]).toMatchObject({
|
||||
category: "tool",
|
||||
startMs: 10_000,
|
||||
durationMs: 4000,
|
||||
selection: { kind: "group", childTurnIndices: [0, 1] },
|
||||
});
|
||||
});
|
||||
|
||||
test("falls back to first item's ts when runStart is missing", () => {
|
||||
const items = buildThreadDnaItems(
|
||||
[
|
||||
singleSystem(0, "2026-04-09T12:00:05Z"),
|
||||
singleAssistant(1, "2026-04-09T12:00:10Z"),
|
||||
],
|
||||
undefined,
|
||||
);
|
||||
expect(items[0]).toMatchObject({ startMs: 0 });
|
||||
expect(items[1]).toMatchObject({ startMs: 0, durationMs: 5000 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,11 +14,16 @@ import {
|
|||
DetailsPanel,
|
||||
EventSearchInput,
|
||||
MultiSelectFilter,
|
||||
ThreadDnaStrip,
|
||||
debugCategory,
|
||||
debugCategoryLabel,
|
||||
formatElapsed,
|
||||
} from "../components/event-debug";
|
||||
import type { DebugCategory } from "../components/event-debug";
|
||||
import type {
|
||||
DebugCategory,
|
||||
ThreadDnaItem,
|
||||
ThreadDnaSelection,
|
||||
} from "../components/event-debug";
|
||||
import { StageSidebar } from "../components/stage-sidebar";
|
||||
import type { Stage } from "../components/stage-sidebar";
|
||||
import { EmptyState } from "../components/state";
|
||||
|
|
@ -81,9 +86,7 @@ export type StageRenderer =
|
|||
| "wait"
|
||||
| "summary";
|
||||
|
||||
type PanelSelection =
|
||||
| { kind: "single"; turnIndex: number }
|
||||
| { kind: "group"; childTurnIndices: number[] };
|
||||
type PanelSelection = ThreadDnaSelection;
|
||||
|
||||
const STAGE_ACTIVITY_EVENT_SET = new Set<string>(STAGE_ACTIVITY_EVENT_TYPES);
|
||||
|
||||
|
|
@ -343,6 +346,147 @@ export function groupConsecutiveTools(
|
|||
return out;
|
||||
}
|
||||
|
||||
// Convert the event list / grouped tool view into bars for the Thread DNA
|
||||
// strip. Each bar carries the same selection identifier the event list uses,
|
||||
// so clicking a bar opens the same side-panel entry as clicking its row.
|
||||
//
|
||||
// Duration semantics:
|
||||
// - tool / command turns use their explicit durationMs
|
||||
// - tool groups span from the first child's start to the last child's end
|
||||
// - assistant turns have no native duration; we treat the time from the
|
||||
// previous activity's end to this message's ts as "thinking" time
|
||||
// - system / steer / interrupt are instants (durationMs = 0)
|
||||
export function buildThreadDnaItems(
|
||||
items: DisplayItem[],
|
||||
runStart: string | undefined,
|
||||
): ThreadDnaItem[] {
|
||||
if (items.length === 0) return [];
|
||||
|
||||
const anchorMs = (() => {
|
||||
if (runStart) {
|
||||
const parsed = Date.parse(runStart);
|
||||
if (!Number.isNaN(parsed)) return parsed;
|
||||
}
|
||||
const firstTs =
|
||||
items[0].kind === "single" ? items[0].turn.ts : items[0].ts;
|
||||
const parsedFirst = Date.parse(firstTs);
|
||||
return Number.isNaN(parsedFirst) ? null : parsedFirst;
|
||||
})();
|
||||
if (anchorMs == null) return [];
|
||||
|
||||
const out: ThreadDnaItem[] = [];
|
||||
let prevEndMs: number | null = null;
|
||||
|
||||
for (const item of items) {
|
||||
if (item.kind === "single") {
|
||||
const turn = item.turn;
|
||||
const tsMs = Date.parse(turn.ts);
|
||||
if (Number.isNaN(tsMs)) continue;
|
||||
const selection: ThreadDnaSelection = {
|
||||
kind: "single",
|
||||
turnIndex: item.turnIndex,
|
||||
};
|
||||
|
||||
switch (turn.kind) {
|
||||
case "system":
|
||||
out.push({
|
||||
category: "system",
|
||||
label: "stage.prompt",
|
||||
startMs: Math.max(0, tsMs - anchorMs),
|
||||
durationMs: 0,
|
||||
selection,
|
||||
});
|
||||
prevEndMs = tsMs;
|
||||
break;
|
||||
case "steer":
|
||||
out.push({
|
||||
category: "user",
|
||||
label: "user.steer",
|
||||
startMs: Math.max(0, tsMs - anchorMs),
|
||||
durationMs: 0,
|
||||
selection,
|
||||
});
|
||||
prevEndMs = tsMs;
|
||||
break;
|
||||
case "interrupt":
|
||||
out.push({
|
||||
category: "interrupt",
|
||||
label: "interrupt",
|
||||
startMs: Math.max(0, tsMs - anchorMs),
|
||||
durationMs: 0,
|
||||
selection,
|
||||
});
|
||||
prevEndMs = tsMs;
|
||||
break;
|
||||
case "assistant": {
|
||||
// turn.ts is the moment the assistant message arrived (end of
|
||||
// generation). Its bar represents the gap from the last activity
|
||||
// to that moment, so the visual width approximates "thinking".
|
||||
const startSourceMs = prevEndMs ?? tsMs;
|
||||
const startMs = Math.max(0, startSourceMs - anchorMs);
|
||||
const durationMs = Math.max(0, tsMs - startSourceMs);
|
||||
out.push({
|
||||
category: "agent",
|
||||
label: "agent.message",
|
||||
startMs,
|
||||
durationMs,
|
||||
selection,
|
||||
});
|
||||
prevEndMs = tsMs;
|
||||
break;
|
||||
}
|
||||
case "tool": {
|
||||
const startMs = Math.max(0, tsMs - anchorMs);
|
||||
const durationMs = Math.max(0, turn.durationMs);
|
||||
out.push({
|
||||
category: "tool",
|
||||
label: humanizeToolName(turn.toolName),
|
||||
startMs,
|
||||
durationMs,
|
||||
selection,
|
||||
});
|
||||
prevEndMs = tsMs + durationMs;
|
||||
break;
|
||||
}
|
||||
case "command": {
|
||||
const startMs = Math.max(0, tsMs - anchorMs);
|
||||
const durationMs = Math.max(0, turn.durationMs);
|
||||
out.push({
|
||||
category: "tool",
|
||||
label: "command",
|
||||
startMs,
|
||||
durationMs,
|
||||
selection,
|
||||
});
|
||||
prevEndMs = tsMs + durationMs;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const firstStart = Date.parse(item.ts);
|
||||
const lastChild = item.children[item.children.length - 1].turn;
|
||||
const lastEnd = Date.parse(lastChild.ts) + lastChild.durationMs;
|
||||
if (Number.isNaN(firstStart) || Number.isNaN(lastEnd)) continue;
|
||||
|
||||
const startMs = Math.max(0, firstStart - anchorMs);
|
||||
const durationMs = Math.max(0, lastEnd - firstStart);
|
||||
out.push({
|
||||
category: "tool",
|
||||
label: `${humanizeToolName(item.toolName)} ×${item.children.length}`,
|
||||
startMs,
|
||||
durationMs,
|
||||
selection: {
|
||||
kind: "group",
|
||||
childTurnIndices: item.children.map((c) => c.turnIndex),
|
||||
},
|
||||
});
|
||||
prevEndMs = lastEnd;
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
const STAGE_MODEL_EVENT_NAMES = new Set([
|
||||
"stage.prompt",
|
||||
"agent.session.activated",
|
||||
|
|
@ -1127,6 +1271,10 @@ export default function RunStages() {
|
|||
|
||||
const selectedStage = stages.find((s: Stage) => s.id === stageId) ?? stages[0];
|
||||
const selectedStageId = selectedStage?.id;
|
||||
const runStart =
|
||||
selectedStage?.startedAt ??
|
||||
runQuery.data?.start_time ??
|
||||
runQuery.data?.created_at;
|
||||
const stageEventsQuery = useRunStageEvents(id, selectedStageId);
|
||||
const turns = useMemo(
|
||||
() =>
|
||||
|
|
@ -1180,6 +1328,10 @@ export default function RunStages() {
|
|||
() => groupConsecutiveTools(filteredTurns),
|
||||
[filteredTurns],
|
||||
);
|
||||
const threadDnaItems = useMemo(
|
||||
() => buildThreadDnaItems(displayItems, runStart),
|
||||
[displayItems, runStart],
|
||||
);
|
||||
|
||||
const openTurn =
|
||||
panelSelection?.kind === "single" ? turns[panelSelection.turnIndex] ?? null : null;
|
||||
|
|
@ -1249,11 +1401,6 @@ export default function RunStages() {
|
|||
);
|
||||
}
|
||||
|
||||
const runStart =
|
||||
selectedStage.startedAt ??
|
||||
runQuery.data?.start_time ??
|
||||
runQuery.data?.created_at;
|
||||
|
||||
return (
|
||||
<div className="-mr-4 -mt-6 flex min-h-0 flex-1 sm:-mr-6 lg:-mr-8">
|
||||
<div className="shrink-0 pb-6 pr-3 pt-6">
|
||||
|
|
@ -1296,6 +1443,15 @@ export default function RunStages() {
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
{effectiveTab === "primary" && renderer === "agent" && (
|
||||
<div className="pb-3">
|
||||
<ThreadDnaStrip
|
||||
items={threadDnaItems}
|
||||
selection={panelSelection}
|
||||
onSelect={setPanelSelection}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pt-6 pb-[calc(1.5rem+var(--fabro-interview-dock-clearance,0px))]">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue