From 23b4f0b2ed72fb9242da3dafaf9d315629fd94e3 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Mon, 17 Aug 2026 14:52:55 +0100 Subject: [PATCH 01/35] feat(webapp): runs-list column registry, URL codec, and smart-column parsing Isomorphic column catalog plus the URL state codec (cols/sc) and the client-side payload/metadata/output parsing and JSON subpath extraction that the customizable runs list is built on. Pure, unit-tested; no behavior change on its own. --- .../app/components/runs/v3/runColumns.test.ts | 181 ++++++++ .../app/components/runs/v3/runColumns.ts | 391 ++++++++++++++++++ .../runs/v3/smartColumnData.test.ts | 117 ++++++ .../app/components/runs/v3/smartColumnData.ts | 117 ++++++ apps/webapp/vitest.config.ts | 1 + 5 files changed, 807 insertions(+) create mode 100644 apps/webapp/app/components/runs/v3/runColumns.test.ts create mode 100644 apps/webapp/app/components/runs/v3/runColumns.ts create mode 100644 apps/webapp/app/components/runs/v3/smartColumnData.test.ts create mode 100644 apps/webapp/app/components/runs/v3/smartColumnData.ts diff --git a/apps/webapp/app/components/runs/v3/runColumns.test.ts b/apps/webapp/app/components/runs/v3/runColumns.test.ts new file mode 100644 index 00000000000..154eb9dba6c --- /dev/null +++ b/apps/webapp/app/components/runs/v3/runColumns.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from "vitest"; +import { + availableStandardColumns, + decodeSmartColumn, + deriveRunSelect, + encodeColumnLayout, + encodeSmartColumn, + resolveColumnLayout, + type RunColumnRuntime, + type SmartColumnDef, +} from "./runColumns"; + +const cloud: RunColumnRuntime = { isManagedCloud: true, isDevelopment: false }; +const dev: RunColumnRuntime = { isManagedCloud: false, isDevelopment: true }; + +describe("deriveRunSelect", () => { + it("always includes the presenter's scalar contract", () => { + const select = deriveRunSelect([], []); + for (const field of [ + "id", + "friendlyId", + "spanId", + "status", + "runtimeEnvironmentId", + "rootTaskRunId", + "createdAt", + "updatedAt", + "startedAt", + "lockedAt", + "completedAt", + "queueTimestamp", + "delayUntil", + "scheduleId", + "metadata", + "metadataType", + "taskIdentifier", + "machinePreset", + "queue", + "runTags", + ]) { + expect(select[field as keyof typeof select]).toBe(true); + } + }); + + it("does not hydrate the large blobs unless a smart column references them", () => { + const select = deriveRunSelect(["task", "status", "tags"], []); + expect(select.payload).toBeUndefined(); + expect(select.payloadType).toBeUndefined(); + expect(select.output).toBeUndefined(); + expect(select.outputType).toBeUndefined(); + }); + + it("adds payload/output fields only for referenced smart sources", () => { + const payloadOnly = deriveRunSelect([], ["payload"]); + expect(payloadOnly.payload).toBe(true); + expect(payloadOnly.payloadType).toBe(true); + expect(payloadOnly.output).toBeUndefined(); + + const both = deriveRunSelect([], ["payload", "output"]); + expect(both.output).toBe(true); + expect(both.outputType).toBe(true); + }); + + it("references metadata from the always-selected set without a smart source", () => { + const select = deriveRunSelect([], ["metadata"]); + expect(select.metadata).toBe(true); + expect(select.metadataType).toBe(true); + }); +}); + +describe("availableStandardColumns gating", () => { + it("includes compute and region on managed cloud", () => { + const ids = availableStandardColumns(cloud).map((c) => c.id); + expect(ids).toContain("compute"); + expect(ids).toContain("region"); + }); + + it("drops compute and region on development / self-host", () => { + const ids = availableStandardColumns(dev).map((c) => c.id); + expect(ids).not.toContain("compute"); + expect(ids).not.toContain("region"); + }); +}); + +describe("resolveColumnLayout", () => { + it("returns the default layout when cols is absent", () => { + const layout = resolveColumnLayout({ cols: [], sc: [] }, cloud); + expect(layout.isCustomized).toBe(false); + expect(layout.hiddenStandard).toHaveLength(0); + expect(layout.visible[0]).toMatchObject({ kind: "standard", def: { id: "id" } }); + expect(layout.visible).toHaveLength(availableStandardColumns(cloud).length); + }); + + it("keeps locked columns in the requested order (they are reorderable)", () => { + const layout = resolveColumnLayout({ cols: ["task", "status", "id"], sc: [] }, cloud); + const ids = layout.visible.map((c) => (c.kind === "standard" ? c.def.id : "smart")); + expect(ids).toEqual(["task", "status", "id"]); + }); + + it("moves omitted standard columns into hiddenStandard", () => { + const layout = resolveColumnLayout({ cols: ["id", "task", "status"], sc: [] }, cloud); + const hidden = layout.hiddenStandard.map((c) => c.id); + expect(hidden).toContain("tags"); + expect(hidden).toContain("ttl"); + expect(hidden).not.toContain("id"); + }); + + it("never hides locked columns and reinserts them if the URL omits them", () => { + const layout = resolveColumnLayout({ cols: ["id", "ver"], sc: [] }, cloud); + const ids = layout.visible.filter((c) => c.kind === "standard").map((c) => c.def.id); + expect(ids).toContain("task"); + expect(ids).toContain("status"); + const hidden = layout.hiddenStandard.map((c) => c.id); + expect(hidden).not.toContain("task"); + expect(hidden).not.toContain("status"); + }); + + it("resolves smart-column refs positionally", () => { + const sc = [ + encodeSmartColumn({ source: "metadata", path: "$.failed", label: "Failed", displayAs: "number" }), + ]; + const layout = resolveColumnLayout({ cols: ["id", "sc1"], sc }, cloud); + const smart = layout.visible.find((c) => c.kind === "smart"); + expect(smart).toMatchObject({ kind: "smart", def: { label: "Failed", source: "metadata" } }); + }); + + it("drops gated columns referenced on a runtime that lacks them", () => { + const layout = resolveColumnLayout({ cols: ["id", "region", "compute", "task"], sc: [] }, dev); + const ids = layout.visible.map((c) => (c.kind === "standard" ? c.def.id : "smart")); + expect(ids).toEqual(["id", "task", "status"]); + }); +}); + +describe("encodeColumnLayout round-trip", () => { + it("encodes the default layout to empty params", () => { + const layout = resolveColumnLayout({ cols: [], sc: [] }, cloud); + expect(encodeColumnLayout(layout.visible, cloud)).toEqual({ cols: [], sc: [] }); + }); + + it("round-trips a reordered, hidden, smart-augmented layout", () => { + const scDef: SmartColumnDef = { + source: "payload", + path: "$.order.total", + label: "Order total", + displayAs: "number", + }; + const encoded = encodeColumnLayout( + [ + { kind: "standard", def: availableStandardColumns(cloud).find((c) => c.id === "id")! }, + { kind: "standard", def: availableStandardColumns(cloud).find((c) => c.id === "status")! }, + { kind: "smart", index: 0, def: scDef }, + ], + cloud + ); + expect(encoded.cols).toEqual(["id", "status", "sc1"]); + expect(encoded.sc).toHaveLength(1); + + const layout = resolveColumnLayout(encoded, cloud); + const ids = layout.visible.map((c) => (c.kind === "standard" ? c.def.id : c.def.label)); + expect(ids).toEqual(["id", "task", "status", "Order total"]); + }); +}); + +describe("smart column codec", () => { + it("round-trips including delimiter-dangerous characters", () => { + const def: SmartColumnDef = { + source: "metadata", + path: "$['a:b'].c", + label: "Weird: 50%", + displayAs: "badge", + }; + const decoded = decodeSmartColumn(encodeSmartColumn(def)); + expect(decoded).toEqual(def); + }); + + it("rejects an unknown source or display", () => { + expect(decodeSmartColumn("bogus:$.a:A:number")).toBeUndefined(); + expect(decodeSmartColumn("metadata:$.a:A:bogus")).toBeUndefined(); + expect(decodeSmartColumn("metadata::A:number")).toBeUndefined(); + }); +}); diff --git a/apps/webapp/app/components/runs/v3/runColumns.ts b/apps/webapp/app/components/runs/v3/runColumns.ts new file mode 100644 index 00000000000..13fb908035e --- /dev/null +++ b/apps/webapp/app/components/runs/v3/runColumns.ts @@ -0,0 +1,391 @@ +import type { Prisma } from "@trigger.dev/database"; + +/** + * Isomorphic column catalog for the runs list. Shared by the client table + * renderer, the display-options popover, the URL codec, and the server-side + * Postgres select derivation, so none of it may import React or server code. + * + * The order of `RUN_COLUMN_IDS`/`STANDARD_COLUMNS` is the default column order. + */ +export const RUN_COLUMN_IDS = [ + "id", + "task", + "status", + "ver", + "started", + "dur", + "compute", + "machine", + "queue", + "region", + "test", + "created", + "delayed", + "ttl", + "tags", +] as const; + +export type RunColumnId = (typeof RUN_COLUMN_IDS)[number]; + +export type RunColumnGate = "managedCloud" | "nonDev"; + +type RunSelectField = keyof Prisma.TaskRunSelect; + +export type StandardColumnDef = { + id: RunColumnId; + label: string; + /** + * When set, the column only exists in this runtime; otherwise it is absent + * from the table AND the popover (not merely hidden). + */ + gate?: RunColumnGate; + /** Locked columns can be reordered but never hidden (their toggle is disabled). */ + locked?: boolean; + /** Raw ListedRun/TaskRun fields the column needs hydrated from Postgres. */ + fields: readonly RunSelectField[]; +}; + +/** + * The scalar fields the shared presenter always maps into its stable output, + * regardless of which columns show. Because the presenter contract is fixed and + * these are all small single-row columns (no DB win from narrowing them), the + * select currently gates only the large blobs: payload/output are added solely + * when a smart column references them. Shrinking this set to a behaviour-only + * floor later is a change here plus defensive presenter mapping, not an API + * change to `deriveRunSelect`. + */ +const ALWAYS_SELECTED_FIELDS = [ + "id", + "friendlyId", + "taskIdentifier", + "taskVersion", + "runtimeEnvironmentId", + "status", + "createdAt", + "queueTimestamp", + "scheduleId", + "startedAt", + "lockedAt", + "delayUntil", + "updatedAt", + "completedAt", + "isTest", + "spanId", + "idempotencyKey", + "ttl", + "expiredAt", + "costInCents", + "baseCostInCents", + "usageDurationMs", + "runTags", + "depth", + "rootTaskRunId", + "batchId", + "metadata", + "metadataType", + "machinePreset", + "queue", + "workerQueue", + "region", + "annotations", +] as const satisfies readonly RunSelectField[]; + +export const STANDARD_COLUMNS: readonly StandardColumnDef[] = [ + { id: "id", label: "ID", locked: true, fields: ["friendlyId", "spanId"] }, + { + id: "task", + label: "Task", + locked: true, + fields: ["taskIdentifier", "annotations", "rootTaskRunId"], + }, + { id: "status", label: "Status", locked: true, fields: ["status"] }, + { id: "ver", label: "Version", fields: ["taskVersion"] }, + { id: "started", label: "Started", fields: ["startedAt", "lockedAt"] }, + { + id: "dur", + label: "Duration", + fields: [ + "startedAt", + "lockedAt", + "completedAt", + "updatedAt", + "createdAt", + "queueTimestamp", + "delayUntil", + "scheduleId", + "usageDurationMs", + "status", + ], + }, + { + id: "compute", + label: "Compute", + gate: "managedCloud", + fields: ["costInCents", "baseCostInCents"], + }, + { id: "machine", label: "Machine", fields: ["machinePreset"] }, + { id: "queue", label: "Queue", fields: ["queue"] }, + { id: "region", label: "Region", gate: "nonDev", fields: ["region", "workerQueue"] }, + { id: "test", label: "Test", fields: ["isTest"] }, + { id: "created", label: "Created at", fields: ["createdAt"] }, + { id: "delayed", label: "Delayed until", fields: ["delayUntil"] }, + { id: "ttl", label: "TTL", fields: ["ttl", "expiredAt"] }, + { id: "tags", label: "Tags", fields: ["runTags"] }, +]; + +const STANDARD_COLUMNS_BY_ID = new Map(STANDARD_COLUMNS.map((c) => [c.id, c] as const)); + +export const SMART_COLUMN_SOURCES = ["payload", "metadata", "output"] as const; +export type SmartColumnSource = (typeof SMART_COLUMN_SOURCES)[number]; + +export const SMART_COLUMN_DISPLAYS = ["text", "number", "duration", "badge"] as const; +export type SmartColumnDisplay = (typeof SMART_COLUMN_DISPLAYS)[number]; + +export type SmartColumnDef = { + source: SmartColumnSource; + path: string; + label: string; + displayAs: SmartColumnDisplay; +}; + +const SMART_SOURCE_FIELDS: Record = { + payload: ["payload", "payloadType"], + metadata: ["metadata", "metadataType"], + output: ["output", "outputType"], +}; + +const SMART_REF_PREFIX = "sc"; + +export function smartColumnRef(index: number): string { + return `${SMART_REF_PREFIX}${index + 1}`; +} + +function parseSmartColumnRef(ref: string): number | undefined { + if (!ref.startsWith(SMART_REF_PREFIX)) return undefined; + const n = Number(ref.slice(SMART_REF_PREFIX.length)); + return Number.isInteger(n) && n >= 1 ? n - 1 : undefined; +} + +/** + * Build the Postgres `select` for a page from the visible columns. Fields for + * shown standard columns are added on top of the always-selected set (a no-op + * while that set is the full scalar contract); payload/output are hydrated + * solely when a smart column references them. + */ +export function deriveRunSelect( + visibleStandardIds: readonly RunColumnId[], + smartSources: readonly SmartColumnSource[] +): Prisma.TaskRunSelect { + const select: Prisma.TaskRunSelect = {}; + + const add = (field: RunSelectField) => { + (select as Record)[field] = true; + }; + + for (const field of ALWAYS_SELECTED_FIELDS) add(field); + + for (const id of visibleStandardIds) { + const def = STANDARD_COLUMNS_BY_ID.get(id); + if (!def) continue; + for (const field of def.fields) add(field); + } + + for (const source of smartSources) { + for (const field of SMART_SOURCE_FIELDS[source]) add(field); + } + + return select; +} + +export type RunColumnRuntime = { + isManagedCloud: boolean; + isDevelopment: boolean; +}; + +function isColumnAvailable(def: StandardColumnDef, runtime: RunColumnRuntime): boolean { + switch (def.gate) { + case "managedCloud": + return runtime.isManagedCloud; + case "nonDev": + return !runtime.isDevelopment; + default: + return true; + } +} + +export function availableStandardColumns(runtime: RunColumnRuntime): StandardColumnDef[] { + return STANDARD_COLUMNS.filter((def) => isColumnAvailable(def, runtime)); +} + +function escapeSmartPart(value: string): string { + return value.replace(/%/g, "%25").replace(/:/g, "%3A"); +} + +function unescapeSmartPart(value: string): string { + return value.replace(/%3A/g, ":").replace(/%25/g, "%"); +} + +export function encodeSmartColumn(def: SmartColumnDef): string { + return [def.source, escapeSmartPart(def.path), escapeSmartPart(def.label), def.displayAs].join( + ":" + ); +} + +export function decodeSmartColumn(raw: string): SmartColumnDef | undefined { + const parts = raw.split(":"); + if (parts.length < 4) return undefined; + + const [source, path, label, displayAs] = parts; + if (!SMART_COLUMN_SOURCES.includes(source as SmartColumnSource)) return undefined; + if (!SMART_COLUMN_DISPLAYS.includes(displayAs as SmartColumnDisplay)) return undefined; + + const decodedPath = unescapeSmartPart(path); + if (decodedPath.length === 0) return undefined; + + return { + source: source as SmartColumnSource, + path: decodedPath, + label: unescapeSmartPart(label), + displayAs: displayAs as SmartColumnDisplay, + }; +} + +export type ResolvedColumn = + | { kind: "standard"; def: StandardColumnDef } + | { kind: "smart"; index: number; def: SmartColumnDef }; + +export type ColumnLayout = { + /** Visible columns in display order. */ + visible: ResolvedColumn[]; + /** Available standard columns that are currently hidden, in default order. */ + hiddenStandard: StandardColumnDef[]; + /** All decoded smart columns (visible or not), indexed by position. */ + smartColumns: SmartColumnDef[]; + /** Whether the layout differs from the default (drives "Reset to default"). */ + isCustomized: boolean; +}; + +/** + * Resolve the on-screen layout from the URL params and the runtime gates. When + * `cols` is absent the default layout (all available standard columns in + * default order, no smart columns) is returned and `sc` is ignored. + */ +export function resolveColumnLayout( + params: { cols: string[]; sc: string[] }, + runtime: RunColumnRuntime +): ColumnLayout { + const available = availableStandardColumns(runtime); + const availableById = new Map(available.map((c) => [c.id, c] as const)); + const smartColumns = params.sc + .map(decodeSmartColumn) + .filter((c): c is SmartColumnDef => c !== undefined); + + if (params.cols.length === 0) { + return { + visible: available.map((def) => ({ kind: "standard", def })), + hiddenStandard: [], + smartColumns, + isCustomized: false, + }; + } + + const visible: ResolvedColumn[] = []; + const seenStandard = new Set(); + + for (const token of params.cols) { + const smartIndex = parseSmartColumnRef(token); + if (smartIndex !== undefined) { + const def = smartColumns[smartIndex]; + if (def) visible.push({ kind: "smart", index: smartIndex, def }); + continue; + } + + if (seenStandard.has(token as RunColumnId)) continue; + const def = availableById.get(token as RunColumnId); + if (!def) continue; + visible.push({ kind: "standard", def }); + seenStandard.add(def.id); + } + + ensureLockedColumnsPresent(visible, seenStandard, available); + + const hiddenStandard = available.filter((def) => !def.locked && !seenStandard.has(def.id)); + + return { visible, hiddenStandard, smartColumns, isCustomized: true }; +} + +/** + * Locked columns can never be hidden, so a `cols` param that omits one (a + * hand-edited or stale URL) gets it reinserted at its default-order position. + */ +function ensureLockedColumnsPresent( + visible: ResolvedColumn[], + seenStandard: Set, + available: StandardColumnDef[] +): void { + const defaultIndex = new Map(available.map((def, index) => [def.id, index] as const)); + for (const def of available) { + if (!def.locked || seenStandard.has(def.id)) continue; + const target = defaultIndex.get(def.id) ?? 0; + let insertAt = visible.length; + for (let i = 0; i < visible.length; i++) { + const col = visible[i]; + if (col.kind === "standard" && (defaultIndex.get(col.def.id) ?? 0) > target) { + insertAt = i; + break; + } + } + visible.splice(insertAt, 0, { kind: "standard", def }); + seenStandard.add(def.id); + } +} + +/** + * Serialize a layout back to `cols`/`sc` params. Returns empty arrays for the + * default layout so the URL stays clean (the caller deletes both keys). + */ +export function encodeColumnLayout( + visible: ResolvedColumn[], + runtime: RunColumnRuntime +): { cols: string[]; sc: string[] } { + const available = availableStandardColumns(runtime); + const hasSmart = visible.some((c) => c.kind === "smart"); + const isDefault = + !hasSmart && + visible.length === available.length && + visible.every((c, i) => c.kind === "standard" && c.def.id === available[i]?.id); + + if (isDefault) { + return { cols: [], sc: [] }; + } + + const sc: string[] = []; + const smartRefByIndex = new Map(); + for (const col of visible) { + if (col.kind === "smart") { + const ref = smartColumnRef(sc.length); + smartRefByIndex.set(col.index, ref); + sc.push(encodeSmartColumn(col.def)); + } + } + + const cols = visible.map((col) => + col.kind === "standard" ? col.def.id : (smartRefByIndex.get(col.index) as string) + ); + + return { cols, sc }; +} + +/** The set of smart-column sources referenced by the visible layout. */ +export function visibleSmartSources(visible: ResolvedColumn[]): SmartColumnSource[] { + const sources = new Set(); + for (const col of visible) { + if (col.kind === "smart") sources.add(col.def.source); + } + return Array.from(sources); +} + +/** Visible standard column ids, for select derivation. */ +export function visibleStandardIds(visible: ResolvedColumn[]): RunColumnId[] { + return visible.filter((c) => c.kind === "standard").map((c) => c.def.id); +} diff --git a/apps/webapp/app/components/runs/v3/smartColumnData.test.ts b/apps/webapp/app/components/runs/v3/smartColumnData.test.ts new file mode 100644 index 00000000000..d3df655f895 --- /dev/null +++ b/apps/webapp/app/components/runs/v3/smartColumnData.test.ts @@ -0,0 +1,117 @@ +import superjson from "superjson"; +import { describe, expect, it } from "vitest"; +import { + extractSmartValue, + getAtPath, + labelFromPath, + parseSource, +} from "./smartColumnData"; + +describe("parseSource", () => { + it("reports empty for missing data", () => { + expect(parseSource({ data: null, dataType: "application/json" })).toEqual({ state: "empty" }); + expect(parseSource({ data: undefined, dataType: "application/json" })).toEqual({ + state: "empty", + }); + expect(parseSource({ data: "", dataType: "application/json" })).toEqual({ state: "empty" }); + }); + + it("reports offloaded for application/store without touching the path", () => { + expect(parseSource({ data: "s3://bucket/key", dataType: "application/store" })).toEqual({ + state: "offloaded", + }); + }); + + it("parses application/json", () => { + expect(parseSource({ data: '{"a":1}', dataType: "application/json" })).toEqual({ + state: "parsed", + value: { a: 1 }, + }); + }); + + it("parses application/super+json (dates survive)", () => { + const serialized = superjson.stringify({ when: new Date("2026-01-01T00:00:00.000Z"), n: 2 }); + const parsed = parseSource({ data: serialized, dataType: "application/super+json" }); + expect(parsed.state).toBe("parsed"); + if (parsed.state === "parsed") { + const value = parsed.value as { when: Date; n: number }; + expect(value.when).toBeInstanceOf(Date); + expect(value.n).toBe(2); + } + }); + + it("defaults an unknown/absent content type to raw string and json respectively", () => { + expect(parseSource({ data: "hello", dataType: "text/plain" })).toEqual({ + state: "parsed", + value: "hello", + }); + expect(parseSource({ data: '{"a":1}', dataType: undefined })).toEqual({ + state: "parsed", + value: { a: 1 }, + }); + }); + + it("falls back to the raw string on malformed json", () => { + expect(parseSource({ data: "{not json", dataType: "application/json" })).toEqual({ + state: "parsed", + value: "{not json", + }); + }); +}); + +describe("getAtPath", () => { + const obj = { + failed: 3, + suites: [{ name: "nightly" }, { name: "smoke" }], + "a.b": { c: 7 }, + nested: { deep: { value: "x" } }, + }; + + it("reads a top-level key with and without $ / dot prefixes", () => { + expect(getAtPath(obj, "$.failed")).toBe(3); + expect(getAtPath(obj, "failed")).toBe(3); + expect(getAtPath(obj, ".failed")).toBe(3); + }); + + it("reads array indices and nested keys", () => { + expect(getAtPath(obj, "$.suites[0].name")).toBe("nightly"); + expect(getAtPath(obj, "suites[1].name")).toBe("smoke"); + expect(getAtPath(obj, "nested.deep.value")).toBe("x"); + }); + + it("reads quoted bracket keys containing a dot", () => { + expect(getAtPath(obj, "$['a.b'].c")).toBe(7); + }); + + it("returns undefined for missing segments", () => { + expect(getAtPath(obj, "$.nope")).toBeUndefined(); + expect(getAtPath(obj, "$.suites[9].name")).toBeUndefined(); + expect(getAtPath(obj, "$.failed.x")).toBeUndefined(); + }); + + it("rejects malformed paths", () => { + expect(getAtPath(obj, "$.a..b")).toBeUndefined(); + expect(getAtPath(obj, "$.a[b]")).toBeUndefined(); + }); +}); + +describe("extractSmartValue", () => { + it("passes through empty and offloaded states", () => { + expect(extractSmartValue({ state: "empty" }, "$.a")).toEqual({ state: "empty" }); + expect(extractSmartValue({ state: "offloaded" }, "$.a")).toEqual({ state: "offloaded" }); + }); + + it("returns the value when present and empty when absent", () => { + const parsed = { state: "parsed" as const, value: { a: { b: 5 } } }; + expect(extractSmartValue(parsed, "$.a.b")).toEqual({ state: "value", value: 5 }); + expect(extractSmartValue(parsed, "$.a.c")).toEqual({ state: "empty" }); + }); +}); + +describe("labelFromPath", () => { + it("uses the last segment", () => { + expect(labelFromPath("$.suites[0].name")).toBe("name"); + expect(labelFromPath("$.failed")).toBe("failed"); + expect(labelFromPath("failed")).toBe("failed"); + }); +}); diff --git a/apps/webapp/app/components/runs/v3/smartColumnData.ts b/apps/webapp/app/components/runs/v3/smartColumnData.ts new file mode 100644 index 00000000000..36db70310ea --- /dev/null +++ b/apps/webapp/app/components/runs/v3/smartColumnData.ts @@ -0,0 +1,117 @@ +import superjson from "superjson"; +import type { SmartColumnSource } from "./runColumns"; + +export type SourcePacket = { + data: string | null | undefined; + dataType: string | null | undefined; +}; + +export type ParsedSource = + | { state: "empty" } + | { state: "offloaded" } + | { state: "parsed"; value: unknown }; + +/** + * Parse a raw payload/metadata/output packet on the client, respecting its + * content type. Never fetches: an offloaded (`application/store`) packet returns + * the `offloaded` state rather than its object-store path. A parse failure falls + * back to the raw string so a malformed value degrades to text, not a throw. + */ +export function parseSource(packet: SourcePacket): ParsedSource { + const { data, dataType } = packet; + if (data === null || data === undefined || data === "") { + return { state: "empty" }; + } + + const type = dataType ?? "application/json"; + if (type === "application/store") { + return { state: "offloaded" }; + } + + try { + switch (type) { + case "application/json": + return { state: "parsed", value: JSON.parse(data) }; + case "application/super+json": + return { state: "parsed", value: superjson.parse(data) }; + default: + return { state: "parsed", value: data }; + } + } catch { + return { state: "parsed", value: data }; + } +} + +export type SmartCellValue = + | { state: "empty" } + | { state: "offloaded" } + | { state: "value"; value: unknown }; + +export function extractSmartValue(parsed: ParsedSource, path: string): SmartCellValue { + if (parsed.state === "empty") return { state: "empty" }; + if (parsed.state === "offloaded") return { state: "offloaded" }; + + const value = getAtPath(parsed.value, path); + if (value === undefined) return { state: "empty" }; + return { state: "value", value }; +} + +const PATH_TOKEN_RE = /\.([^.[\]]+)|\[(\d+)\]|\['([^']*)'\]|\["([^"]*)"\]/g; + +/** + * Read a value out of a parsed object with dot/bracket notation. Accepts a + * leading `$`, dotted keys, and numeric or quoted bracket indices, e.g. + * `$.failed`, `suites[0].name`, `$['a.b'].c`. Returns undefined when any + * segment is missing. + */ +export function getAtPath(root: unknown, path: string): unknown { + let normalized = path.trim(); + if (normalized.startsWith("$")) normalized = normalized.slice(1); + if (normalized.length === 0) return root; + if (!normalized.startsWith(".") && !normalized.startsWith("[")) { + normalized = `.${normalized}`; + } + + const tokens: (string | number)[] = []; + let lastIndex = 0; + PATH_TOKEN_RE.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = PATH_TOKEN_RE.exec(normalized)) !== null) { + if (match.index !== lastIndex) return undefined; + lastIndex = PATH_TOKEN_RE.lastIndex; + + if (match[1] !== undefined) tokens.push(match[1]); + else if (match[2] !== undefined) tokens.push(Number(match[2])); + else if (match[3] !== undefined) tokens.push(match[3]); + else if (match[4] !== undefined) tokens.push(match[4]); + } + if (lastIndex !== normalized.length) return undefined; + + let current: unknown = root; + for (const token of tokens) { + if (current === null || current === undefined) return undefined; + if (typeof current !== "object") return undefined; + current = (current as Record)[token]; + } + return current; +} + +/** Default column label from a path: its last segment, or the raw path. */ +export function labelFromPath(path: string): string { + let normalized = path.trim(); + if (normalized.startsWith("$")) normalized = normalized.slice(1); + const segments = normalized.match(/[^.[\]'"]+/g); + return segments && segments.length > 0 ? segments[segments.length - 1] : path; +} + +export const SMART_SOURCE_DOT_COLOR: Record = { + payload: "bg-blue-500", + metadata: "bg-purple-500", + output: "bg-green-500", +}; + +export const SMART_SOURCE_LABEL: Record = { + payload: "Payload", + metadata: "Metadata", + output: "Output", +}; diff --git a/apps/webapp/vitest.config.ts b/apps/webapp/vitest.config.ts index 80ce4c0a275..0c36292e6cd 100644 --- a/apps/webapp/vitest.config.ts +++ b/apps/webapp/vitest.config.ts @@ -19,6 +19,7 @@ export default defineConfig({ "app/runEngine/services/**/*.test.ts", "app/utils/**/*.test.ts", "app/components/code/**/*.test.ts", + "app/components/runs/**/*.test.ts", "app/components/dashboard-agent/**/*.test.ts", "app/components/queues/**/*.test.ts", "app/routes/storybook.agent-ui/*.test.ts", From 7d519c14c0bd273e145aa8ae3c1cd7eb7746e8f7 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Mon, 17 Aug 2026 14:54:43 +0100 Subject: [PATCH 02/35] feat(webapp): derive the runs-list Postgres select from visible columns The list select is now built from the columns actually shown. A run's payload and output are large, so they are only hydrated when a smart column references them; everything else the presenter needs stays selected regardless. --- .../v3/NextRunListPresenter.server.ts | 24 +++++ .../v3/runColumnsFromRequest.server.ts | 29 ++++++ .../route.tsx | 2 + .../clickhouseRunsRepository.server.ts | 88 +++++++++++-------- .../runsRepository/runsRepository.server.ts | 23 ++++- 5 files changed, 126 insertions(+), 40 deletions(-) create mode 100644 apps/webapp/app/presenters/v3/runColumnsFromRequest.server.ts diff --git a/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts b/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts index c9a120334f3..52836fad293 100644 --- a/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/NextRunListPresenter.server.ts @@ -25,6 +25,11 @@ import { machinePresetFromRun } from "~/v3/machinePresets.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; import { isCancellableRunStatus, isFinalRunStatus, isPendingRunStatus } from "~/v3/taskStatus"; import { runTriggeredAt } from "~/v3/runTimestamps"; +import { + deriveRunSelect, + type RunColumnId, + type SmartColumnSource, +} from "~/components/runs/v3/runColumns"; // Positive-only cache: only envs known to have runs are stored (empty envs are re-checked), // so "has runs" is monotonic and the TTL can be very long. Tiered memory + Redis. @@ -81,6 +86,15 @@ export type RunListOptions = { pageSize?: number; // Run the empty-state "has any run ever" probe. Only the runs list consumes it. includeHasAnyRuns?: boolean; + /** + * Visible-column set used to derive the Postgres select. Omitted => the + * default select (all fields, no payload/output). Provided by the list route + * so payload/output are only hydrated when a smart column references them. + */ + columns?: { + visibleStandardIds: RunColumnId[]; + smartSources: SmartColumnSource[]; + }; }; const DEFAULT_PAGE_SIZE = 25; @@ -159,6 +173,7 @@ export class NextRunListPresenter { cursor, pageSize = DEFAULT_PAGE_SIZE, includeHasAnyRuns = false, + columns, }: RunListOptions ) { //get the time values from the raw values (including a default period) @@ -255,7 +270,12 @@ export class NextRunListPresenter { return date > now ? now : date; } + const runSelect = columns + ? deriveRunSelect(columns.visibleStandardIds, columns.smartSources) + : undefined; + const { runs, pagination } = await runsRepository.listRuns({ + runSelect, organizationId, environmentId, projectId, @@ -335,6 +355,10 @@ export class NextRunListPresenter { rootTaskRunId: run.rootTaskRunId, metadata: run.metadata, metadataType: run.metadataType, + payload: run.payload, + payloadType: run.payloadType, + output: run.output, + outputType: run.outputType, machinePreset: run.machinePreset ? machinePresetFromRun(run)?.name : undefined, queue: { name: run.queue.replace("task/", ""), diff --git a/apps/webapp/app/presenters/v3/runColumnsFromRequest.server.ts b/apps/webapp/app/presenters/v3/runColumnsFromRequest.server.ts new file mode 100644 index 00000000000..c3310b11d51 --- /dev/null +++ b/apps/webapp/app/presenters/v3/runColumnsFromRequest.server.ts @@ -0,0 +1,29 @@ +import { + resolveColumnLayout, + visibleSmartSources, + visibleStandardIds, + type RunColumnId, + type SmartColumnSource, +} from "~/components/runs/v3/runColumns"; + +/** + * Read the runs-list column state (`cols`/`sc`) off the request and resolve the + * column set the presenter needs to derive its Postgres select. Gates are + * resolved permissively here because they do not affect the always-selected + * fields; only the referenced smart-column sources change what is hydrated. + */ +export function getRunColumnsForSelect(request: Request): { + visibleStandardIds: RunColumnId[]; + smartSources: SmartColumnSource[]; +} { + const url = new URL(request.url); + const layout = resolveColumnLayout( + { cols: url.searchParams.getAll("cols"), sc: url.searchParams.getAll("sc") }, + { isManagedCloud: true, isDevelopment: false } + ); + + return { + visibleStandardIds: visibleStandardIds(layout.visible), + smartSources: visibleSmartSources(layout.visible), + }; +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx index 50bfb36a583..2a090b8efa1 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx @@ -52,6 +52,7 @@ import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { getRunFiltersFromRequest } from "~/presenters/RunFilters.server"; import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server"; +import { getRunColumnsForSelect } from "~/presenters/v3/runColumnsFromRequest.server"; import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server"; import { setRootOnlyFilterPreference, @@ -123,6 +124,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { projectId: project.id, ...filters, includeHasAnyRuns: true, + columns: getRunColumnsForSelect(request), }); // Only persist rootOnly when no tasks are filtered. While a task filter is active, diff --git a/apps/webapp/app/services/runsRepository/clickhouseRunsRepository.server.ts b/apps/webapp/app/services/runsRepository/clickhouseRunsRepository.server.ts index 808e30431ea..1150354398b 100644 --- a/apps/webapp/app/services/runsRepository/clickhouseRunsRepository.server.ts +++ b/apps/webapp/app/services/runsRepository/clickhouseRunsRepository.server.ts @@ -15,9 +15,51 @@ import { decodeRunsCursor, encodeRunsCursor } from "./runsCursor.server"; import { runStore } from "~/v3/runStore.server"; import { type PrismaClientOrTransaction } from "~/db.server"; -import { boundedIn } from "@trigger.dev/database"; +import { boundedIn, type Prisma } from "@trigger.dev/database"; +import { type ListedRun } from "./runsRepository.server"; type RunCursorRow = { runId: string; createdAt: number }; +/** + * Default hydrate select for the runs list, used when a caller does not derive + * one from the visible columns (bulk actions, the live poll). Kept in sync with + * the `ListedRun` payload type. + */ +const LIST_RUN_DEFAULT_SELECT = { + id: true, + friendlyId: true, + taskIdentifier: true, + taskVersion: true, + runtimeEnvironmentId: true, + status: true, + createdAt: true, + queueTimestamp: true, + scheduleId: true, + startedAt: true, + lockedAt: true, + delayUntil: true, + updatedAt: true, + completedAt: true, + isTest: true, + spanId: true, + idempotencyKey: true, + ttl: true, + expiredAt: true, + costInCents: true, + baseCostInCents: true, + usageDurationMs: true, + runTags: true, + depth: true, + rootTaskRunId: true, + batchId: true, + metadata: true, + metadataType: true, + machinePreset: true, + queue: true, + workerQueue: true, + region: true, + annotations: true, +} satisfies Prisma.TaskRunSelect; + /** * Hydrates a set of rows for a ClickHouse-derived run-id set against the given * read client. The closure MUST select `id` so `#hydrateRunsByIds` can key @@ -264,7 +306,11 @@ export class ClickHouseRunsRepository implements IRunsRepository { const store = this.options.runStore ?? runStore; - let runs = await this.#hydrateRunsByIds(runIds, (client, ids) => + const select: Prisma.TaskRunSelect = options.runSelect + ? { ...options.runSelect, id: true } + : LIST_RUN_DEFAULT_SELECT; + + let runs = await this.#hydrateRunsByIds(runIds, (client, ids) => store.findRuns( { where: { @@ -272,44 +318,10 @@ export class ClickHouseRunsRepository implements IRunsRepository { in: boundedIn(ids), }, }, - select: { - id: true, - friendlyId: true, - taskIdentifier: true, - taskVersion: true, - runtimeEnvironmentId: true, - status: true, - createdAt: true, - queueTimestamp: true, - scheduleId: true, - startedAt: true, - lockedAt: true, - delayUntil: true, - updatedAt: true, - completedAt: true, - isTest: true, - spanId: true, - idempotencyKey: true, - ttl: true, - expiredAt: true, - costInCents: true, - baseCostInCents: true, - usageDurationMs: true, - runTags: true, - depth: true, - rootTaskRunId: true, - batchId: true, - metadata: true, - metadataType: true, - machinePreset: true, - queue: true, - workerQueue: true, - region: true, - annotations: true, - }, + select, }, client - ) + ) as Promise ); // ClickHouse is slightly delayed, so we're going to do in-memory status filtering too diff --git a/apps/webapp/app/services/runsRepository/runsRepository.server.ts b/apps/webapp/app/services/runsRepository/runsRepository.server.ts index 431ed271883..2ff54b948ab 100644 --- a/apps/webapp/app/services/runsRepository/runsRepository.server.ts +++ b/apps/webapp/app/services/runsRepository/runsRepository.server.ts @@ -127,9 +127,28 @@ export type ListedRun = Prisma.TaskRunGetPayload<{ region: true; annotations: true; }; -}>; +}> & { + /** + * Large source blobs hydrated only when a smart column references them (see + * `runSelect`). Absent from the default list select. + */ + payload?: string; + payloadType?: string; + output?: string | null; + outputType?: string; +}; -export type ListRunsOptions = RunListInputOptions & Pagination; +export type ListRunsOptions = RunListInputOptions & + Pagination & { + /** + * Overrides the default list `select`. The runs list derives this from the + * visible columns so only the fields a shown column needs are hydrated (in + * particular payload/output are omitted unless a smart column asks). Must + * include `id` for hydration keying; behaviour-critical fields are enforced + * by the caller's `deriveRunSelect`. + */ + runSelect?: Prisma.TaskRunSelect; + }; export type TagListOptions = { organizationId: string; From a33890ac6c30e5256ca7644205b7b9325e3d4433 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Mon, 17 Aug 2026 14:54:54 +0100 Subject: [PATCH 03/35] feat(webapp): column display options and smart columns on the runs list Adds a Display popover to show/hide and reorder columns, and lets you add "smart columns" that pull a JSON value out of a run's payload, metadata, or output. Column choices live in the URL. ID, Task, and Status can be reordered but not hidden. Smart columns are display-only; offloaded or missing values render a clear placeholder. --- .../runs/v3/AddSmartColumnDialog.tsx | 276 ++++++ .../app/components/runs/v3/RunFilters.tsx | 2 + .../components/runs/v3/RunsDisplayOptions.tsx | 238 +++++ .../app/components/runs/v3/TaskRunsTable.tsx | 865 ++++++++++++------ ....env.$envParam.runs.smart-column-sample.ts | 68 ++ 5 files changed, 1150 insertions(+), 299 deletions(-) create mode 100644 apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx create mode 100644 apps/webapp/app/components/runs/v3/RunsDisplayOptions.tsx create mode 100644 apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.smart-column-sample.ts diff --git a/apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx b/apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx new file mode 100644 index 00000000000..bb1cf3fd262 --- /dev/null +++ b/apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx @@ -0,0 +1,276 @@ +import { useEffect, useMemo, useState } from "react"; +import { useTypedFetcher } from "remix-typedjson"; +import { Button } from "~/components/primitives/Buttons"; +import { Callout } from "~/components/primitives/Callout"; +import { Dialog, DialogContent, DialogHeader } from "~/components/primitives/Dialog"; +import { Input } from "~/components/primitives/Input"; +import { Label } from "~/components/primitives/Label"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import SegmentedControl from "~/components/primitives/SegmentedControl"; +import { Switch } from "~/components/primitives/Switch"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { cn } from "~/utils/cn"; +import { + SMART_COLUMN_DISPLAYS, + SMART_COLUMN_SOURCES, + type SmartColumnDef, + type SmartColumnDisplay, + type SmartColumnSource, +} from "./runColumns"; +import { + extractSmartValue, + labelFromPath, + parseSource, + SMART_SOURCE_DOT_COLOR, +} from "./smartColumnData"; +import type { loader as sampleLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.smart-column-sample"; + +type AddSmartColumnDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + onAdd: (def: SmartColumnDef) => void; + currentSearch: string; +}; + +const SOURCE_OPTIONS = SMART_COLUMN_SOURCES.map((source) => ({ + label: source.charAt(0).toUpperCase() + source.slice(1), + value: source, +})); + +const DISPLAY_OPTIONS = SMART_COLUMN_DISPLAYS.map((display) => ({ + label: display.charAt(0).toUpperCase() + display.slice(1), + value: display, +})); + +export function AddSmartColumnDialog({ + open, + onOpenChange, + onAdd, + currentSearch, +}: AddSmartColumnDialogProps) { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + const sample = useTypedFetcher(); + + const [source, setSource] = useState("metadata"); + const [path, setPath] = useState(""); + const [label, setLabel] = useState(""); + const [labelEdited, setLabelEdited] = useState(false); + const [displayAs, setDisplayAs] = useState("text"); + + const sampleUrl = useMemo(() => { + const base = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/runs/smart-column-sample`; + return currentSearch ? `${base}?${currentSearch.replace(/^\?/, "")}` : base; + }, [organization.slug, project.slug, environment.slug, currentSearch]); + + useEffect(() => { + if (open && sample.state === "idle" && sample.data === undefined) { + sample.load(sampleUrl); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, sampleUrl]); + + const effectiveLabel = labelEdited ? label : labelFromPath(path); + + const sampleRun = sample.data?.run ?? null; + + const parsed = useMemo(() => { + if (!sampleRun) return undefined; + switch (source) { + case "payload": + return parseSource({ data: sampleRun.payload, dataType: sampleRun.payloadType }); + case "metadata": + return parseSource({ data: sampleRun.metadata, dataType: sampleRun.metadataType }); + case "output": + return parseSource({ data: sampleRun.output, dataType: sampleRun.outputType }); + } + }, [sampleRun, source]); + + const sampleJson = useMemo(() => { + if (!parsed) return undefined; + if (parsed.state === "offloaded") return "// offloaded to object storage"; + if (parsed.state === "empty") return "// no value for this run"; + try { + return JSON.stringify(parsed.value, null, 2); + } catch { + return String(parsed.value); + } + }, [parsed]); + + const resolved = useMemo(() => { + if (!parsed || path.trim().length === 0) return undefined; + return extractSmartValue(parsed, path); + }, [parsed, path]); + + const canAdd = path.trim().length > 0; + + const reset = () => { + setSource("metadata"); + setPath(""); + setLabel(""); + setLabelEdited(false); + setDisplayAs("text"); + }; + + const handleAdd = () => { + if (!canAdd) return; + onAdd({ source, path: path.trim(), label: effectiveLabel.trim() || path.trim(), displayAs }); + reset(); + onOpenChange(false); + }; + + return ( + { + if (!next) reset(); + onOpenChange(next); + }} + > + + Add smart column +
+
+ + setSource(value as SmartColumnSource)} + fullWidth + /> + + Metadata is what the run writes about itself while it runs, so it has a value before + the run ends. Payload is what you triggered it with; output is what it returned. + +
+ +
+
+ + setPath(e.target.value)} + placeholder="$.failed" + spellCheck={false} + /> + + Dot and bracket notation, e.g. $.failed or{" "} + $.suites[0].name. + +
+
+ + { + setLabel(e.target.value); + setLabelEdited(true); + }} + placeholder={labelFromPath(path)} + /> + + Defaults to the last part of the path. Rename it to anything you like. + +
+
+ +
+ + setDisplayAs(value as SmartColumnDisplay)} + fullWidth + /> + + Number right-aligns the column and uses tabular figures. Anything that doesn't parse + falls back to text. + +
+ +
+
+ + Sample — {source} of the newest run + +
+                {sample.state === "loading"
+                  ? "Loading…"
+                  : sampleRun
+                    ? sampleJson
+                    : "// no runs to sample"}
+              
+
+
+ Resolves to + + {sampleRun && ( + + Against {sampleRun.friendlyId} + {sampleRun.hasFinished ? "" : " · still running"} + + )} +
+
+ +
+ + + + Both off, and not switchable + +
+ + + Display only. A smart column shows you a value, but you can't sort or filter the list by + it. To narrow the list, use tags or the query editor. + +
+
+ + +
+
+
+ ); +} + +function SmartColumnResolvedPreview({ + source, + label, + resolved, +}: { + source: SmartColumnSource; + label: string; + resolved: ReturnType | undefined; +}) { + let value: string; + if (!resolved) value = "–"; + else if (resolved.state === "offloaded") value = "Too large"; + else if (resolved.state === "empty") value = "–"; + else if (typeof resolved.value === "object") value = JSON.stringify(resolved.value); + else value = String(resolved.value); + + return ( +
+
+ + {label || "Column"} +
+
{value}
+
+ ); +} diff --git a/apps/webapp/app/components/runs/v3/RunFilters.tsx b/apps/webapp/app/components/runs/v3/RunFilters.tsx index 7dd6e7d9a61..1e84b3bc260 100644 --- a/apps/webapp/app/components/runs/v3/RunFilters.tsx +++ b/apps/webapp/app/components/runs/v3/RunFilters.tsx @@ -68,6 +68,7 @@ import { type loader as versionsLoader } from "~/routes/resources.orgs.$organiza import { makeFriendlyIdValidator } from "~/utils/friendlyId"; import { Button } from "../../primitives/Buttons"; import { AIFilterInput } from "./AIFilterInput"; +import { RunsDisplayOptions } from "./RunsDisplayOptions"; import { BulkActionTypeCombo } from "./BulkAction"; import { RegionLabel } from "./RegionLabel"; import { @@ -415,6 +416,7 @@ export function RunsFilters(props: RunFiltersProps) { /> )} + ); } diff --git a/apps/webapp/app/components/runs/v3/RunsDisplayOptions.tsx b/apps/webapp/app/components/runs/v3/RunsDisplayOptions.tsx new file mode 100644 index 00000000000..a3ceb28232d --- /dev/null +++ b/apps/webapp/app/components/runs/v3/RunsDisplayOptions.tsx @@ -0,0 +1,238 @@ +import { ArrowUturnLeftIcon, PlusIcon, ViewColumnsIcon } from "@heroicons/react/20/solid"; +import { GripVerticalIcon } from "lucide-react"; +import { useMemo, useState } from "react"; +import { Button } from "~/components/primitives/Buttons"; +import { Checkbox } from "~/components/primitives/Checkbox"; +import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { useFeatures } from "~/hooks/useFeatures"; +import { useOptimisticLocation } from "~/hooks/useOptimisticLocation"; +import { useSearchParams } from "~/hooks/useSearchParam"; +import { cn } from "~/utils/cn"; +import { + availableStandardColumns, + encodeColumnLayout, + resolveColumnLayout, + type ResolvedColumn, + type RunColumnRuntime, + type SmartColumnDef, +} from "./runColumns"; +import { AddSmartColumnDialog } from "./AddSmartColumnDialog"; +import { SMART_SOURCE_DOT_COLOR } from "./smartColumnData"; + +function keyFor(col: ResolvedColumn): string { + return col.kind === "standard" ? `std:${col.def.id}` : `smart:${col.index}`; +} + +export function RunsDisplayOptions() { + const environment = useEnvironment(); + const { isManagedCloud } = useFeatures(); + const location = useOptimisticLocation(); + const { values, replace } = useSearchParams(); + const [addOpen, setAddOpen] = useState(false); + const [dragKey, setDragKey] = useState(null); + + const runtime: RunColumnRuntime = { + isManagedCloud, + isDevelopment: environment.type === "DEVELOPMENT", + }; + + const cols = values("cols"); + const sc = values("sc"); + const layout = useMemo( + () => resolveColumnLayout({ cols, sc }, runtime), + // eslint-disable-next-line react-hooks/exhaustive-deps + [cols.join(" "), sc.join(" "), runtime.isManagedCloud, runtime.isDevelopment] + ); + + const available = availableStandardColumns(runtime); + const visibleStandardCount = layout.visible.filter((c) => c.kind === "standard").length; + const smartCount = layout.visible.filter((c) => c.kind === "smart").length; + + const applyVisible = (nextVisible: ResolvedColumn[]) => { + const encoded = encodeColumnLayout(nextVisible, runtime); + replace({ + cols: encoded.cols.length > 0 ? encoded.cols : undefined, + sc: encoded.sc.length > 0 ? encoded.sc : undefined, + }); + }; + + const hideStandard = (id: string) => { + applyVisible(layout.visible.filter((c) => !(c.kind === "standard" && c.def.id === id))); + }; + + const showStandard = (id: string) => { + const def = available.find((c) => c.id === id); + if (!def) return; + applyVisible([...layout.visible, { kind: "standard", def }]); + }; + + const removeSmart = (index: number) => { + applyVisible(layout.visible.filter((c) => !(c.kind === "smart" && c.index === index))); + }; + + const addSmart = (def: SmartColumnDef) => { + const nextIndex = layout.smartColumns.length; + applyVisible([...layout.visible, { kind: "smart", index: nextIndex, def }]); + }; + + const reset = () => replace({ cols: undefined, sc: undefined }); + + const reorder = (fromKey: string, toKey: string) => { + if (fromKey === toKey) return; + const arr = [...layout.visible]; + const from = arr.findIndex((c) => keyFor(c) === fromKey); + const to = arr.findIndex((c) => keyFor(c) === toKey); + if (from < 0 || to < 0) return; + const [moved] = arr.splice(from, 1); + arr.splice(to, 0, moved); + applyVisible(arr); + }; + + return ( + <> + + + + + +
+ Columns + + {visibleStandardCount} of {available.length} + +
+
+ {layout.visible.map((col) => ( + setDragKey(keyFor(col))} + onDragEnd={() => setDragKey(null)} + onDrop={() => { + if (dragKey) reorder(dragKey, keyFor(col)); + setDragKey(null); + }} + onToggle={() => { + if (col.kind === "smart") removeSmart(col.index); + else if (!col.def.locked) hideStandard(col.def.id); + }} + /> + ))} + {layout.hiddenStandard.map((def) => ( + showStandard(def.id)} + /> + ))} +
+
+ + +
+
+
+ + + ); +} + +function ColumnRow({ + col, + checked, + draggable, + locked, + dragging, + onToggle, + onDragStart, + onDragEnd, + onDrop, +}: { + col: ResolvedColumn; + checked: boolean; + draggable: boolean; + locked: boolean; + dragging: boolean; + onToggle: () => void; + onDragStart?: () => void; + onDragEnd?: () => void; + onDrop?: () => void; +}) { + const isSmart = col.kind === "smart"; + const label = col.def.label; + const isDuration = col.kind === "standard" && col.def.id === "dur"; + + return ( +
{ + if (draggable) e.preventDefault(); + }} + onDrop={onDrop} + > + {locked ? ( + + ) : ( + + )} + {isSmart && ( + + )} + + {label} + + {isDuration && 3 cells} + {draggable ? ( + + ) : ( +
+ )} +
+ ); +} diff --git a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx index ddb7220213c..2ea17e3f88d 100644 --- a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx +++ b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx @@ -9,7 +9,7 @@ import { import { BookOpenIcon, CheckIcon } from "@heroicons/react/24/solid"; import { useLocation } from "@remix-run/react"; import { formatDuration, formatDurationMilliseconds } from "@trigger.dev/core/v3"; -import { useCallback, useRef } from "react"; +import { useCallback, useMemo, useRef } from "react"; import { TasksIcon } from "~/assets/icons/TasksIcon"; import { MachineLabelCombo } from "~/components/MachineLabelCombo"; import { MachineTooltipInfo } from "~/components/MachineTooltipInfo"; @@ -33,6 +33,7 @@ import { } from "~/presenters/v3/NextRunListPresenter.server"; import { formatCurrencyAccurate } from "~/utils/numberFormatter"; import { docsPath, v3RunSpanPath, v3TestPath, v3TestTaskPath } from "~/utils/pathBuilder"; +import { cn } from "~/utils/cn"; import { DateTime } from "../../primitives/DateTime"; import { Paragraph } from "../../primitives/Paragraph"; import { Spinner } from "../../primitives/Spinner"; @@ -63,6 +64,20 @@ import { useOptimisticLocation } from "~/hooks/useOptimisticLocation"; import { useSearchParams } from "~/hooks/useSearchParam"; import type { TaskTriggerSource } from "@trigger.dev/database"; import { BeakerIcon } from "~/assets/icons/BeakerIcon"; +import { + resolveColumnLayout, + visibleSmartSources, + type ResolvedColumn, + type RunColumnRuntime, + type SmartColumnDef, + type SmartColumnSource, +} from "./runColumns"; +import { + extractSmartValue, + parseSource, + SMART_SOURCE_DOT_COLOR, + type ParsedSource, +} from "./smartColumnData"; type RunsTableProps = { total: number; @@ -89,6 +104,520 @@ type RunsTableProps = { canReplayRuns?: boolean; }; +type CellRenderContext = { + run: NextRunListItem; + path: string; + regionByMasterQueue: Map; + childrenStatusesBasePath?: string; + sources: Partial>; +}; + +type StandardColumnRenderer = { + header: React.ReactNode; + cell: (ctx: CellRenderContext) => React.ReactNode; + /** Cells/header this column occupies (Duration renders three). */ + span: number; +}; + +const STANDARD_RENDERERS: Record = { + id: { + span: 1, + header: ID, + cell: ({ run, path }) => ( + + + + ), + }, + task: { + span: 1, + header: Task, + cell: ({ run, path }) => ( + + + + {run.taskIdentifier} + {run.rootTaskRunId === null ? Root : null} + + + ), + }, + ver: { + span: 1, + header: Version, + cell: ({ run, path }) => {run.version ?? "–"}, + }, + status: { + span: 1, + header: ( + + {filterableTaskRunStatuses.map((status) => ( +
+
+ +
+ + {descriptionForTaskRunStatus(status)} + +
+ ))} +
+ } + > + Status + + ), + cell: ({ run, path, childrenStatusesBasePath }) => ( + + {run.rootTaskRunId === null && childrenStatusesBasePath ? ( + + ) : ( + } + /> + )} + + ), + }, + started: { + span: 1, + header: Started, + cell: ({ run, path }) => ( + {run.startedAt ? : "–"} + ), + }, + dur: { + span: 3, + header: ( + +
+
+ + Queued duration +
+ + The amount of time from when the run was created to it starting to run. + +
+
+
+ Run duration +
+ + The total amount of time from the run starting to it finishing. This includes all + time spent waiting. + +
+
+
+ + Compute duration +
+ + The amount of compute time used in the run. This does not include time spent + waiting. + +
+ + } + > + Duration +
+ ), + cell: ({ run, path }) => ( + <> + +
+ + {run.isPending ? ( + "–" + ) : run.startedAt ? ( + formatDuration(new Date(run.triggeredAt), new Date(run.startedAt), { + style: "short", + }) + ) : run.isCancellable ? ( + + ) : ( + formatDuration(new Date(run.triggeredAt), new Date(run.updatedAt), { + style: "short", + }) + )} +
+
+ +
+ + {run.startedAt && run.finishedAt ? ( + formatDuration(new Date(run.startedAt), new Date(run.finishedAt), { + style: "short", + }) + ) : run.startedAt ? ( + + ) : ( + "–" + )} +
+
+ +
+ + {run.usageDurationMs > 0 + ? formatDurationMilliseconds(run.usageDurationMs, { + style: "short", + }) + : "–"} +
+
+ + ), + }, + compute: { + span: 1, + header: Compute, + cell: ({ run, path }) => ( + + {run.costInCents > 0 + ? formatCurrencyAccurate((run.costInCents + run.baseCostInCents) / 100) + : "–"} + + ), + }, + machine: { + span: 1, + header: ( + }> + Machine + + ), + cell: ({ run, path }) => ( + + + + ), + }, + queue: { + span: 1, + header: Queue, + cell: ({ run, path }) => ( + + {run.queue.type === "task" ? ( + + + {run.queue.name} + + } + content={`This queue was automatically created from your "${run.queue.name}" task`} + disableHoverableContent + /> + ) : ( + + + {run.queue.name} + + } + content={`This is a custom queue you added in your code.`} + disableHoverableContent + /> + )} + + ), + }, + region: { + span: 1, + header: Region, + cell: ({ run, path, regionByMasterQueue }) => ( + + {run.region ? ( + + ) : ( + "–" + )} + + ), + }, + test: { + span: 1, + header: Test, + cell: ({ run, path }) => ( + + {run.isTest ? ( + + ) : ( + "–" + )} + + ), + }, + created: { + span: 1, + header: Created at, + cell: ({ run, path }) => ( + {run.createdAt ? : "–"} + ), + }, + delayed: { + span: 1, + header: ( + + + When you want to trigger a task now, but have it run at a later time, you can use the + delay option. + + + Runs that are delayed and have not been enqueued yet will display in the dashboard + with a “Delayed” status. + + + Read docs + + + } + > + Delayed until + + ), + cell: ({ run, path }) => ( + {run.delayUntil ? : "–"} + ), + }, + ttl: { + span: 1, + header: ( + + + You can set a TTL (time to live) when triggering a task, which will automatically + expire the run if it hasn’t started within the specified time. + + + All runs in development have a default ttl of 10 minutes. You can disable this by + setting the ttl option. + + + Read docs + + + } + > + TTL + + ), + cell: ({ run, path }) => {run.ttl ?? "–"}, + }, + tags: { + span: 1, + header: ( + + + You can add tags to a run and then filter runs using them. + + + You can add tags when triggering a run or inside the run function. + + + Read docs + + + } + > + Tags + + ), + cell: ({ run, path }) => ( + +
+ {run.tags.map((tag) => ) || "–"} +
+
+ ), + }, +}; + +function SmartColumnHeader({ def }: { def: SmartColumnDef }) { + return ( + + + + {def.label} + + + ); +} + +function stringifySmartValue(value: unknown): string { + if (value === null) return "null"; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") return String(value); + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +function renderSmartValue(value: unknown, def: SmartColumnDef): React.ReactNode { + switch (def.displayAs) { + case "number": { + const n = typeof value === "number" ? value : Number(value); + return Number.isFinite(n) ? n.toLocaleString() : stringifySmartValue(value); + } + case "duration": { + const n = typeof value === "number" ? value : Number(value); + return Number.isFinite(n) + ? formatDurationMilliseconds(n, { style: "short" }) + : stringifySmartValue(value); + } + case "badge": + return {stringifySmartValue(value)}; + default: + return stringifySmartValue(value); + } +} + +function SmartColumnCell({ + def, + run, + path, + parsed, +}: { + def: SmartColumnDef; + run: NextRunListItem; + path: string; + parsed: ParsedSource | undefined; +}) { + const numeric = def.displayAs === "number" || def.displayAs === "duration"; + const cell = extractSmartValue(parsed ?? { state: "empty" }, def.path); + + if (cell.state === "offloaded") { + return ( + + + Too large + + } + content={`This run's ${def.source} is offloaded to object storage instead of the run row. Open the run to read it.`} + /> + + ); + } + + if (cell.state === "empty") { + return ( + + – + + ); + } + + const provisional = !run.hasFinished; + + return ( + + + {renderSmartValue(cell.value, def)} + + + ); +} + +function buildRowSources( + run: NextRunListItem, + sources: SmartColumnSource[] +): Partial> { + const result: Partial> = {}; + for (const source of sources) { + switch (source) { + case "payload": + result.payload = parseSource({ data: run.payload, dataType: run.payloadType }); + break; + case "metadata": + result.metadata = parseSource({ data: run.metadata, dataType: run.metadataType }); + break; + case "output": + result.output = parseSource({ data: run.output, dataType: run.outputType }); + break; + } + } + return result; +} + +function columnKey(col: ResolvedColumn): string { + return col.kind === "standard" ? `std:${col.def.id}` : `smart:${col.index}`; +} + +function ColumnHeader({ column }: { column: ResolvedColumn }) { + if (column.kind === "smart") { + return ; + } + return <>{STANDARD_RENDERERS[column.def.id]?.header ?? null}; +} + +function ColumnCell({ column, ctx }: { column: ResolvedColumn; ctx: CellRenderContext }) { + if (column.kind === "smart") { + return ( + + ); + } + return <>{STANDARD_RENDERERS[column.def.id]?.cell(ctx) ?? null}; +} + export function TaskRunsTable({ total, hasFilters, @@ -114,7 +643,7 @@ export function TaskRunsTable({ const checkboxes = useRef<(HTMLInputElement | null)[]>([]); const { has, hasAll, select, deselect, toggle } = useSelectedItems(allowSelection); const { isManagedCloud } = useFeatures(); - const { value } = useSearchParams(); + const { value, values } = useSearchParams(); const location = useOptimisticLocation(); const params = new URLSearchParams(location.search || ""); if (!value("rootOnly")) { @@ -129,8 +658,25 @@ export function TaskRunsTable({ /** TableState has to be encoded as a separate URI component, so it's merged under one, 'tableState' param */ const tableStateParam = disableAdjacentRows ? "" : encodeURIComponent(search); - const showCompute = isManagedCloud; - const showRegion = environment.type !== "DEVELOPMENT"; + const isDevelopment = environment.type === "DEVELOPMENT"; + const colsFromUrl = values("cols"); + const scFromUrl = values("sc"); + const colsKey = colsFromUrl.join(" "); + const scKey = scFromUrl.join(" "); + const layout = useMemo(() => { + const runtime: RunColumnRuntime = { isManagedCloud, isDevelopment }; + return resolveColumnLayout({ cols: colsFromUrl, sc: scFromUrl }, runtime); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [colsKey, scKey, isManagedCloud, isDevelopment]); + + const visibleColumns = layout.visible; + const referencedSources = useMemo(() => visibleSmartSources(visibleColumns), [visibleColumns]); + + const dataColSpan = visibleColumns.reduce( + (sum, col) => sum + (col.kind === "standard" ? (STANDARD_RENDERERS[col.def.id]?.span ?? 1) : 1), + 0 + ); + const totalColSpan = (allowSelection ? 1 : 0) + dataColSpan + 1; const navigateCheckboxes = useCallback( (event: React.KeyboardEvent, index: number) => { @@ -189,152 +735,9 @@ export function TaskRunsTable({ )} )} - ID - Task - Version - - {filterableTaskRunStatuses.map((status) => ( -
-
- -
- - {descriptionForTaskRunStatus(status)} - -
- ))} - - } - > - Status -
- Started - -
-
- - Queued duration -
- - The amount of time from when the run was created to it starting to run. - -
-
-
- Run duration -
- - The total amount of time from the run starting to it finishing. This includes - all time spent waiting. - -
-
-
- - Compute duration -
- - The amount of compute time used in the run. This does not include time spent - waiting. - -
- - } - > - Duration -
- {showCompute && ( - <> - Compute - - )} - }> - Machine - - Queue - {showRegion && Region} - Test - Created at - - - When you want to trigger a task now, but have it run at a later time, you can use - the delay option. - - - Runs that are delayed and have not been enqueued yet will display in the dashboard - with a “Delayed” status. - - - Read docs - - - } - > - Delayed until - - - - You can set a TTL (time to live) when triggering a task, which will automatically - expire the run if it hasn’t started within the specified time. - - - All runs in development have a default ttl of 10 minutes. You can disable this by - setting the ttl option. - - - Read docs - - - } - > - TTL - - - - You can add tags to a run and then filter runs using them. - - - You can add tags when triggering a run or inside the run function. - - - Read docs - - - } - > - Tags - + {visibleColumns.map((col) => ( + + ))} Go to page @@ -342,11 +745,11 @@ export function TaskRunsTable({ {total === 0 && !hasFilters ? ( - + {!isLoading && } ) : runs.length === 0 ? ( - + ) : ( runs.map((run, index) => { const searchParams = new URLSearchParams(); @@ -363,6 +766,7 @@ export function TaskRunsTable({ }, searchParams ); + const sources = buildRowSources(run, referencedSources); return ( {allowSelection && ( @@ -379,149 +783,13 @@ export function TaskRunsTable({ /> )} - - - - - - - {run.taskIdentifier} - {run.rootTaskRunId === null ? Root : null} - - - {run.version ?? "–"} - - {run.rootTaskRunId === null && childrenStatusesBasePath ? ( - - ) : ( - } - /> - )} - - - {run.startedAt ? : "–"} - - -
- - {run.isPending ? ( - "–" - ) : run.startedAt ? ( - formatDuration(new Date(run.triggeredAt), new Date(run.startedAt), { - style: "short", - }) - ) : run.isCancellable ? ( - - ) : ( - formatDuration(new Date(run.triggeredAt), new Date(run.updatedAt), { - style: "short", - }) - )} -
-
- -
- - {run.startedAt && run.finishedAt ? ( - formatDuration(new Date(run.startedAt), new Date(run.finishedAt), { - style: "short", - }) - ) : run.startedAt ? ( - - ) : ( - "–" - )} -
-
- -
- - {run.usageDurationMs > 0 - ? formatDurationMilliseconds(run.usageDurationMs, { - style: "short", - }) - : "–"} -
-
- {showCompute && ( - - {run.costInCents > 0 - ? formatCurrencyAccurate((run.costInCents + run.baseCostInCents) / 100) - : "–"} - - )} - - - - - {run.queue.type === "task" ? ( - - - {run.queue.name} - - } - content={`This queue was automatically created from your "${run.queue.name}" task`} - disableHoverableContent - /> - ) : ( - - - {run.queue.name} - - } - content={`This is a custom queue you added in your code.`} - disableHoverableContent - /> - )} - - {showRegion && ( - - {run.region ? ( - - ) : ( - "–" - )} - - )} - - {run.isTest ? ( - - ) : ( - "–" - )} - - - {run.createdAt ? : "–"} - - - {run.delayUntil ? : "–"} - - {run.ttl ?? "–"} - -
- {run.tags.map((tag) => ) || "–"} -
-
+ {visibleColumns.map((col) => ( + + ))} Loading… @@ -711,12 +979,11 @@ function NoRuns({ title }: { title: string }) { function BlankState({ isLoading, filters, - showRegion, -}: Pick & { showRegion: boolean }) { + colSpan, +}: Pick & { colSpan: number }) { const organization = useOrganization(); const project = useProject(); const environment = useEnvironment(); - const colSpan = showRegion ? 16 : 15; if (isLoading) return ; const { tasks, from, to, ...otherFilters } = filters; diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.smart-column-sample.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.smart-column-sample.ts new file mode 100644 index 00000000000..b0343d98154 --- /dev/null +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.smart-column-sample.ts @@ -0,0 +1,68 @@ +import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { deriveRunSelect } from "~/components/runs/v3/runColumns"; +import { getRunFiltersFromRequest } from "~/presenters/RunFilters.server"; +import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server"; +import { loadProjectEnvironmentFromRequest } from "~/services/loadProjectEnvironmentFromRequest.server"; +import { RunsRepository } from "~/services/runsRepository/runsRepository.server"; +import { $replica } from "~/db.server"; +import { isFinalRunStatus } from "~/v3/taskStatus"; + +/** + * Newest run for the current filters, with its raw payload/metadata/output + * packets, feeding the "Add smart column" live preview. The client parses and + * resolves the JSON path; the server never parses (same rule as the list). + */ +export async function loader({ request, params }: LoaderFunctionArgs) { + const { project, environment } = await loadProjectEnvironmentFromRequest(request, params); + const filters = await getRunFiltersFromRequest(request); + + const clickhouse = await clickhouseFactory.getClickhouseForOrganization( + project.organizationId, + "runsList" + ); + const runsRepository = new RunsRepository({ clickhouse, prisma: $replica }); + + const { runs } = await runsRepository.listRuns({ + organizationId: project.organizationId, + projectId: project.id, + environmentId: environment.id, + tasks: filters.tasks, + versions: filters.versions, + statuses: filters.statuses, + tags: filters.tags, + scheduleId: filters.scheduleId, + period: filters.period, + from: filters.from, + to: filters.to, + rootOnly: filters.rootOnly, + batchId: filters.batchId, + runId: filters.runId, + bulkId: filters.bulkId, + queues: filters.queues, + machines: filters.machines, + errorId: filters.errorId, + runSelect: deriveRunSelect([], ["payload", "metadata", "output"]), + page: { size: 1 }, + }); + + const run = runs[0]; + if (!run) { + return { run: null }; + } + + return { + run: { + friendlyId: run.friendlyId, + status: run.status, + hasFinished: isFinalRunStatus(run.status), + startedAt: (run.startedAt ?? run.lockedAt)?.toISOString(), + createdAt: run.createdAt.toISOString(), + payload: run.payload, + payloadType: run.payloadType, + metadata: run.metadata, + metadataType: run.metadataType, + output: run.output, + outputType: run.outputType, + }, + }; +} From a071987ab0de5a7ce51fb20c5bf506a063e2fc91 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Mon, 17 Aug 2026 14:55:05 +0100 Subject: [PATCH 04/35] feat(webapp): keep smart-column values fresh in the runs live poll The 3s poll now carries the payload/metadata/output a smart column reads, so custom column values update in place instead of only on a full page load. --- .../app/presenters/v3/mapRunToLiveFields.server.ts | 6 ++++++ .../useRunsLiveReload.ts | 10 ++++++++++ ...g.projects.$projectParam.env.$envParam.runs.live.ts | 4 ++++ 3 files changed, 20 insertions(+) diff --git a/apps/webapp/app/presenters/v3/mapRunToLiveFields.server.ts b/apps/webapp/app/presenters/v3/mapRunToLiveFields.server.ts index ce7654cc1bc..25b3e5ad2b9 100644 --- a/apps/webapp/app/presenters/v3/mapRunToLiveFields.server.ts +++ b/apps/webapp/app/presenters/v3/mapRunToLiveFields.server.ts @@ -19,5 +19,11 @@ export function mapRunToLiveFields(run: ListedRun) { usageDurationMs: Number(run.usageDurationMs), costInCents: run.costInCents, baseCostInCents: run.baseCostInCents, + metadata: run.metadata, + metadataType: run.metadataType, + payload: run.payload, + payloadType: run.payloadType, + output: run.output, + outputType: run.outputType, }; } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/useRunsLiveReload.ts b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/useRunsLiveReload.ts index e443fb84cfb..c760f5814f8 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/useRunsLiveReload.ts +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/useRunsLiveReload.ts @@ -87,6 +87,12 @@ function patchVisibleRunsWithLiveUpdates(currentRuns: ListedRun[], liveRuns: Liv usageDurationMs: update.usageDurationMs, costInCents: update.costInCents, baseCostInCents: update.baseCostInCents, + metadata: update.metadata ?? run.metadata, + metadataType: update.metadataType ?? run.metadataType, + payload: update.payload ?? run.payload, + payloadType: update.payloadType ?? run.payloadType, + output: update.output ?? run.output, + outputType: update.outputType ?? run.outputType, }; }); } @@ -242,6 +248,10 @@ export function useRunsLiveReload({ searchParams.set("runIds", activeRunIdsParam); } + const locationParams = new URLSearchParams(location.search); + for (const col of locationParams.getAll("cols")) searchParams.append("cols", col); + for (const smart of locationParams.getAll("sc")) searchParams.append("sc", smart); + if (checkForNewRuns) { appendNewRunsSearchParams(searchParams, { locationSearch: location.search, diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.live.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.live.ts index 4c919827835..feae35161c8 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.live.ts +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.live.ts @@ -7,6 +7,8 @@ import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstan import { loadProjectEnvironmentFromRequest } from "~/services/loadProjectEnvironmentFromRequest.server"; import { RunsRepository } from "~/services/runsRepository/runsRepository.server"; import { runIdsQueryParam } from "~/utils/searchParams"; +import { deriveRunSelect } from "~/components/runs/v3/runColumns"; +import { getRunColumnsForSelect } from "~/presenters/v3/runColumnsFromRequest.server"; const SearchParamsSchema = z.object({ runIds: runIdsQueryParam, @@ -36,6 +38,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { "runsList" ); const runsRepository = new RunsRepository({ clickhouse, prisma: $replica }); + const columns = getRunColumnsForSelect(request); const [runs, newRunsResult] = await Promise.all([ runIds.length > 0 @@ -45,6 +48,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) { projectId: project.id, environmentId: environment.id, runId: runIds, + runSelect: deriveRunSelect(columns.visibleStandardIds, columns.smartSources), page: { size: 100 }, }) .then(({ runs: listedRuns }) => listedRuns.map(mapRunToLiveFields)) From 3e3d717d9379bdaa25082183703bd1cae5b8e166 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Mon, 17 Aug 2026 14:56:00 +0100 Subject: [PATCH 05/35] docs(webapp): add server-changes note for runs-list column customization --- .server-changes/runs-list-column-customization.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .server-changes/runs-list-column-customization.md diff --git a/.server-changes/runs-list-column-customization.md b/.server-changes/runs-list-column-customization.md new file mode 100644 index 00000000000..a069ca8ce73 --- /dev/null +++ b/.server-changes/runs-list-column-customization.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +Customize the runs list: show, hide, and reorder columns, and add smart columns that pull a value straight out of a run's payload, metadata, or output. Your column choices are saved in the page URL so you can share or bookmark a view. From 41c06a5304e8288eef893cc18ec5f29510cf0e6b Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Mon, 17 Aug 2026 15:11:34 +0100 Subject: [PATCH 06/35] feat(webapp): editable smart columns and display-options polish Smart columns can now be edited in place from the Display popover. Marks smart columns with a code-bracket icon instead of a source-colored dot, shows a drop indicator while reordering columns, drops the redundant Duration cell-count label, and keeps the Display button label constant. --- .../runs/v3/AddSmartColumnDialog.tsx | 68 +++++------- .../components/runs/v3/RunsDisplayOptions.tsx | 100 ++++++++++++------ .../app/components/runs/v3/TaskRunsTable.tsx | 12 +-- .../app/components/runs/v3/smartColumnData.ts | 13 --- 4 files changed, 99 insertions(+), 94 deletions(-) diff --git a/apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx b/apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx index bb1cf3fd262..5bc67b3fb4e 100644 --- a/apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx +++ b/apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx @@ -1,3 +1,4 @@ +import { CodeBracketIcon } from "@heroicons/react/20/solid"; import { useEffect, useMemo, useState } from "react"; import { useTypedFetcher } from "remix-typedjson"; import { Button } from "~/components/primitives/Buttons"; @@ -11,7 +12,6 @@ import { Switch } from "~/components/primitives/Switch"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; -import { cn } from "~/utils/cn"; import { SMART_COLUMN_DISPLAYS, SMART_COLUMN_SOURCES, @@ -19,18 +19,15 @@ import { type SmartColumnDisplay, type SmartColumnSource, } from "./runColumns"; -import { - extractSmartValue, - labelFromPath, - parseSource, - SMART_SOURCE_DOT_COLOR, -} from "./smartColumnData"; +import { extractSmartValue, labelFromPath, parseSource } from "./smartColumnData"; import type { loader as sampleLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.smart-column-sample"; type AddSmartColumnDialogProps = { open: boolean; + /** When set, the dialog edits this existing column instead of adding a new one. */ + editing: SmartColumnDef | null; onOpenChange: (open: boolean) => void; - onAdd: (def: SmartColumnDef) => void; + onSubmit: (def: SmartColumnDef) => void; currentSearch: string; }; @@ -46,8 +43,9 @@ const DISPLAY_OPTIONS = SMART_COLUMN_DISPLAYS.map((display) => ({ export function AddSmartColumnDialog({ open, + editing, onOpenChange, - onAdd, + onSubmit, currentSearch, }: AddSmartColumnDialogProps) { const organization = useOrganization(); @@ -61,6 +59,15 @@ export function AddSmartColumnDialog({ const [labelEdited, setLabelEdited] = useState(false); const [displayAs, setDisplayAs] = useState("text"); + useEffect(() => { + if (!open) return; + setSource(editing?.source ?? "metadata"); + setPath(editing?.path ?? ""); + setLabel(editing?.label ?? ""); + setLabelEdited(editing !== null); + setDisplayAs(editing?.displayAs ?? "text"); + }, [open, editing]); + const sampleUrl = useMemo(() => { const base = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/runs/smart-column-sample`; return currentSearch ? `${base}?${currentSearch.replace(/^\?/, "")}` : base; @@ -105,33 +112,18 @@ export function AddSmartColumnDialog({ return extractSmartValue(parsed, path); }, [parsed, path]); - const canAdd = path.trim().length > 0; + const canSubmit = path.trim().length > 0; - const reset = () => { - setSource("metadata"); - setPath(""); - setLabel(""); - setLabelEdited(false); - setDisplayAs("text"); - }; - - const handleAdd = () => { - if (!canAdd) return; - onAdd({ source, path: path.trim(), label: effectiveLabel.trim() || path.trim(), displayAs }); - reset(); + const handleSubmit = () => { + if (!canSubmit) return; + onSubmit({ source, path: path.trim(), label: effectiveLabel.trim() || path.trim(), displayAs }); onOpenChange(false); }; return ( - { - if (!next) reset(); - onOpenChange(next); - }} - > + - Add smart column + {editing ? "Edit smart column" : "Add smart column"}
@@ -208,11 +200,7 @@ export function AddSmartColumnDialog({
Resolves to - + {sampleRun && ( Against {sampleRun.friendlyId} @@ -239,8 +227,8 @@ export function AddSmartColumnDialog({ -
@@ -249,11 +237,9 @@ export function AddSmartColumnDialog({ } function SmartColumnResolvedPreview({ - source, label, resolved, }: { - source: SmartColumnSource; label: string; resolved: ReturnType | undefined; }) { @@ -266,8 +252,8 @@ function SmartColumnResolvedPreview({ return (
-
- +
+ {label || "Column"}
{value}
diff --git a/apps/webapp/app/components/runs/v3/RunsDisplayOptions.tsx b/apps/webapp/app/components/runs/v3/RunsDisplayOptions.tsx index a3ceb28232d..40e492357e5 100644 --- a/apps/webapp/app/components/runs/v3/RunsDisplayOptions.tsx +++ b/apps/webapp/app/components/runs/v3/RunsDisplayOptions.tsx @@ -1,4 +1,10 @@ -import { ArrowUturnLeftIcon, PlusIcon, ViewColumnsIcon } from "@heroicons/react/20/solid"; +import { + ArrowUturnLeftIcon, + CodeBracketIcon, + PencilSquareIcon, + PlusIcon, + ViewColumnsIcon, +} from "@heroicons/react/20/solid"; import { GripVerticalIcon } from "lucide-react"; import { useMemo, useState } from "react"; import { Button } from "~/components/primitives/Buttons"; @@ -18,19 +24,22 @@ import { type SmartColumnDef, } from "./runColumns"; import { AddSmartColumnDialog } from "./AddSmartColumnDialog"; -import { SMART_SOURCE_DOT_COLOR } from "./smartColumnData"; function keyFor(col: ResolvedColumn): string { return col.kind === "standard" ? `std:${col.def.id}` : `smart:${col.index}`; } +type SmartEditTarget = { index: number; def: SmartColumnDef }; + export function RunsDisplayOptions() { const environment = useEnvironment(); const { isManagedCloud } = useFeatures(); const location = useOptimisticLocation(); const { values, replace } = useSearchParams(); const [addOpen, setAddOpen] = useState(false); + const [editing, setEditing] = useState(null); const [dragKey, setDragKey] = useState(null); + const [overKey, setOverKey] = useState(null); const runtime: RunColumnRuntime = { isManagedCloud, @@ -47,7 +56,6 @@ export function RunsDisplayOptions() { const available = availableStandardColumns(runtime); const visibleStandardCount = layout.visible.filter((c) => c.kind === "standard").length; - const smartCount = layout.visible.filter((c) => c.kind === "smart").length; const applyVisible = (nextVisible: ResolvedColumn[]) => { const encoded = encodeColumnLayout(nextVisible, runtime); @@ -71,9 +79,16 @@ export function RunsDisplayOptions() { applyVisible(layout.visible.filter((c) => !(c.kind === "smart" && c.index === index))); }; - const addSmart = (def: SmartColumnDef) => { - const nextIndex = layout.smartColumns.length; - applyVisible([...layout.visible, { kind: "smart", index: nextIndex, def }]); + const submitSmart = (def: SmartColumnDef) => { + if (editing) { + applyVisible( + layout.visible.map((c) => + c.kind === "smart" && c.index === editing.index ? { ...c, def } : c + ) + ); + } else { + applyVisible([...layout.visible, { kind: "smart", index: layout.smartColumns.length, def }]); + } }; const reset = () => replace({ cols: undefined, sc: undefined }); @@ -89,17 +104,17 @@ export function RunsDisplayOptions() { applyVisible(arr); }; + const endDrag = () => { + setDragKey(null); + setOverKey(null); + }; + return ( <> @@ -118,16 +133,23 @@ export function RunsDisplayOptions() { draggable locked={col.kind === "standard" && !!col.def.locked} dragging={dragKey === keyFor(col)} + isOver={overKey === keyFor(col) && dragKey !== keyFor(col)} onDragStart={() => setDragKey(keyFor(col))} - onDragEnd={() => setDragKey(null)} + onDragEnter={() => setOverKey(keyFor(col))} + onDragEnd={endDrag} onDrop={() => { if (dragKey) reorder(dragKey, keyFor(col)); - setDragKey(null); + endDrag(); }} onToggle={() => { if (col.kind === "smart") removeSmart(col.index); else if (!col.def.locked) hideStandard(col.def.id); }} + onEdit={ + col.kind === "smart" + ? () => setEditing({ index: col.index, def: col.def }) + : undefined + } /> ))} {layout.hiddenStandard.map((def) => ( @@ -138,6 +160,7 @@ export function RunsDisplayOptions() { draggable={false} locked={false} dragging={false} + isOver={false} onToggle={() => showStandard(def.id)} /> ))} @@ -164,9 +187,15 @@ export function RunsDisplayOptions() { { + if (!next) { + setAddOpen(false); + setEditing(null); + } + }} + onSubmit={submitSmart} currentSearch={location.search} /> @@ -179,8 +208,11 @@ function ColumnRow({ draggable, locked, dragging, + isOver, onToggle, + onEdit, onDragStart, + onDragEnter, onDragEnd, onDrop, }: { @@ -189,45 +221,49 @@ function ColumnRow({ draggable: boolean; locked: boolean; dragging: boolean; + isOver: boolean; onToggle: () => void; + onEdit?: () => void; onDragStart?: () => void; + onDragEnter?: () => void; onDragEnd?: () => void; onDrop?: () => void; }) { const isSmart = col.kind === "smart"; - const label = col.def.label; - const isDuration = col.kind === "standard" && col.def.id === "dur"; return (
{ if (draggable) e.preventDefault(); }} onDrop={onDrop} > - {locked ? ( - - ) : ( - - )} - {isSmart && ( - - )} + {isOver &&
} + {locked ? : } + {isSmart && } - {label} + {col.def.label} - {isDuration && 3 cells} + {onEdit && ( + + )} {draggable ? ( ) : ( diff --git a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx index 2ea17e3f88d..550efd824e0 100644 --- a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx +++ b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx @@ -2,6 +2,7 @@ import { ArrowPathIcon, ArrowRightIcon, ClockIcon, + CodeBracketIcon, CpuChipIcon, NoSymbolIcon, RectangleStackIcon, @@ -72,12 +73,7 @@ import { type SmartColumnDef, type SmartColumnSource, } from "./runColumns"; -import { - extractSmartValue, - parseSource, - SMART_SOURCE_DOT_COLOR, - type ParsedSource, -} from "./smartColumnData"; +import { extractSmartValue, parseSource, type ParsedSource } from "./smartColumnData"; type RunsTableProps = { total: number; @@ -485,8 +481,8 @@ const STANDARD_RENDERERS: Record = { function SmartColumnHeader({ def }: { def: SmartColumnDef }) { return ( - - + + {def.label} diff --git a/apps/webapp/app/components/runs/v3/smartColumnData.ts b/apps/webapp/app/components/runs/v3/smartColumnData.ts index 36db70310ea..9c736e942f3 100644 --- a/apps/webapp/app/components/runs/v3/smartColumnData.ts +++ b/apps/webapp/app/components/runs/v3/smartColumnData.ts @@ -1,5 +1,4 @@ import superjson from "superjson"; -import type { SmartColumnSource } from "./runColumns"; export type SourcePacket = { data: string | null | undefined; @@ -103,15 +102,3 @@ export function labelFromPath(path: string): string { const segments = normalized.match(/[^.[\]'"]+/g); return segments && segments.length > 0 ? segments[segments.length - 1] : path; } - -export const SMART_SOURCE_DOT_COLOR: Record = { - payload: "bg-blue-500", - metadata: "bg-purple-500", - output: "bg-green-500", -}; - -export const SMART_SOURCE_LABEL: Record = { - payload: "Payload", - metadata: "Metadata", - output: "Output", -}; From 380a6659f4699cdeee675a97ad353c5e198793b2 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Mon, 17 Aug 2026 15:25:06 +0100 Subject: [PATCH 07/35] feat(webapp): stable column toggle and a clearer smart-column icon Toggling a column now hides/shows it in place instead of moving it to a separate section, so the list never reorders when you check a box (order lives in the URL, hidden columns keep their slot). Marks smart columns with a variable icon, and gives them an explicit remove action distinct from hiding. --- .../runs/v3/AddSmartColumnDialog.tsx | 4 +- .../components/runs/v3/RunsDisplayOptions.tsx | 150 +++++++++--------- .../app/components/runs/v3/TaskRunsTable.tsx | 4 +- .../app/components/runs/v3/runColumns.test.ts | 83 ++++++---- .../app/components/runs/v3/runColumns.ts | 89 ++++++----- 5 files changed, 181 insertions(+), 149 deletions(-) diff --git a/apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx b/apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx index 5bc67b3fb4e..fef33f79535 100644 --- a/apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx +++ b/apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx @@ -1,4 +1,4 @@ -import { CodeBracketIcon } from "@heroicons/react/20/solid"; +import { VariableIcon } from "@heroicons/react/20/solid"; import { useEffect, useMemo, useState } from "react"; import { useTypedFetcher } from "remix-typedjson"; import { Button } from "~/components/primitives/Buttons"; @@ -253,7 +253,7 @@ function SmartColumnResolvedPreview({ return (
- + {label || "Column"}
{value}
diff --git a/apps/webapp/app/components/runs/v3/RunsDisplayOptions.tsx b/apps/webapp/app/components/runs/v3/RunsDisplayOptions.tsx index 40e492357e5..773e7203213 100644 --- a/apps/webapp/app/components/runs/v3/RunsDisplayOptions.tsx +++ b/apps/webapp/app/components/runs/v3/RunsDisplayOptions.tsx @@ -1,9 +1,10 @@ import { ArrowUturnLeftIcon, - CodeBracketIcon, PencilSquareIcon, PlusIcon, + VariableIcon, ViewColumnsIcon, + XMarkIcon, } from "@heroicons/react/20/solid"; import { GripVerticalIcon } from "lucide-react"; import { useMemo, useState } from "react"; @@ -16,9 +17,9 @@ import { useOptimisticLocation } from "~/hooks/useOptimisticLocation"; import { useSearchParams } from "~/hooks/useSearchParam"; import { cn } from "~/utils/cn"; import { - availableStandardColumns, encodeColumnLayout, resolveColumnLayout, + type LayoutColumn, type ResolvedColumn, type RunColumnRuntime, type SmartColumnDef, @@ -54,40 +55,41 @@ export function RunsDisplayOptions() { [cols.join(" "), sc.join(" "), runtime.isManagedCloud, runtime.isDevelopment] ); - const available = availableStandardColumns(runtime); - const visibleStandardCount = layout.visible.filter((c) => c.kind === "standard").length; + const totalCount = layout.ordered.filter((o) => o.col.kind === "standard").length; + const shownCount = layout.ordered.filter((o) => o.col.kind === "standard" && !o.hidden).length; - const applyVisible = (nextVisible: ResolvedColumn[]) => { - const encoded = encodeColumnLayout(nextVisible, runtime); + const applyLayout = (next: LayoutColumn[]) => { + const encoded = encodeColumnLayout(next, runtime); replace({ cols: encoded.cols.length > 0 ? encoded.cols : undefined, sc: encoded.sc.length > 0 ? encoded.sc : undefined, }); }; - const hideStandard = (id: string) => { - applyVisible(layout.visible.filter((c) => !(c.kind === "standard" && c.def.id === id))); - }; - - const showStandard = (id: string) => { - const def = available.find((c) => c.id === id); - if (!def) return; - applyVisible([...layout.visible, { kind: "standard", def }]); + const toggleHidden = (key: string) => { + applyLayout( + layout.ordered.map((o) => (keyFor(o.col) === key ? { ...o, hidden: !o.hidden } : o)) + ); }; const removeSmart = (index: number) => { - applyVisible(layout.visible.filter((c) => !(c.kind === "smart" && c.index === index))); + applyLayout(layout.ordered.filter((o) => !(o.col.kind === "smart" && o.col.index === index))); }; const submitSmart = (def: SmartColumnDef) => { if (editing) { - applyVisible( - layout.visible.map((c) => - c.kind === "smart" && c.index === editing.index ? { ...c, def } : c + applyLayout( + layout.ordered.map((o) => + o.col.kind === "smart" && o.col.index === editing.index + ? { ...o, col: { ...o.col, def } } + : o ) ); } else { - applyVisible([...layout.visible, { kind: "smart", index: layout.smartColumns.length, def }]); + applyLayout([ + ...layout.ordered, + { col: { kind: "smart", index: layout.smartColumns.length, def }, hidden: false }, + ]); } }; @@ -95,13 +97,13 @@ export function RunsDisplayOptions() { const reorder = (fromKey: string, toKey: string) => { if (fromKey === toKey) return; - const arr = [...layout.visible]; - const from = arr.findIndex((c) => keyFor(c) === fromKey); - const to = arr.findIndex((c) => keyFor(c) === toKey); + const arr = [...layout.ordered]; + const from = arr.findIndex((o) => keyFor(o.col) === fromKey); + const to = arr.findIndex((o) => keyFor(o.col) === toKey); if (from < 0 || to < 0) return; const [moved] = arr.splice(from, 1); arr.splice(to, 0, moved); - applyVisible(arr); + applyLayout(arr); }; const endDrag = () => { @@ -121,49 +123,37 @@ export function RunsDisplayOptions() {
Columns - {visibleStandardCount} of {available.length} + {shownCount} of {totalCount}
- {layout.visible.map((col) => ( - setDragKey(keyFor(col))} - onDragEnter={() => setOverKey(keyFor(col))} - onDragEnd={endDrag} - onDrop={() => { - if (dragKey) reorder(dragKey, keyFor(col)); - endDrag(); - }} - onToggle={() => { - if (col.kind === "smart") removeSmart(col.index); - else if (!col.def.locked) hideStandard(col.def.id); - }} - onEdit={ - col.kind === "smart" - ? () => setEditing({ index: col.index, def: col.def }) - : undefined - } - /> - ))} - {layout.hiddenStandard.map((def) => ( - showStandard(def.id)} - /> - ))} + {layout.ordered.map(({ col, hidden }) => { + const key = keyFor(col); + return ( + setDragKey(key)} + onDragEnter={() => setOverKey(key)} + onDragEnd={endDrag} + onDrop={() => { + if (dragKey) reorder(dragKey, key); + endDrag(); + }} + onToggle={() => toggleHidden(key)} + onEdit={ + col.kind === "smart" + ? () => setEditing({ index: col.index, def: col.def }) + : undefined + } + onRemove={col.kind === "smart" ? () => removeSmart(col.index) : undefined} + /> + ); + })}
)} - {draggable ? ( - - ) : ( -
+ {onRemove && ( + )} +
); } diff --git a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx index 550efd824e0..9c764719c41 100644 --- a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx +++ b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx @@ -2,7 +2,7 @@ import { ArrowPathIcon, ArrowRightIcon, ClockIcon, - CodeBracketIcon, + VariableIcon, CpuChipIcon, NoSymbolIcon, RectangleStackIcon, @@ -482,7 +482,7 @@ function SmartColumnHeader({ def }: { def: SmartColumnDef }) { return ( - + {def.label} diff --git a/apps/webapp/app/components/runs/v3/runColumns.test.ts b/apps/webapp/app/components/runs/v3/runColumns.test.ts index 154eb9dba6c..09f68f989ef 100644 --- a/apps/webapp/app/components/runs/v3/runColumns.test.ts +++ b/apps/webapp/app/components/runs/v3/runColumns.test.ts @@ -82,42 +82,60 @@ describe("availableStandardColumns gating", () => { }); }); +const orderedIds = (layout: { ordered: { col: ResolvedColumn }[] }) => + layout.ordered.map((o) => (o.col.kind === "standard" ? o.col.def.id : o.col.def.label)); + +const visibleIds = (layout: { visible: ResolvedColumn[] }) => + layout.visible.map((c) => (c.kind === "standard" ? c.def.id : c.def.label)); + describe("resolveColumnLayout", () => { it("returns the default layout when cols is absent", () => { const layout = resolveColumnLayout({ cols: [], sc: [] }, cloud); expect(layout.isCustomized).toBe(false); - expect(layout.hiddenStandard).toHaveLength(0); - expect(layout.visible[0]).toMatchObject({ kind: "standard", def: { id: "id" } }); + expect(layout.ordered.every((o) => !o.hidden)).toBe(true); + expect(layout.ordered[0].col).toMatchObject({ kind: "standard", def: { id: "id" } }); expect(layout.visible).toHaveLength(availableStandardColumns(cloud).length); }); - it("keeps locked columns in the requested order (they are reorderable)", () => { + it("keeps every column in the requested order (columns are reorderable)", () => { const layout = resolveColumnLayout({ cols: ["task", "status", "id"], sc: [] }, cloud); - const ids = layout.visible.map((c) => (c.kind === "standard" ? c.def.id : "smart")); - expect(ids).toEqual(["task", "status", "id"]); + expect(orderedIds(layout).slice(0, 3)).toEqual(["task", "status", "id"]); }); - it("moves omitted standard columns into hiddenStandard", () => { - const layout = resolveColumnLayout({ cols: ["id", "task", "status"], sc: [] }, cloud); - const hidden = layout.hiddenStandard.map((c) => c.id); - expect(hidden).toContain("tags"); - expect(hidden).toContain("ttl"); - expect(hidden).not.toContain("id"); + it("hides a `-`-prefixed column in place without dropping it from the order", () => { + const layout = resolveColumnLayout( + { cols: ["id", "task", "status", "ver", "-ttl", "tags"], sc: [] }, + cloud + ); + const ttl = layout.ordered.find((o) => o.col.kind === "standard" && o.col.def.id === "ttl"); + expect(ttl?.hidden).toBe(true); + const ids = orderedIds(layout); + expect(ids.indexOf("ttl")).toBeGreaterThan(ids.indexOf("ver")); + expect(ids.indexOf("ttl")).toBeLessThan(ids.indexOf("tags")); + expect(visibleIds(layout)).not.toContain("ttl"); + }); + + it("never hides locked columns, even with a `-` prefix", () => { + const layout = resolveColumnLayout({ cols: ["id", "-task", "-status", "ver"], sc: [] }, cloud); + const locked = layout.ordered.filter( + (o) => o.col.kind === "standard" && o.col.def.locked + ); + expect(locked.every((o) => !o.hidden)).toBe(true); }); - it("never hides locked columns and reinserts them if the URL omits them", () => { + it("reinserts standard columns missing from the URL as visible", () => { const layout = resolveColumnLayout({ cols: ["id", "ver"], sc: [] }, cloud); - const ids = layout.visible.filter((c) => c.kind === "standard").map((c) => c.def.id); - expect(ids).toContain("task"); - expect(ids).toContain("status"); - const hidden = layout.hiddenStandard.map((c) => c.id); - expect(hidden).not.toContain("task"); - expect(hidden).not.toContain("status"); + expect(visibleIds(layout)).toEqual(expect.arrayContaining(["task", "status", "tags", "ttl"])); }); it("resolves smart-column refs positionally", () => { const sc = [ - encodeSmartColumn({ source: "metadata", path: "$.failed", label: "Failed", displayAs: "number" }), + encodeSmartColumn({ + source: "metadata", + path: "$.failed", + label: "Failed", + displayAs: "number", + }), ]; const layout = resolveColumnLayout({ cols: ["id", "sc1"], sc }, cloud); const smart = layout.visible.find((c) => c.kind === "smart"); @@ -126,15 +144,21 @@ describe("resolveColumnLayout", () => { it("drops gated columns referenced on a runtime that lacks them", () => { const layout = resolveColumnLayout({ cols: ["id", "region", "compute", "task"], sc: [] }, dev); - const ids = layout.visible.map((c) => (c.kind === "standard" ? c.def.id : "smart")); - expect(ids).toEqual(["id", "task", "status"]); + expect(orderedIds(layout)).not.toContain("region"); + expect(orderedIds(layout)).not.toContain("compute"); + expect(orderedIds(layout).slice(0, 2)).toEqual(["id", "task"]); }); }); describe("encodeColumnLayout round-trip", () => { + const std = (id: string) => ({ + kind: "standard" as const, + def: availableStandardColumns(cloud).find((c) => c.id === id)!, + }); + it("encodes the default layout to empty params", () => { const layout = resolveColumnLayout({ cols: [], sc: [] }, cloud); - expect(encodeColumnLayout(layout.visible, cloud)).toEqual({ cols: [], sc: [] }); + expect(encodeColumnLayout(layout.ordered, cloud)).toEqual({ cols: [], sc: [] }); }); it("round-trips a reordered, hidden, smart-augmented layout", () => { @@ -146,18 +170,21 @@ describe("encodeColumnLayout round-trip", () => { }; const encoded = encodeColumnLayout( [ - { kind: "standard", def: availableStandardColumns(cloud).find((c) => c.id === "id")! }, - { kind: "standard", def: availableStandardColumns(cloud).find((c) => c.id === "status")! }, - { kind: "smart", index: 0, def: scDef }, + { col: std("id"), hidden: false }, + { col: std("status"), hidden: false }, + { col: std("ttl"), hidden: true }, + { col: { kind: "smart", index: 0, def: scDef }, hidden: false }, ], cloud ); - expect(encoded.cols).toEqual(["id", "status", "sc1"]); + expect(encoded.cols).toEqual(["id", "status", "-ttl", "sc1"]); expect(encoded.sc).toHaveLength(1); const layout = resolveColumnLayout(encoded, cloud); - const ids = layout.visible.map((c) => (c.kind === "standard" ? c.def.id : c.def.label)); - expect(ids).toEqual(["id", "task", "status", "Order total"]); + const ttl = layout.ordered.find((o) => o.col.kind === "standard" && o.col.def.id === "ttl"); + expect(ttl?.hidden).toBe(true); + expect(visibleIds(layout)).toContain("Order total"); + expect(visibleIds(layout)).not.toContain("ttl"); }); }); diff --git a/apps/webapp/app/components/runs/v3/runColumns.ts b/apps/webapp/app/components/runs/v3/runColumns.ts index 13fb908035e..9a8ef1c09a9 100644 --- a/apps/webapp/app/components/runs/v3/runColumns.ts +++ b/apps/webapp/app/components/runs/v3/runColumns.ts @@ -254,21 +254,28 @@ export type ResolvedColumn = | { kind: "standard"; def: StandardColumnDef } | { kind: "smart"; index: number; def: SmartColumnDef }; +/** A column in the popover's full display order, with its current visibility. */ +export type LayoutColumn = { col: ResolvedColumn; hidden: boolean }; + export type ColumnLayout = { - /** Visible columns in display order. */ + /** Every column in display order, hidden ones included (drives the popover). */ + ordered: LayoutColumn[]; + /** Shown columns in display order (drives the table). */ visible: ResolvedColumn[]; - /** Available standard columns that are currently hidden, in default order. */ - hiddenStandard: StandardColumnDef[]; /** All decoded smart columns (visible or not), indexed by position. */ smartColumns: SmartColumnDef[]; /** Whether the layout differs from the default (drives "Reset to default"). */ isCustomized: boolean; }; +/** A hidden column is written into `cols` with this prefix, keeping its slot. */ +const HIDDEN_PREFIX = "-"; + /** - * Resolve the on-screen layout from the URL params and the runtime gates. When - * `cols` is absent the default layout (all available standard columns in - * default order, no smart columns) is returned and `sc` is ignored. + * Resolve the on-screen layout from the URL params and the runtime gates. `cols` + * carries the full column order; a `-`-prefixed token is hidden but keeps its + * position. When `cols` is absent the default layout (all available standard + * columns in default order, nothing hidden) is returned and `sc` is ignored. */ export function resolveColumnLayout( params: { cols: string[]; sc: string[] }, @@ -281,61 +288,62 @@ export function resolveColumnLayout( .filter((c): c is SmartColumnDef => c !== undefined); if (params.cols.length === 0) { - return { - visible: available.map((def) => ({ kind: "standard", def })), - hiddenStandard: [], - smartColumns, - isCustomized: false, - }; + const ordered = available.map((def) => ({ + col: { kind: "standard", def }, + hidden: false, + })); + return { ordered, visible: ordered.map((o) => o.col), smartColumns, isCustomized: false }; } - const visible: ResolvedColumn[] = []; + const ordered: LayoutColumn[] = []; const seenStandard = new Set(); for (const token of params.cols) { - const smartIndex = parseSmartColumnRef(token); + const hidden = token.startsWith(HIDDEN_PREFIX); + const base = hidden ? token.slice(HIDDEN_PREFIX.length) : token; + + const smartIndex = parseSmartColumnRef(base); if (smartIndex !== undefined) { const def = smartColumns[smartIndex]; - if (def) visible.push({ kind: "smart", index: smartIndex, def }); + if (def) ordered.push({ col: { kind: "smart", index: smartIndex, def }, hidden }); continue; } - if (seenStandard.has(token as RunColumnId)) continue; - const def = availableById.get(token as RunColumnId); + if (seenStandard.has(base as RunColumnId)) continue; + const def = availableById.get(base as RunColumnId); if (!def) continue; - visible.push({ kind: "standard", def }); + ordered.push({ col: { kind: "standard", def }, hidden: hidden && !def.locked }); seenStandard.add(def.id); } - ensureLockedColumnsPresent(visible, seenStandard, available); + ensureAllStandardColumnsPresent(ordered, seenStandard, available); - const hiddenStandard = available.filter((def) => !def.locked && !seenStandard.has(def.id)); - - return { visible, hiddenStandard, smartColumns, isCustomized: true }; + const visible = ordered.filter((o) => !o.hidden).map((o) => o.col); + return { ordered, visible, smartColumns, isCustomized: true }; } /** - * Locked columns can never be hidden, so a `cols` param that omits one (a - * hand-edited or stale URL) gets it reinserted at its default-order position. + * Any available standard column missing from `cols` (a locked column, or one + * added after a URL was saved) is inserted, shown, at its default position. */ -function ensureLockedColumnsPresent( - visible: ResolvedColumn[], +function ensureAllStandardColumnsPresent( + ordered: LayoutColumn[], seenStandard: Set, available: StandardColumnDef[] ): void { const defaultIndex = new Map(available.map((def, index) => [def.id, index] as const)); for (const def of available) { - if (!def.locked || seenStandard.has(def.id)) continue; + if (seenStandard.has(def.id)) continue; const target = defaultIndex.get(def.id) ?? 0; - let insertAt = visible.length; - for (let i = 0; i < visible.length; i++) { - const col = visible[i]; + let insertAt = ordered.length; + for (let i = 0; i < ordered.length; i++) { + const { col } = ordered[i]; if (col.kind === "standard" && (defaultIndex.get(col.def.id) ?? 0) > target) { insertAt = i; break; } } - visible.splice(insertAt, 0, { kind: "standard", def }); + ordered.splice(insertAt, 0, { col: { kind: "standard", def }, hidden: false }); seenStandard.add(def.id); } } @@ -345,15 +353,17 @@ function ensureLockedColumnsPresent( * default layout so the URL stays clean (the caller deletes both keys). */ export function encodeColumnLayout( - visible: ResolvedColumn[], + ordered: LayoutColumn[], runtime: RunColumnRuntime ): { cols: string[]; sc: string[] } { const available = availableStandardColumns(runtime); - const hasSmart = visible.some((c) => c.kind === "smart"); + const hasSmart = ordered.some((o) => o.col.kind === "smart"); const isDefault = !hasSmart && - visible.length === available.length && - visible.every((c, i) => c.kind === "standard" && c.def.id === available[i]?.id); + ordered.length === available.length && + ordered.every( + (o, i) => o.col.kind === "standard" && o.col.def.id === available[i]?.id && !o.hidden + ); if (isDefault) { return { cols: [], sc: [] }; @@ -361,7 +371,7 @@ export function encodeColumnLayout( const sc: string[] = []; const smartRefByIndex = new Map(); - for (const col of visible) { + for (const { col } of ordered) { if (col.kind === "smart") { const ref = smartColumnRef(sc.length); smartRefByIndex.set(col.index, ref); @@ -369,9 +379,10 @@ export function encodeColumnLayout( } } - const cols = visible.map((col) => - col.kind === "standard" ? col.def.id : (smartRefByIndex.get(col.index) as string) - ); + const cols = ordered.map(({ col, hidden }) => { + const base = col.kind === "standard" ? col.def.id : (smartRefByIndex.get(col.index) as string); + return hidden ? `${HIDDEN_PREFIX}${base}` : base; + }); return { cols, sc }; } From ecd527a27fce8ee2d5cbbe242657a9ee1444d642 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Mon, 17 Aug 2026 16:10:25 +0100 Subject: [PATCH 08/35] feat(webapp): use a bolt icon for smart columns and reveal row actions on hover Marks smart columns with a bolt icon, and the display-options rows now show edit/remove/drag only on hover so a resting list is just a checkbox and a name. --- .../app/components/runs/v3/AddSmartColumnDialog.tsx | 4 ++-- .../app/components/runs/v3/RunsDisplayOptions.tsx | 12 ++++++------ apps/webapp/app/components/runs/v3/TaskRunsTable.tsx | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx b/apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx index fef33f79535..c4fed47937b 100644 --- a/apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx +++ b/apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx @@ -1,4 +1,4 @@ -import { VariableIcon } from "@heroicons/react/20/solid"; +import { BoltIcon } from "@heroicons/react/20/solid"; import { useEffect, useMemo, useState } from "react"; import { useTypedFetcher } from "remix-typedjson"; import { Button } from "~/components/primitives/Buttons"; @@ -253,7 +253,7 @@ function SmartColumnResolvedPreview({ return (
- + {label || "Column"}
{value}
diff --git a/apps/webapp/app/components/runs/v3/RunsDisplayOptions.tsx b/apps/webapp/app/components/runs/v3/RunsDisplayOptions.tsx index 773e7203213..a3aaa351993 100644 --- a/apps/webapp/app/components/runs/v3/RunsDisplayOptions.tsx +++ b/apps/webapp/app/components/runs/v3/RunsDisplayOptions.tsx @@ -2,7 +2,7 @@ import { ArrowUturnLeftIcon, PencilSquareIcon, PlusIcon, - VariableIcon, + BoltIcon, ViewColumnsIcon, XMarkIcon, } from "@heroicons/react/20/solid"; @@ -224,7 +224,7 @@ function ColumnRow({ return (
{isOver &&
} {locked ? : } - {isSmart && } + {isSmart && } @@ -247,7 +247,7 @@ function ColumnRow({ type="button" onClick={onEdit} aria-label={`Edit ${col.def.label}`} - className="flex size-5 items-center justify-center rounded text-text-dimmed transition-colors hover:text-text-bright focus-custom" + className="flex size-5 items-center justify-center rounded text-text-dimmed opacity-0 transition hover:text-text-bright focus-custom group-hover:opacity-100" > @@ -257,12 +257,12 @@ function ColumnRow({ type="button" onClick={onRemove} aria-label={`Remove ${col.def.label}`} - className="flex size-5 items-center justify-center rounded text-text-dimmed transition-colors hover:text-error focus-custom" + className="flex size-5 items-center justify-center rounded text-text-dimmed opacity-0 transition hover:text-error focus-custom group-hover:opacity-100" > )} - +
); } diff --git a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx index 9c764719c41..0b08928c231 100644 --- a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx +++ b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx @@ -2,7 +2,7 @@ import { ArrowPathIcon, ArrowRightIcon, ClockIcon, - VariableIcon, + BoltIcon, CpuChipIcon, NoSymbolIcon, RectangleStackIcon, @@ -482,7 +482,7 @@ function SmartColumnHeader({ def }: { def: SmartColumnDef }) { return ( - + {def.label} From fbedcb9c2b7b1a592290e6453d6b925a1c5045fb Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Mon, 17 Aug 2026 16:22:58 +0100 Subject: [PATCH 09/35] feat(webapp): compact column URL state Column state is now delta-encoded: order is written only when it differs from the default, and hidden columns are a single `hide` list. Removing one column produces `?hide=ver` instead of the whole ordered list. --- .../components/runs/v3/RunsDisplayOptions.tsx | 17 +-- .../app/components/runs/v3/TaskRunsTable.tsx | 9 +- .../app/components/runs/v3/runColumns.test.ts | 70 +++++++---- .../app/components/runs/v3/runColumns.ts | 115 +++++++++++------- .../v3/runColumnsFromRequest.server.ts | 7 +- .../useRunsLiveReload.ts | 5 +- 6 files changed, 145 insertions(+), 78 deletions(-) diff --git a/apps/webapp/app/components/runs/v3/RunsDisplayOptions.tsx b/apps/webapp/app/components/runs/v3/RunsDisplayOptions.tsx index a3aaa351993..95b4e240390 100644 --- a/apps/webapp/app/components/runs/v3/RunsDisplayOptions.tsx +++ b/apps/webapp/app/components/runs/v3/RunsDisplayOptions.tsx @@ -18,6 +18,7 @@ import { useSearchParams } from "~/hooks/useSearchParam"; import { cn } from "~/utils/cn"; import { encodeColumnLayout, + parseColumnParams, resolveColumnLayout, type LayoutColumn, type ResolvedColumn, @@ -36,7 +37,7 @@ export function RunsDisplayOptions() { const environment = useEnvironment(); const { isManagedCloud } = useFeatures(); const location = useOptimisticLocation(); - const { values, replace } = useSearchParams(); + const { value, values, replace } = useSearchParams(); const [addOpen, setAddOpen] = useState(false); const [editing, setEditing] = useState(null); const [dragKey, setDragKey] = useState(null); @@ -47,12 +48,13 @@ export function RunsDisplayOptions() { isDevelopment: environment.type === "DEVELOPMENT", }; - const cols = values("cols"); + const colsParam = value("cols"); + const hideParam = value("hide"); const sc = values("sc"); const layout = useMemo( - () => resolveColumnLayout({ cols, sc }, runtime), + () => resolveColumnLayout(parseColumnParams(colsParam, sc, hideParam), runtime), // eslint-disable-next-line react-hooks/exhaustive-deps - [cols.join(" "), sc.join(" "), runtime.isManagedCloud, runtime.isDevelopment] + [colsParam, hideParam, sc.join(" "), runtime.isManagedCloud, runtime.isDevelopment] ); const totalCount = layout.ordered.filter((o) => o.col.kind === "standard").length; @@ -61,11 +63,14 @@ export function RunsDisplayOptions() { const applyLayout = (next: LayoutColumn[]) => { const encoded = encodeColumnLayout(next, runtime); replace({ - cols: encoded.cols.length > 0 ? encoded.cols : undefined, + cols: encoded.cols.length > 0 ? encoded.cols.join(",") : undefined, sc: encoded.sc.length > 0 ? encoded.sc : undefined, + hide: encoded.hide.length > 0 ? encoded.hide.join(",") : undefined, }); }; + const reset = () => replace({ cols: undefined, sc: undefined, hide: undefined }); + const toggleHidden = (key: string) => { applyLayout( layout.ordered.map((o) => (keyFor(o.col) === key ? { ...o, hidden: !o.hidden } : o)) @@ -93,8 +98,6 @@ export function RunsDisplayOptions() { } }; - const reset = () => replace({ cols: undefined, sc: undefined }); - const reorder = (fromKey: string, toKey: string) => { if (fromKey === toKey) return; const arr = [...layout.ordered]; diff --git a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx index 0b08928c231..e0f36a0cf73 100644 --- a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx +++ b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx @@ -66,6 +66,7 @@ import { useSearchParams } from "~/hooks/useSearchParam"; import type { TaskTriggerSource } from "@trigger.dev/database"; import { BeakerIcon } from "~/assets/icons/BeakerIcon"; import { + parseColumnParams, resolveColumnLayout, visibleSmartSources, type ResolvedColumn, @@ -655,15 +656,15 @@ export function TaskRunsTable({ const tableStateParam = disableAdjacentRows ? "" : encodeURIComponent(search); const isDevelopment = environment.type === "DEVELOPMENT"; - const colsFromUrl = values("cols"); + const colsParam = value("cols"); + const hideParam = value("hide"); const scFromUrl = values("sc"); - const colsKey = colsFromUrl.join(" "); const scKey = scFromUrl.join(" "); const layout = useMemo(() => { const runtime: RunColumnRuntime = { isManagedCloud, isDevelopment }; - return resolveColumnLayout({ cols: colsFromUrl, sc: scFromUrl }, runtime); + return resolveColumnLayout(parseColumnParams(colsParam, scFromUrl, hideParam), runtime); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [colsKey, scKey, isManagedCloud, isDevelopment]); + }, [colsParam, hideParam, scKey, isManagedCloud, isDevelopment]); const visibleColumns = layout.visible; const referencedSources = useMemo(() => visibleSmartSources(visibleColumns), [visibleColumns]); diff --git a/apps/webapp/app/components/runs/v3/runColumns.test.ts b/apps/webapp/app/components/runs/v3/runColumns.test.ts index 09f68f989ef..8e9388c3980 100644 --- a/apps/webapp/app/components/runs/v3/runColumns.test.ts +++ b/apps/webapp/app/components/runs/v3/runColumns.test.ts @@ -88,9 +88,16 @@ const orderedIds = (layout: { ordered: { col: ResolvedColumn }[] }) => const visibleIds = (layout: { visible: ResolvedColumn[] }) => layout.visible.map((c) => (c.kind === "standard" ? c.def.id : c.def.label)); +const params = (over: Partial<{ cols: string[]; sc: string[]; hide: string[] }> = {}) => ({ + cols: [], + sc: [], + hide: [], + ...over, +}); + describe("resolveColumnLayout", () => { - it("returns the default layout when cols is absent", () => { - const layout = resolveColumnLayout({ cols: [], sc: [] }, cloud); + it("returns the default layout when no params are set", () => { + const layout = resolveColumnLayout(params(), cloud); expect(layout.isCustomized).toBe(false); expect(layout.ordered.every((o) => !o.hidden)).toBe(true); expect(layout.ordered[0].col).toMatchObject({ kind: "standard", def: { id: "id" } }); @@ -98,37 +105,31 @@ describe("resolveColumnLayout", () => { }); it("keeps every column in the requested order (columns are reorderable)", () => { - const layout = resolveColumnLayout({ cols: ["task", "status", "id"], sc: [] }, cloud); + const layout = resolveColumnLayout(params({ cols: ["task", "status", "id"] }), cloud); expect(orderedIds(layout).slice(0, 3)).toEqual(["task", "status", "id"]); }); - it("hides a `-`-prefixed column in place without dropping it from the order", () => { - const layout = resolveColumnLayout( - { cols: ["id", "task", "status", "ver", "-ttl", "tags"], sc: [] }, - cloud - ); + it("hides columns from the `hide` list in place, keeping the default order", () => { + const layout = resolveColumnLayout(params({ hide: ["ttl"] }), cloud); const ttl = layout.ordered.find((o) => o.col.kind === "standard" && o.col.def.id === "ttl"); expect(ttl?.hidden).toBe(true); const ids = orderedIds(layout); - expect(ids.indexOf("ttl")).toBeGreaterThan(ids.indexOf("ver")); expect(ids.indexOf("ttl")).toBeLessThan(ids.indexOf("tags")); expect(visibleIds(layout)).not.toContain("ttl"); }); - it("never hides locked columns, even with a `-` prefix", () => { - const layout = resolveColumnLayout({ cols: ["id", "-task", "-status", "ver"], sc: [] }, cloud); - const locked = layout.ordered.filter( - (o) => o.col.kind === "standard" && o.col.def.locked - ); + it("never hides locked columns even if the `hide` list names them", () => { + const layout = resolveColumnLayout(params({ hide: ["task", "status"] }), cloud); + const locked = layout.ordered.filter((o) => o.col.kind === "standard" && o.col.def.locked); expect(locked.every((o) => !o.hidden)).toBe(true); }); it("reinserts standard columns missing from the URL as visible", () => { - const layout = resolveColumnLayout({ cols: ["id", "ver"], sc: [] }, cloud); + const layout = resolveColumnLayout(params({ cols: ["id", "ver"] }), cloud); expect(visibleIds(layout)).toEqual(expect.arrayContaining(["task", "status", "tags", "ttl"])); }); - it("resolves smart-column refs positionally", () => { + it("resolves smart-column refs positionally, even without a cols order", () => { const sc = [ encodeSmartColumn({ source: "metadata", @@ -137,28 +138,52 @@ describe("resolveColumnLayout", () => { displayAs: "number", }), ]; - const layout = resolveColumnLayout({ cols: ["id", "sc1"], sc }, cloud); + const layout = resolveColumnLayout(params({ sc }), cloud); const smart = layout.visible.find((c) => c.kind === "smart"); expect(smart).toMatchObject({ kind: "smart", def: { label: "Failed", source: "metadata" } }); }); it("drops gated columns referenced on a runtime that lacks them", () => { - const layout = resolveColumnLayout({ cols: ["id", "region", "compute", "task"], sc: [] }, dev); + const layout = resolveColumnLayout(params({ cols: ["id", "region", "compute", "task"] }), dev); expect(orderedIds(layout)).not.toContain("region"); expect(orderedIds(layout)).not.toContain("compute"); expect(orderedIds(layout).slice(0, 2)).toEqual(["id", "task"]); }); }); -describe("encodeColumnLayout round-trip", () => { +describe("encodeColumnLayout compactness + round-trip", () => { const std = (id: string) => ({ kind: "standard" as const, def: availableStandardColumns(cloud).find((c) => c.id === id)!, }); it("encodes the default layout to empty params", () => { - const layout = resolveColumnLayout({ cols: [], sc: [] }, cloud); - expect(encodeColumnLayout(layout.ordered, cloud)).toEqual({ cols: [], sc: [] }); + const layout = resolveColumnLayout(params(), cloud); + expect(encodeColumnLayout(layout.ordered, cloud)).toEqual({ cols: [], sc: [], hide: [] }); + }); + + it("hiding a column with the default order emits only a hide entry, no cols", () => { + const layout = resolveColumnLayout(params({ hide: ["ver"] }), cloud); + const encoded = encodeColumnLayout(layout.ordered, cloud); + expect(encoded.cols).toEqual([]); + expect(encoded.hide).toEqual(["ver"]); + expect(encoded.sc).toEqual([]); + }); + + it("appending a smart column with the default order emits only sc, no cols", () => { + const scDef: SmartColumnDef = { + source: "metadata", + path: "$.failed", + label: "Failed", + displayAs: "number", + }; + const layout = resolveColumnLayout(params(), cloud); + const encoded = encodeColumnLayout( + [...layout.ordered, { col: { kind: "smart", index: 0, def: scDef }, hidden: false }], + cloud + ); + expect(encoded.cols).toEqual([]); + expect(encoded.sc).toHaveLength(1); }); it("round-trips a reordered, hidden, smart-augmented layout", () => { @@ -177,7 +202,8 @@ describe("encodeColumnLayout round-trip", () => { ], cloud ); - expect(encoded.cols).toEqual(["id", "status", "-ttl", "sc1"]); + expect(encoded.cols).toEqual(["id", "status", "ttl", "sc1"]); + expect(encoded.hide).toEqual(["ttl"]); expect(encoded.sc).toHaveLength(1); const layout = resolveColumnLayout(encoded, cloud); diff --git a/apps/webapp/app/components/runs/v3/runColumns.ts b/apps/webapp/app/components/runs/v3/runColumns.ts index 9a8ef1c09a9..c9435c7203a 100644 --- a/apps/webapp/app/components/runs/v3/runColumns.ts +++ b/apps/webapp/app/components/runs/v3/runColumns.ts @@ -268,17 +268,28 @@ export type ColumnLayout = { isCustomized: boolean; }; -/** A hidden column is written into `cols` with this prefix, keeping its slot. */ -const HIDDEN_PREFIX = "-"; +export type ColumnLayoutParams = { cols: string[]; sc: string[]; hide: string[] }; +export type EncodedColumnLayout = { cols: string[]; sc: string[]; hide: string[] }; /** - * Resolve the on-screen layout from the URL params and the runtime gates. `cols` - * carries the full column order; a `-`-prefixed token is hidden but keeps its - * position. When `cols` is absent the default layout (all available standard - * columns in default order, nothing hidden) is returned and `sc` is ignored. + * The order columns take when `cols` is absent: standard columns in default + * order, then smart columns in their `sc` definition order. + */ +function canonicalOrder(available: StandardColumnDef[], smartCount: number): string[] { + return [ + ...available.map((def) => def.id as string), + ...Array.from({ length: smartCount }, (_, i) => smartColumnRef(i)), + ]; +} + +/** + * Resolve the on-screen layout from the URL params and the runtime gates. + * `cols` is present only when the order differs from the default; otherwise the + * default order is used. `hide` lists the columns that are hidden but still + * occupy their slot, so hiding a column does not rewrite the whole order. */ export function resolveColumnLayout( - params: { cols: string[]; sc: string[] }, + params: ColumnLayoutParams, runtime: RunColumnRuntime ): ColumnLayout { const available = availableStandardColumns(runtime); @@ -286,40 +297,48 @@ export function resolveColumnLayout( const smartColumns = params.sc .map(decodeSmartColumn) .filter((c): c is SmartColumnDef => c !== undefined); + const hideSet = new Set(params.hide); - if (params.cols.length === 0) { - const ordered = available.map((def) => ({ - col: { kind: "standard", def }, - hidden: false, - })); - return { ordered, visible: ordered.map((o) => o.col), smartColumns, isCustomized: false }; - } + const baseTokens = + params.cols.length > 0 ? params.cols : canonicalOrder(available, smartColumns.length); const ordered: LayoutColumn[] = []; const seenStandard = new Set(); + const seenSmart = new Set(); - for (const token of params.cols) { - const hidden = token.startsWith(HIDDEN_PREFIX); - const base = hidden ? token.slice(HIDDEN_PREFIX.length) : token; - - const smartIndex = parseSmartColumnRef(base); + for (const token of baseTokens) { + const smartIndex = parseSmartColumnRef(token); if (smartIndex !== undefined) { const def = smartColumns[smartIndex]; - if (def) ordered.push({ col: { kind: "smart", index: smartIndex, def }, hidden }); + if (!def || seenSmart.has(smartIndex)) continue; + ordered.push({ col: { kind: "smart", index: smartIndex, def }, hidden: hideSet.has(token) }); + seenSmart.add(smartIndex); continue; } - if (seenStandard.has(base as RunColumnId)) continue; - const def = availableById.get(base as RunColumnId); + if (seenStandard.has(token as RunColumnId)) continue; + const def = availableById.get(token as RunColumnId); if (!def) continue; - ordered.push({ col: { kind: "standard", def }, hidden: hidden && !def.locked }); + ordered.push({ + col: { kind: "standard", def }, + hidden: hideSet.has(token) && !def.locked, + }); seenStandard.add(def.id); } ensureAllStandardColumnsPresent(ordered, seenStandard, available); + for (let i = 0; i < smartColumns.length; i++) { + if (seenSmart.has(i)) continue; + ordered.push({ + col: { kind: "smart", index: i, def: smartColumns[i] }, + hidden: hideSet.has(smartColumnRef(i)), + }); + } + const visible = ordered.filter((o) => !o.hidden).map((o) => o.col); - return { ordered, visible, smartColumns, isCustomized: true }; + const isCustomized = params.cols.length > 0 || params.hide.length > 0 || smartColumns.length > 0; + return { ordered, visible, smartColumns, isCustomized }; } /** @@ -349,25 +368,16 @@ function ensureAllStandardColumnsPresent( } /** - * Serialize a layout back to `cols`/`sc` params. Returns empty arrays for the - * default layout so the URL stays clean (the caller deletes both keys). + * Serialize a layout to compact `cols`/`sc`/`hide` params. `cols` is omitted + * whenever the order still matches the default, so hiding a column produces just + * a `hide` entry rather than the entire ordered list. All arrays empty means the + * default layout, and the caller deletes the keys. */ export function encodeColumnLayout( ordered: LayoutColumn[], runtime: RunColumnRuntime -): { cols: string[]; sc: string[] } { +): EncodedColumnLayout { const available = availableStandardColumns(runtime); - const hasSmart = ordered.some((o) => o.col.kind === "smart"); - const isDefault = - !hasSmart && - ordered.length === available.length && - ordered.every( - (o, i) => o.col.kind === "standard" && o.col.def.id === available[i]?.id && !o.hidden - ); - - if (isDefault) { - return { cols: [], sc: [] }; - } const sc: string[] = []; const smartRefByIndex = new Map(); @@ -379,12 +389,31 @@ export function encodeColumnLayout( } } - const cols = ordered.map(({ col, hidden }) => { - const base = col.kind === "standard" ? col.def.id : (smartRefByIndex.get(col.index) as string); - return hidden ? `${HIDDEN_PREFIX}${base}` : base; - }); + const tokenFor = (col: ResolvedColumn) => + col.kind === "standard" ? (col.def.id as string) : (smartRefByIndex.get(col.index) as string); + + const baseTokens = ordered.map(({ col }) => tokenFor(col)); + const hide = ordered.filter((o) => o.hidden).map(({ col }) => tokenFor(col)); + + const canonical = canonicalOrder(available, sc.length); + const orderIsDefault = + baseTokens.length === canonical.length && baseTokens.every((t, i) => t === canonical[i]); - return { cols, sc }; + return { cols: orderIsDefault ? [] : baseTokens, sc, hide }; +} + +/** + * Parse the raw URL values into layout params. `cols` and `hide` are single + * comma-joined params; `sc` is repeated. + */ +export function parseColumnParams( + cols: string | null | undefined, + sc: string[], + hide: string | null | undefined +): ColumnLayoutParams { + const split = (value: string | null | undefined) => + value ? value.split(",").filter(Boolean) : []; + return { cols: split(cols), sc, hide: split(hide) }; } /** The set of smart-column sources referenced by the visible layout. */ diff --git a/apps/webapp/app/presenters/v3/runColumnsFromRequest.server.ts b/apps/webapp/app/presenters/v3/runColumnsFromRequest.server.ts index c3310b11d51..1a2c7c8d3c0 100644 --- a/apps/webapp/app/presenters/v3/runColumnsFromRequest.server.ts +++ b/apps/webapp/app/presenters/v3/runColumnsFromRequest.server.ts @@ -1,4 +1,5 @@ import { + parseColumnParams, resolveColumnLayout, visibleSmartSources, visibleStandardIds, @@ -18,7 +19,11 @@ export function getRunColumnsForSelect(request: Request): { } { const url = new URL(request.url); const layout = resolveColumnLayout( - { cols: url.searchParams.getAll("cols"), sc: url.searchParams.getAll("sc") }, + parseColumnParams( + url.searchParams.get("cols"), + url.searchParams.getAll("sc"), + url.searchParams.get("hide") + ), { isManagedCloud: true, isDevelopment: false } ); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/useRunsLiveReload.ts b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/useRunsLiveReload.ts index c760f5814f8..510bba33136 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/useRunsLiveReload.ts +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/useRunsLiveReload.ts @@ -249,7 +249,10 @@ export function useRunsLiveReload({ } const locationParams = new URLSearchParams(location.search); - for (const col of locationParams.getAll("cols")) searchParams.append("cols", col); + const colsValue = locationParams.get("cols"); + if (colsValue) searchParams.set("cols", colsValue); + const hideValue = locationParams.get("hide"); + if (hideValue) searchParams.set("hide", hideValue); for (const smart of locationParams.getAll("sc")) searchParams.append("sc", smart); if (checkForNewRuns) { From 7d07612f8ab55f473c85032b6850e21c920fd6dd Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Mon, 17 Aug 2026 17:05:35 +0100 Subject: [PATCH 10/35] feat(webapp): redesign the add-smart-column modal Wider two-column layout with the sample/preview pinned beside the form. Source is now radio cards with a description each and defaults to payload; display options are pills; and the display-only note is an info box at the top instead of a warning at the bottom. --- .../runs/v3/AddSmartColumnDialog.tsx | 218 +++++++++++------- 1 file changed, 131 insertions(+), 87 deletions(-) diff --git a/apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx b/apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx index c4fed47937b..3df702f874f 100644 --- a/apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx +++ b/apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx @@ -7,14 +7,12 @@ import { Dialog, DialogContent, DialogHeader } from "~/components/primitives/Dia import { Input } from "~/components/primitives/Input"; import { Label } from "~/components/primitives/Label"; import { Paragraph } from "~/components/primitives/Paragraph"; -import SegmentedControl from "~/components/primitives/SegmentedControl"; -import { Switch } from "~/components/primitives/Switch"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; +import { cn } from "~/utils/cn"; import { SMART_COLUMN_DISPLAYS, - SMART_COLUMN_SOURCES, type SmartColumnDef, type SmartColumnDisplay, type SmartColumnSource, @@ -31,16 +29,19 @@ type AddSmartColumnDialogProps = { currentSearch: string; }; -const SOURCE_OPTIONS = SMART_COLUMN_SOURCES.map((source) => ({ - label: source.charAt(0).toUpperCase() + source.slice(1), - value: source, -})); +const SOURCE_CARDS: { value: SmartColumnSource; label: string; description: string }[] = [ + { value: "payload", label: "Payload", description: "What you triggered the run with." }, + { value: "metadata", label: "Metadata", description: "What the run writes while it runs." }, + { value: "output", label: "Output", description: "What the run returned." }, +]; const DISPLAY_OPTIONS = SMART_COLUMN_DISPLAYS.map((display) => ({ label: display.charAt(0).toUpperCase() + display.slice(1), value: display, })); +const DEFAULT_SOURCE: SmartColumnSource = "payload"; + export function AddSmartColumnDialog({ open, editing, @@ -53,7 +54,7 @@ export function AddSmartColumnDialog({ const environment = useEnvironment(); const sample = useTypedFetcher(); - const [source, setSource] = useState("metadata"); + const [source, setSource] = useState(DEFAULT_SOURCE); const [path, setPath] = useState(""); const [label, setLabel] = useState(""); const [labelEdited, setLabelEdited] = useState(false); @@ -61,7 +62,7 @@ export function AddSmartColumnDialog({ useEffect(() => { if (!open) return; - setSource(editing?.source ?? "metadata"); + setSource(editing?.source ?? DEFAULT_SOURCE); setPath(editing?.path ?? ""); setLabel(editing?.label ?? ""); setLabelEdited(editing !== null); @@ -122,84 +123,101 @@ export function AddSmartColumnDialog({ return ( - + {editing ? "Edit smart column" : "Add smart column"} -
-
- - setSource(value as SmartColumnSource)} - fullWidth - /> - - Metadata is what the run writes about itself while it runs, so it has a value before - the run ends. Payload is what you triggered it with; output is what it returned. - -
+
+ + Display only. A smart column shows you a value from a run, but you can't sort or filter + the list by it. To narrow the list, use tags or the query editor. + -
-
- - setPath(e.target.value)} - placeholder="$.failed" - spellCheck={false} - /> - - Dot and bracket notation, e.g. $.failed or{" "} - $.suites[0].name. - -
-
- - { - setLabel(e.target.value); - setLabelEdited(true); - }} - placeholder={labelFromPath(path)} - /> - - Defaults to the last part of the path. Rename it to anything you like. - -
-
+
+
+
+ +
+ {SOURCE_CARDS.map((card) => ( + setSource(card.value)} + /> + ))} +
+
-
- - setDisplayAs(value as SmartColumnDisplay)} - fullWidth - /> - - Number right-aligns the column and uses tabular figures. Anything that doesn't parse - falls back to text. - -
+
+
+ + setPath(e.target.value)} + placeholder="$.order.total" + spellCheck={false} + /> + + Dot and bracket notation, e.g. $.order.total or{" "} + $.items[0].sku. + +
+
+ + { + setLabel(e.target.value); + setLabelEdited(true); + }} + placeholder={labelFromPath(path)} + /> + + Defaults to the last part of the path. + +
+
+ +
+ +
+ {DISPLAY_OPTIONS.map((option) => ( + + ))} +
+ + Number right-aligns the column and uses tabular figures. Anything that doesn't + parse falls back to text. + +
+
-
-
+
Sample — {source} of the newest run -
+              
                 {sample.state === "loading"
                   ? "Loading…"
                   : sampleRun
                     ? sampleJson
                     : "// no runs to sample"}
               
-
-
- Resolves to + + Resolves to + {sampleRun && ( @@ -209,19 +227,6 @@ export function AddSmartColumnDialog({ )}
- -
- - - - Both off, and not switchable - -
- - - Display only. A smart column shows you a value, but you can't sort or filter the list by - it. To narrow the list, use tags or the query editor. -
+ ); +} + function SmartColumnResolvedPreview({ label, resolved, From c2ad8bef6bfecef83b7b1d6b70f6e0c6b5b584f8 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Mon, 17 Aug 2026 17:10:56 +0100 Subject: [PATCH 11/35] feat(webapp): syntax-highlight the smart-column sample and cap large blobs The sample now renders through the shared CodeBlock (JSON syntax highlighting, same as the run page) and the sample string is capped so a large inline blob can't stall the modal; the full value is still used to resolve the path, and offloaded values show the offloaded state. --- .../runs/v3/AddSmartColumnDialog.tsx | 59 +++++++++++++++---- 1 file changed, 47 insertions(+), 12 deletions(-) diff --git a/apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx b/apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx index 3df702f874f..2a8cf5cd2d2 100644 --- a/apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx +++ b/apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx @@ -1,6 +1,7 @@ import { BoltIcon } from "@heroicons/react/20/solid"; import { useEffect, useMemo, useState } from "react"; import { useTypedFetcher } from "remix-typedjson"; +import { CodeBlock } from "~/components/code/CodeBlock"; import { Button } from "~/components/primitives/Buttons"; import { Callout } from "~/components/primitives/Callout"; import { Dialog, DialogContent, DialogHeader } from "~/components/primitives/Dialog"; @@ -42,6 +43,10 @@ const DISPLAY_OPTIONS = SMART_COLUMN_DISPLAYS.map((display) => ({ const DEFAULT_SOURCE: SmartColumnSource = "payload"; +/** Cap the highlighted sample so a large inline blob doesn't stall the modal; + * the full parsed value is still used to resolve the path. */ +const MAX_SAMPLE_CHARS = 15000; + export function AddSmartColumnDialog({ open, editing, @@ -97,15 +102,27 @@ export function AddSmartColumnDialog({ } }, [sampleRun, source]); - const sampleJson = useMemo(() => { + const sampleJson = useMemo<{ text: string; truncated: boolean } | undefined>(() => { if (!parsed) return undefined; - if (parsed.state === "offloaded") return "// offloaded to object storage"; - if (parsed.state === "empty") return "// no value for this run"; + if (parsed.state === "offloaded") { + return { + text: "// Offloaded to object storage — too large to sample here.", + truncated: false, + }; + } + if (parsed.state === "empty") { + return { text: "// No value for this run.", truncated: false }; + } + let text: string; try { - return JSON.stringify(parsed.value, null, 2); + text = JSON.stringify(parsed.value, null, 2); } catch { - return String(parsed.value); + text = String(parsed.value); } + if (text.length > MAX_SAMPLE_CHARS) { + return { text: `${text.slice(0, MAX_SAMPLE_CHARS)}\n…`, truncated: true }; + } + return { text, truncated: false }; }, [parsed]); const resolved = useMemo(() => { @@ -208,13 +225,31 @@ export function AddSmartColumnDialog({ Sample — {source} of the newest run -
-                {sample.state === "loading"
-                  ? "Loading…"
-                  : sampleRun
-                    ? sampleJson
-                    : "// no runs to sample"}
-              
+ {sample.state === "loading" ? ( + + Loading… + + ) : sampleJson ? ( + <> + + {sampleJson.truncated && ( + + Sample truncated. The full value is still used to resolve the path. + + )} + + ) : ( + + No runs to sample. + + )} Resolves to From a4746c65a65b364068e9d8f616427aea2c896c42 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Mon, 17 Aug 2026 17:16:48 +0100 Subject: [PATCH 12/35] feat(webapp): click a key in the smart-column sample to fill the path The sample is now a clickable, syntax-colored JSON tree: clicking a key or array index fills the JSON path field and highlights the active node. Nodes collapse and children are capped so a large blob stays manageable. --- .../runs/v3/AddSmartColumnDialog.tsx | 66 +++----- .../components/runs/v3/SmartColumnSample.tsx | 159 ++++++++++++++++++ 2 files changed, 180 insertions(+), 45 deletions(-) create mode 100644 apps/webapp/app/components/runs/v3/SmartColumnSample.tsx diff --git a/apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx b/apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx index 2a8cf5cd2d2..4a55199a8f7 100644 --- a/apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx +++ b/apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx @@ -1,7 +1,6 @@ import { BoltIcon } from "@heroicons/react/20/solid"; import { useEffect, useMemo, useState } from "react"; import { useTypedFetcher } from "remix-typedjson"; -import { CodeBlock } from "~/components/code/CodeBlock"; import { Button } from "~/components/primitives/Buttons"; import { Callout } from "~/components/primitives/Callout"; import { Dialog, DialogContent, DialogHeader } from "~/components/primitives/Dialog"; @@ -19,6 +18,7 @@ import { type SmartColumnSource, } from "./runColumns"; import { extractSmartValue, labelFromPath, parseSource } from "./smartColumnData"; +import { SmartColumnSample } from "./SmartColumnSample"; import type { loader as sampleLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.smart-column-sample"; type AddSmartColumnDialogProps = { @@ -43,10 +43,6 @@ const DISPLAY_OPTIONS = SMART_COLUMN_DISPLAYS.map((display) => ({ const DEFAULT_SOURCE: SmartColumnSource = "payload"; -/** Cap the highlighted sample so a large inline blob doesn't stall the modal; - * the full parsed value is still used to resolve the path. */ -const MAX_SAMPLE_CHARS = 15000; - export function AddSmartColumnDialog({ open, editing, @@ -102,29 +98,6 @@ export function AddSmartColumnDialog({ } }, [sampleRun, source]); - const sampleJson = useMemo<{ text: string; truncated: boolean } | undefined>(() => { - if (!parsed) return undefined; - if (parsed.state === "offloaded") { - return { - text: "// Offloaded to object storage — too large to sample here.", - truncated: false, - }; - } - if (parsed.state === "empty") { - return { text: "// No value for this run.", truncated: false }; - } - let text: string; - try { - text = JSON.stringify(parsed.value, null, 2); - } catch { - text = String(parsed.value); - } - if (text.length > MAX_SAMPLE_CHARS) { - return { text: `${text.slice(0, MAX_SAMPLE_CHARS)}\n…`, truncated: true }; - } - return { text, truncated: false }; - }, [parsed]); - const resolved = useMemo(() => { if (!parsed || path.trim().length === 0) return undefined; return extractSmartValue(parsed, path); @@ -229,26 +202,29 @@ export function AddSmartColumnDialog({ Loading… - ) : sampleJson ? ( - <> - - {sampleJson.truncated && ( - - Sample truncated. The full value is still used to resolve the path. - - )} - - ) : ( + ) : !parsed ? ( No runs to sample. + ) : parsed.state === "offloaded" ? ( + + This {source} is offloaded to object storage, too large to sample here. + + ) : parsed.state === "empty" ? ( + + No {source} value for this run. + + ) : ( + <> + + + Click a key to use its path. + + )} Resolves to diff --git a/apps/webapp/app/components/runs/v3/SmartColumnSample.tsx b/apps/webapp/app/components/runs/v3/SmartColumnSample.tsx new file mode 100644 index 00000000000..90e40acb333 --- /dev/null +++ b/apps/webapp/app/components/runs/v3/SmartColumnSample.tsx @@ -0,0 +1,159 @@ +import { useState } from "react"; +import { cn } from "~/utils/cn"; + +/** Max children rendered per node so a large blob can't blow up the DOM. */ +const MAX_CHILDREN = 200; +/** Levels auto-expanded; deeper nodes start collapsed and open on click. */ +const AUTO_OPEN_DEPTH = 2; +const MAX_STRING = 80; + +/** + * A clickable, syntax-colored JSON tree for the smart-column sample. Clicking a + * key (or array index) fills the JSON path field via `onSelectPath`; the value + * currently at `activePath` is highlighted. + */ +export function SmartColumnSample({ + value, + activePath, + onSelectPath, +}: { + value: unknown; + activePath: string; + onSelectPath: (path: string) => void; +}) { + return ( +
+ +
+ ); +} + +function childPath(parentPath: string, key: string | number): string { + if (typeof key === "number") return `${parentPath}[${key}]`; + if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key)) return `${parentPath}.${key}`; + return `${parentPath}['${key.replace(/'/g, "\\'")}']`; +} + +function JsonNode({ + name, + path, + value, + depth, + activePath, + onSelectPath, +}: { + name: string | number | undefined; + path: string; + value: unknown; + depth: number; + activePath: string; + onSelectPath: (path: string) => void; +}) { + const [open, setOpen] = useState(depth < AUTO_OPEN_DEPTH); + const isObject = value !== null && typeof value === "object"; + const selected = path === activePath; + + const keyButton = + name !== undefined ? ( + + ) : depth === 0 && !isObject ? ( + + ) : null; + + if (!isObject) { + return ( +
+ {keyButton} + {keyButton && : } + +
+ ); + } + + const isArray = Array.isArray(value); + const entries: [string | number, unknown][] = isArray + ? (value as unknown[]).map((v, i) => [i, v]) + : Object.entries(value as Record); + const shown = entries.slice(0, MAX_CHILDREN); + const openBrace = isArray ? "[" : "{"; + const closeBrace = isArray ? "]" : "}"; + + return ( +
+
+ + {keyButton} + {keyButton && : } + + {openBrace} + {!open && `… ${closeBrace}`} + {!open && entries.length > 0 && ( + {`${entries.length} ${isArray ? "items" : "keys"}`} + )} + +
+ {open && ( +
+ {shown.map(([key, childValue]) => ( + + ))} + {entries.length > MAX_CHILDREN && ( +
… {entries.length - MAX_CHILDREN} more
+ )} +
{closeBrace}
+
+ )} +
+ ); +} + +function PrimitiveValue({ value }: { value: unknown }) { + if (value === null) return null; + if (typeof value === "string") { + const truncated = value.length > MAX_STRING ? `${value.slice(0, MAX_STRING)}…` : value; + return "{truncated}"; + } + if (typeof value === "number") return {String(value)}; + if (typeof value === "boolean") return {String(value)}; + return {String(value)}; +} From 7f776d7b2b7c711d5508fdccc7a1e3023063b10b Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Mon, 17 Aug 2026 17:22:11 +0100 Subject: [PATCH 13/35] feat(webapp): only leaf values are selectable in the sample tree Object and array rows in the smart-column sample now only expand and collapse; only leaf values fill the path when clicked, since a column renders a single value. Drill into a container to pick a leaf inside it (e.g. an array element, or a key within an array element). --- .../runs/v3/AddSmartColumnDialog.tsx | 3 +- .../components/runs/v3/SmartColumnSample.tsx | 66 +++++++------------ 2 files changed, 27 insertions(+), 42 deletions(-) diff --git a/apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx b/apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx index 4a55199a8f7..39aba9f4e81 100644 --- a/apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx +++ b/apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx @@ -222,7 +222,8 @@ export function AddSmartColumnDialog({ onSelectPath={setPath} /> - Click a key to use its path. + Click a value to use its path. Expand objects and arrays to reach the value you + want. )} diff --git a/apps/webapp/app/components/runs/v3/SmartColumnSample.tsx b/apps/webapp/app/components/runs/v3/SmartColumnSample.tsx index 90e40acb333..1781cf7282c 100644 --- a/apps/webapp/app/components/runs/v3/SmartColumnSample.tsx +++ b/apps/webapp/app/components/runs/v3/SmartColumnSample.tsx @@ -8,9 +8,10 @@ const AUTO_OPEN_DEPTH = 2; const MAX_STRING = 80; /** - * A clickable, syntax-colored JSON tree for the smart-column sample. Clicking a - * key (or array index) fills the JSON path field via `onSelectPath`; the value - * currently at `activePath` is highlighted. + * A clickable, syntax-colored JSON tree for the smart-column sample. Only leaf + * values are selectable: clicking one fills the JSON path field via + * `onSelectPath` and highlights it. Object/array rows only expand and collapse, + * so you drill into a container and pick a leaf inside it. */ export function SmartColumnSample({ value, @@ -59,39 +60,23 @@ function JsonNode({ const [open, setOpen] = useState(depth < AUTO_OPEN_DEPTH); const isObject = value !== null && typeof value === "object"; const selected = path === activePath; + const keyLabel = name === undefined ? null : typeof name === "number" ? name : `"${name}"`; - const keyButton = - name !== undefined ? ( - - ) : depth === 0 && !isObject ? ( + if (!isObject) { + const target = name === undefined ? "$" : path; + return ( - ) : null; - - if (!isObject) { - return ( -
- {keyButton} - {keyButton && : } + {keyLabel !== null && {keyLabel}} + {keyLabel !== null && : } -
+ ); } @@ -105,17 +90,16 @@ function JsonNode({ return (
-
- - {keyButton} - {keyButton && : } +
+ {open && (
{shown.map(([key, childValue]) => ( From de33c52325ba5c0ae7dda284cdcbea8b9a0c0ff6 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Mon, 17 Aug 2026 17:35:50 +0100 Subject: [PATCH 14/35] feat(webapp): expand the sample tree and let it page through recent runs The smart-column sample now renders fully expanded (no collapse), and a run picker steps through the most recent runs so you can find one that has the value you're after when the newest run doesn't. --- .../runs/v3/AddSmartColumnDialog.tsx | 66 +++++++++++++++--- .../components/runs/v3/SmartColumnSample.tsx | 69 +++++++------------ ....env.$envParam.runs.smart-column-sample.ts | 21 +++--- 3 files changed, 88 insertions(+), 68 deletions(-) diff --git a/apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx b/apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx index 39aba9f4e81..234e877d7af 100644 --- a/apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx +++ b/apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx @@ -1,4 +1,4 @@ -import { BoltIcon } from "@heroicons/react/20/solid"; +import { BoltIcon, ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/20/solid"; import { useEffect, useMemo, useState } from "react"; import { useTypedFetcher } from "remix-typedjson"; import { Button } from "~/components/primitives/Buttons"; @@ -60,6 +60,7 @@ export function AddSmartColumnDialog({ const [label, setLabel] = useState(""); const [labelEdited, setLabelEdited] = useState(false); const [displayAs, setDisplayAs] = useState("text"); + const [sampleIndex, setSampleIndex] = useState(0); useEffect(() => { if (!open) return; @@ -68,6 +69,7 @@ export function AddSmartColumnDialog({ setLabel(editing?.label ?? ""); setLabelEdited(editing !== null); setDisplayAs(editing?.displayAs ?? "text"); + setSampleIndex(0); }, [open, editing]); const sampleUrl = useMemo(() => { @@ -84,7 +86,9 @@ export function AddSmartColumnDialog({ const effectiveLabel = labelEdited ? label : labelFromPath(path); - const sampleRun = sample.data?.run ?? null; + const sampleRuns = sample.data?.runs ?? []; + const clampedIndex = sampleRuns.length > 0 ? Math.min(sampleIndex, sampleRuns.length - 1) : 0; + const sampleRun = sampleRuns[clampedIndex] ?? null; const parsed = useMemo(() => { if (!sampleRun) return undefined; @@ -195,9 +199,17 @@ export function AddSmartColumnDialog({
- - Sample — {source} of the newest run - +
+ Sample — {source} + {sampleRuns.length > 0 && ( + setSampleIndex((i) => Math.max(0, i - 1))} + onNext={() => setSampleIndex((i) => Math.min(sampleRuns.length - 1, i + 1))} + /> + )} +
{sample.state === "loading" ? ( Loading… @@ -231,12 +243,6 @@ export function AddSmartColumnDialog({ Resolves to - {sampleRun && ( - - Against {sampleRun.friendlyId} - {sampleRun.hasFinished ? "" : " · still running"} - - )}
@@ -253,6 +259,44 @@ export function AddSmartColumnDialog({ ); } +function SampleRunPicker({ + index, + total, + onPrev, + onNext, +}: { + index: number; + total: number; + onPrev: () => void; + onNext: () => void; +}) { + return ( +
+ + {index + 1}/{total} + + + +
+ ); +} + function SourceCard({ label, description, diff --git a/apps/webapp/app/components/runs/v3/SmartColumnSample.tsx b/apps/webapp/app/components/runs/v3/SmartColumnSample.tsx index 1781cf7282c..a361ff1c069 100644 --- a/apps/webapp/app/components/runs/v3/SmartColumnSample.tsx +++ b/apps/webapp/app/components/runs/v3/SmartColumnSample.tsx @@ -1,17 +1,14 @@ -import { useState } from "react"; import { cn } from "~/utils/cn"; /** Max children rendered per node so a large blob can't blow up the DOM. */ const MAX_CHILDREN = 200; -/** Levels auto-expanded; deeper nodes start collapsed and open on click. */ -const AUTO_OPEN_DEPTH = 2; const MAX_STRING = 80; /** - * A clickable, syntax-colored JSON tree for the smart-column sample. Only leaf - * values are selectable: clicking one fills the JSON path field via - * `onSelectPath` and highlights it. Object/array rows only expand and collapse, - * so you drill into a container and pick a leaf inside it. + * A clickable, syntax-colored JSON tree for the smart-column sample, rendered + * fully expanded. Only leaf values are selectable: clicking one fills the JSON + * path field via `onSelectPath` and highlights it. Objects and arrays are shown + * inline (not clickable) so you can see the shape and pick a leaf inside them. */ export function SmartColumnSample({ value, @@ -28,7 +25,6 @@ export function SmartColumnSample({ name={undefined} path="$" value={value} - depth={0} activePath={activePath} onSelectPath={onSelectPath} /> @@ -46,18 +42,15 @@ function JsonNode({ name, path, value, - depth, activePath, onSelectPath, }: { name: string | number | undefined; path: string; value: unknown; - depth: number; activePath: string; onSelectPath: (path: string) => void; }) { - const [open, setOpen] = useState(depth < AUTO_OPEN_DEPTH); const isObject = value !== null && typeof value === "object"; const selected = path === activePath; const keyLabel = name === undefined ? null : typeof name === "number" ? name : `"${name}"`; @@ -90,43 +83,27 @@ function JsonNode({ return (
- - {open && ( -
- {shown.map(([key, childValue]) => ( - - ))} - {entries.length > MAX_CHILDREN && ( -
… {entries.length - MAX_CHILDREN} more
- )} -
{closeBrace}
-
- )} + {openBrace} +
+
+ {shown.map(([key, childValue]) => ( + + ))} + {entries.length > MAX_CHILDREN && ( +
… {entries.length - MAX_CHILDREN} more
+ )} +
+
{closeBrace}
); } diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.smart-column-sample.ts b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.smart-column-sample.ts index b0343d98154..ba6ac6924ed 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.smart-column-sample.ts +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.smart-column-sample.ts @@ -7,10 +7,14 @@ import { RunsRepository } from "~/services/runsRepository/runsRepository.server" import { $replica } from "~/db.server"; import { isFinalRunStatus } from "~/v3/taskStatus"; +/** How many recent runs the smart-column preview can page through. */ +const SAMPLE_RUN_COUNT = 10; + /** - * Newest run for the current filters, with its raw payload/metadata/output - * packets, feeding the "Add smart column" live preview. The client parses and - * resolves the JSON path; the server never parses (same rule as the list). + * The most recent runs for the current filters, with their raw + * payload/metadata/output packets, feeding the "Add smart column" preview. The + * client picks which run to sample, parses, and resolves the JSON path; the + * server never parses (same rule as the list). */ export async function loader({ request, params }: LoaderFunctionArgs) { const { project, environment } = await loadProjectEnvironmentFromRequest(request, params); @@ -42,16 +46,11 @@ export async function loader({ request, params }: LoaderFunctionArgs) { machines: filters.machines, errorId: filters.errorId, runSelect: deriveRunSelect([], ["payload", "metadata", "output"]), - page: { size: 1 }, + page: { size: SAMPLE_RUN_COUNT }, }); - const run = runs[0]; - if (!run) { - return { run: null }; - } - return { - run: { + runs: runs.map((run) => ({ friendlyId: run.friendlyId, status: run.status, hasFinished: isFinalRunStatus(run.status), @@ -63,6 +62,6 @@ export async function loader({ request, params }: LoaderFunctionArgs) { metadataType: run.metadataType, output: run.output, outputType: run.outputType, - }, + })), }; } From d45f094485678509224f150cbaf6311cc3a376a2 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Mon, 17 Aug 2026 17:37:23 +0100 Subject: [PATCH 15/35] feat(webapp): trim smart-column sample chrome Drop the sample help text and the run counter; the run picker keeps just its prev/next arrows. --- .../runs/v3/AddSmartColumnDialog.tsx | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx b/apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx index 234e877d7af..9716ce62e03 100644 --- a/apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx +++ b/apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx @@ -227,17 +227,11 @@ export function AddSmartColumnDialog({ No {source} value for this run. ) : ( - <> - - - Click a value to use its path. Expand objects and arrays to reach the value you - want. - - + )} Resolves to @@ -272,9 +266,6 @@ function SampleRunPicker({ }) { return (
- - {index + 1}/{total} - ))}
- - Number right-aligns the column and uses tabular figures. Anything that doesn't - parse falls back to text. -
-
-
- Sample — {source} +
+
+ Sample {source} {usable.length > 1 && ( )}
- {!sampleLoaded ? ( - - Loading… - - ) : activeSample ? ( - - ) : runCount === 0 ? ( - - No runs to sample. - - ) : anyOffloaded ? ( - - Recent {source}s are offloaded to object storage, too large to sample here. - - ) : ( - - No recent run has a {source} value to sample. - - )} +
+ {!sampleLoaded ? ( + + Loading… + + ) : activeSample ? ( + + ) : runCount === 0 ? ( + + No runs to sample yet. + + ) : anyOffloaded ? ( + + Recent {source}s are too large to sample here. + + ) : ( + + No recent run has a {source} to sample. + + )} +
- Preview +
+ Preview +
@@ -377,7 +380,7 @@ function SmartColumnPreview({ {!loaded ? (
Loading…
) : rows.length === 0 ? ( -
No runs
+
No runs yet
) : ( rows.map((row, index) => { const cell = def.path diff --git a/apps/webapp/app/components/runs/v3/RunsDisplayOptions.tsx b/apps/webapp/app/components/runs/v3/RunsDisplayOptions.tsx index 95b4e240390..0e2dd1062d0 100644 --- a/apps/webapp/app/components/runs/v3/RunsDisplayOptions.tsx +++ b/apps/webapp/app/components/runs/v3/RunsDisplayOptions.tsx @@ -199,6 +199,7 @@ function ColumnRow({ col, checked, locked, + reserveIcon, dragging, isOver, onToggle, @@ -227,7 +228,7 @@ function ColumnRow({ return (
{isOver &&
} {locked ? : } - {isSmart && } - - {col.def.label} - - {onEdit && ( - - )} - {onRemove && ( - - )} - + {col.def.label} + + {isSmart && } + +
+ {onEdit && ( + + )} + {onRemove && ( + + )} + + + +
); } diff --git a/apps/webapp/app/components/runs/v3/SmartColumnSample.tsx b/apps/webapp/app/components/runs/v3/SmartColumnSample.tsx index 2b88a9665ae..20084f6e18c 100644 --- a/apps/webapp/app/components/runs/v3/SmartColumnSample.tsx +++ b/apps/webapp/app/components/runs/v3/SmartColumnSample.tsx @@ -20,7 +20,7 @@ export function SmartColumnSample({ onSelectPath: (path: string) => void; }) { return ( -
+
Date: Mon, 17 Aug 2026 23:39:55 +0100 Subject: [PATCH 23/35] fix(webapp): address PR review on runs-list column customization - Import ResolvedColumn in runColumns.test.ts (typecheck). - Populate payload/output smart columns on the per-task, scheduled, agent and error run lists by threading the column select through those loaders and ErrorGroupPresenter (previously only the main runs list did). - Memoize per-row source parsing so payload/output are decoded once per run rather than on every render / live-poll tick. - Support column reordering in Firefox (set drag data on dragstart, preventDefault on drop) and add keyboard reordering via the grip handle (arrow up/down), revealing row controls on focus. - Show the tags cell placeholder for an empty tag list, and let a live update clear a source value instead of keeping stale data. - Escape backslashes in bracket-notation sample paths. --- .../components/runs/v3/RunsDisplayOptions.tsx | 46 ++++++++++++++++--- .../components/runs/v3/SmartColumnSample.tsx | 2 +- .../app/components/runs/v3/TaskRunsTable.tsx | 15 +++++- .../app/components/runs/v3/runColumns.test.ts | 1 + .../v3/ErrorGroupPresenter.server.ts | 11 +++++ .../route.tsx | 2 + .../route.tsx | 2 + .../useRunsLiveReload.ts | 12 ++--- .../route.tsx | 2 + .../route.tsx | 2 + 10 files changed, 79 insertions(+), 16 deletions(-) diff --git a/apps/webapp/app/components/runs/v3/RunsDisplayOptions.tsx b/apps/webapp/app/components/runs/v3/RunsDisplayOptions.tsx index 0e2dd1062d0..49d51e334f4 100644 --- a/apps/webapp/app/components/runs/v3/RunsDisplayOptions.tsx +++ b/apps/webapp/app/components/runs/v3/RunsDisplayOptions.tsx @@ -109,6 +109,16 @@ export function RunsDisplayOptions() { applyLayout(arr); }; + const move = (key: string, delta: number) => { + const arr = [...layout.ordered]; + const from = arr.findIndex((o) => keyFor(o.col) === key); + const to = from + delta; + if (from < 0 || to < 0 || to >= arr.length) return; + const [moved] = arr.splice(from, 1); + arr.splice(to, 0, moved); + applyLayout(arr); + }; + const endDrag = () => { setDragKey(null); setOverKey(null); @@ -148,6 +158,7 @@ export function RunsDisplayOptions() { endDrag(); }} onToggle={() => toggleHidden(key)} + onMove={(delta) => move(key, delta)} onEdit={ col.kind === "smart" ? () => setEditing({ index: col.index, def: col.def }) @@ -199,10 +210,10 @@ function ColumnRow({ col, checked, locked, - reserveIcon, dragging, isOver, onToggle, + onMove, onEdit, onRemove, onDragStart, @@ -216,6 +227,7 @@ function ColumnRow({ dragging: boolean; isOver: boolean; onToggle: () => void; + onMove: (delta: number) => void; onEdit?: () => void; onRemove?: () => void; onDragStart: () => void; @@ -232,11 +244,18 @@ function ColumnRow({ dragging && "opacity-40" )} draggable - onDragStart={onDragStart} + onDragStart={(e) => { + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", ""); + onDragStart(); + }} onDragEnter={onDragEnter} onDragEnd={onDragEnd} onDragOver={(e) => e.preventDefault()} - onDrop={onDrop} + onDrop={(e) => { + e.preventDefault(); + onDrop(); + }} > {isOver &&
} {locked ? : } @@ -254,7 +273,7 @@ function ColumnRow({ type="button" onClick={onEdit} aria-label={`Edit ${col.def.label}`} - className="flex size-6 items-center justify-center rounded text-text-dimmed opacity-0 transition hover:bg-charcoal-700 hover:text-text-bright focus-custom group-hover:opacity-100" + className="flex size-6 items-center justify-center rounded text-text-dimmed opacity-0 transition hover:bg-charcoal-700 hover:text-text-bright focus-custom group-hover:opacity-100 group-focus-within:opacity-100" > @@ -264,14 +283,27 @@ function ColumnRow({ type="button" onClick={onRemove} aria-label={`Remove ${col.def.label}`} - className="flex size-6 items-center justify-center rounded text-text-dimmed opacity-0 transition hover:bg-charcoal-700 hover:text-error focus-custom group-hover:opacity-100" + className="flex size-6 items-center justify-center rounded text-text-dimmed opacity-0 transition hover:bg-charcoal-700 hover:text-error focus-custom group-hover:opacity-100 group-focus-within:opacity-100" > )} - +
); diff --git a/apps/webapp/app/components/runs/v3/SmartColumnSample.tsx b/apps/webapp/app/components/runs/v3/SmartColumnSample.tsx index 20084f6e18c..468720e2dac 100644 --- a/apps/webapp/app/components/runs/v3/SmartColumnSample.tsx +++ b/apps/webapp/app/components/runs/v3/SmartColumnSample.tsx @@ -35,7 +35,7 @@ export function SmartColumnSample({ function childPath(parentPath: string, key: string | number): string { if (typeof key === "number") return `${parentPath}[${key}]`; if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key)) return `${parentPath}.${key}`; - return `${parentPath}['${key.replace(/'/g, "\\'")}']`; + return `${parentPath}['${key.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}']`; } function JsonNode({ diff --git a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx index d1f5b8045ed..3892198e162 100644 --- a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx +++ b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx @@ -472,7 +472,7 @@ const STANDARD_RENDERERS: Record = { cell: ({ run, path }) => (
- {run.tags.map((tag) => ) || "–"} + {run.tags.length > 0 ? run.tags.map((tag) => ) : "–"}
), @@ -511,6 +511,8 @@ function SmartColumnCell({ ); } +const EMPTY_SOURCES: Partial> = {}; + function buildRowSources( run: NextRunListItem, sources: SmartColumnSource[] @@ -611,6 +613,15 @@ export function TaskRunsTable({ const visibleColumns = layout.visible; const referencedSources = useMemo(() => visibleSmartSources(visibleColumns), [visibleColumns]); + const sourcesByRunId = useMemo(() => { + const map = new Map>>(); + if (referencedSources.length === 0) return map; + for (const run of runs) { + map.set(run.id, buildRowSources(run, referencedSources)); + } + return map; + }, [runs, referencedSources]); + const dataColSpan = visibleColumns.reduce( (sum, col) => sum + (col.kind === "standard" ? (STANDARD_RENDERERS[col.def.id]?.span ?? 1) : 1), 0 @@ -705,7 +716,7 @@ export function TaskRunsTable({ }, searchParams ); - const sources = buildRowSources(run, referencedSources); + const sources = sourcesByRunId.get(run.id) ?? EMPTY_SOURCES; return ( {allowSelection && ( diff --git a/apps/webapp/app/components/runs/v3/runColumns.test.ts b/apps/webapp/app/components/runs/v3/runColumns.test.ts index 8e9388c3980..d30bf24802b 100644 --- a/apps/webapp/app/components/runs/v3/runColumns.test.ts +++ b/apps/webapp/app/components/runs/v3/runColumns.test.ts @@ -6,6 +6,7 @@ import { encodeColumnLayout, encodeSmartColumn, resolveColumnLayout, + type ResolvedColumn, type RunColumnRuntime, type SmartColumnDef, } from "./runColumns"; diff --git a/apps/webapp/app/presenters/v3/ErrorGroupPresenter.server.ts b/apps/webapp/app/presenters/v3/ErrorGroupPresenter.server.ts index 6eb299a5e7b..38149abe5e7 100644 --- a/apps/webapp/app/presenters/v3/ErrorGroupPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ErrorGroupPresenter.server.ts @@ -12,6 +12,12 @@ import { type NextRunList, } from "~/presenters/v3/NextRunListPresenter.server"; import { sortVersionsDescending } from "~/utils/semver"; +import type { RunColumnId, SmartColumnSource } from "~/components/runs/v3/runColumns"; + +type RunColumnsSelect = { + visibleStandardIds: RunColumnId[]; + smartSources: SmartColumnSource[]; +}; const errorGroupGranularity = new TimeGranularity([ { max: "1h", granularity: "1m" }, @@ -33,6 +39,7 @@ export type ErrorGroupOptions = { to?: number; cursor?: string; direction?: Direction; + columns?: RunColumnsSelect; }; const DEFAULT_RUNS_PAGE_SIZE = 25; @@ -99,6 +106,7 @@ export class ErrorGroupPresenter extends BasePresenter { to, cursor, direction, + columns, }: ErrorGroupOptions ) { const displayableEnvironment = await findDisplayableEnvironment(environmentId, userId); @@ -128,6 +136,7 @@ export class ErrorGroupPresenter extends BasePresenter { to: time.to.getTime(), cursor, direction, + columns, }), this.getState(environmentId, summary?.taskIdentifier, fingerprint), ]); @@ -397,6 +406,7 @@ export class ErrorGroupPresenter extends BasePresenter { to?: number; cursor?: string; direction?: Direction; + columns?: RunColumnsSelect; } ): Promise { const runListPresenter = new NextRunListPresenter(this.replica, this.clickhouse); @@ -412,6 +422,7 @@ export class ErrorGroupPresenter extends BasePresenter { to: options.to, cursor: options.cursor, direction: options.direction, + columns: options.columns, }); if (result.runs.length === 0) { diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.agents.$agentParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.agents.$agentParam/route.tsx index d2197776f69..b4cdb64f9e4 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.agents.$agentParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.agents.$agentParam/route.tsx @@ -38,6 +38,7 @@ import { type AgentDetail, } from "~/presenters/v3/AgentDetailPresenter.server"; import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server"; +import { getRunColumnsForSelect } from "~/presenters/v3/runColumnsFromRequest.server"; import { SessionListPresenter } from "~/presenters/v3/SessionListPresenter.server"; import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server"; import { getResizableSnapshot } from "~/services/resizablePanel.server"; @@ -162,6 +163,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { to, cursor, direction, + columns: getRunColumnsForSelect(request), }) .catch(() => null); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.$fingerprint/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.$fingerprint/route.tsx index 1946312aac3..12b4cda2127 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.$fingerprint/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.$fingerprint/route.tsx @@ -74,6 +74,7 @@ import { type ErrorGroupSummary, } from "~/presenters/v3/ErrorGroupPresenter.server"; import { type NextRunList } from "~/presenters/v3/NextRunListPresenter.server"; +import { getRunColumnsForSelect } from "~/presenters/v3/runColumnsFromRequest.server"; import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server"; import { requireUser, requireUserId } from "~/services/session.server"; import { rbac } from "~/services/rbac.server"; @@ -268,6 +269,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { to, cursor, direction, + columns: getRunColumnsForSelect(request), }) .catch((error) => { if (error instanceof ServiceValidationError) { diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/useRunsLiveReload.ts b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/useRunsLiveReload.ts index 510bba33136..8127533634b 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/useRunsLiveReload.ts +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/useRunsLiveReload.ts @@ -87,12 +87,12 @@ function patchVisibleRunsWithLiveUpdates(currentRuns: ListedRun[], liveRuns: Liv usageDurationMs: update.usageDurationMs, costInCents: update.costInCents, baseCostInCents: update.baseCostInCents, - metadata: update.metadata ?? run.metadata, - metadataType: update.metadataType ?? run.metadataType, - payload: update.payload ?? run.payload, - payloadType: update.payloadType ?? run.payloadType, - output: update.output ?? run.output, - outputType: update.outputType ?? run.outputType, + metadata: update.metadata !== undefined ? update.metadata : run.metadata, + metadataType: update.metadataType !== undefined ? update.metadataType : run.metadataType, + payload: update.payload !== undefined ? update.payload : run.payload, + payloadType: update.payloadType !== undefined ? update.payloadType : run.payloadType, + output: update.output !== undefined ? update.output : run.output, + outputType: update.outputType !== undefined ? update.outputType : run.outputType, }; }); } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx index 60a3aaf1043..58f99cfce0c 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx @@ -75,6 +75,7 @@ import { useZoomToTimeFilter } from "~/hooks/useZoomToTimeFilter"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server"; +import { getRunColumnsForSelect } from "~/presenters/v3/runColumnsFromRequest.server"; import { ScheduleListPresenter } from "~/presenters/v3/ScheduleListPresenter.server"; import { TaskDetailPresenter, @@ -219,6 +220,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { cursor, direction, includeHasAnyRuns: true, + columns: getRunColumnsForSelect(request), }) .catch(() => null); diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.standard.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.standard.$taskParam/route.tsx index db829fc74b6..715a09f14ce 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.standard.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.standard.$taskParam/route.tsx @@ -46,6 +46,7 @@ import { useSearchParams } from "~/hooks/useSearchParam"; import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server"; +import { getRunColumnsForSelect } from "~/presenters/v3/runColumnsFromRequest.server"; import { TaskDetailPresenter, type TaskActivity, @@ -163,6 +164,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { cursor, direction, includeHasAnyRuns: true, + columns: getRunColumnsForSelect(request), }) .catch(() => null); From 2d3140fb2b54b68381d72f155de527fd8935af8b Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Tue, 18 Aug 2026 10:22:11 +0100 Subject: [PATCH 24/35] feat(webapp): move Display control next to pagination and add it to task run lists Move the Display button out of the runs filter row to sit just left of the pagination controls, and surface it on the per-task, scheduled, agent and error run lists (in each Runs toolbar next to pagination) so columns can be customized there too. --- apps/webapp/app/components/runs/v3/RunFilters.tsx | 2 -- .../app/components/runs/v3/RunsDisplayOptions.tsx | 2 +- .../route.tsx | 14 +++++++++----- .../route.tsx | 2 ++ .../route.tsx | 2 ++ .../route.tsx | 2 ++ .../route.tsx | 2 ++ 7 files changed, 18 insertions(+), 8 deletions(-) diff --git a/apps/webapp/app/components/runs/v3/RunFilters.tsx b/apps/webapp/app/components/runs/v3/RunFilters.tsx index 1e84b3bc260..7dd6e7d9a61 100644 --- a/apps/webapp/app/components/runs/v3/RunFilters.tsx +++ b/apps/webapp/app/components/runs/v3/RunFilters.tsx @@ -68,7 +68,6 @@ import { type loader as versionsLoader } from "~/routes/resources.orgs.$organiza import { makeFriendlyIdValidator } from "~/utils/friendlyId"; import { Button } from "../../primitives/Buttons"; import { AIFilterInput } from "./AIFilterInput"; -import { RunsDisplayOptions } from "./RunsDisplayOptions"; import { BulkActionTypeCombo } from "./BulkAction"; import { RegionLabel } from "./RegionLabel"; import { @@ -416,7 +415,6 @@ export function RunsFilters(props: RunFiltersProps) { /> )} -
); } diff --git a/apps/webapp/app/components/runs/v3/RunsDisplayOptions.tsx b/apps/webapp/app/components/runs/v3/RunsDisplayOptions.tsx index 49d51e334f4..29fe86d9996 100644 --- a/apps/webapp/app/components/runs/v3/RunsDisplayOptions.tsx +++ b/apps/webapp/app/components/runs/v3/RunsDisplayOptions.tsx @@ -128,7 +128,7 @@ export function RunsDisplayOptions() { <> - diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.agents.$agentParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.agents.$agentParam/route.tsx index b4cdb64f9e4..46a18d3e114 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.agents.$agentParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.agents.$agentParam/route.tsx @@ -39,6 +39,7 @@ import { } from "~/presenters/v3/AgentDetailPresenter.server"; import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server"; import { getRunColumnsForSelect } from "~/presenters/v3/runColumnsFromRequest.server"; +import { RunsDisplayOptions } from "~/components/runs/v3/RunsDisplayOptions"; import { SessionListPresenter } from "~/presenters/v3/SessionListPresenter.server"; import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server"; import { getResizableSnapshot } from "~/services/resizablePanel.server"; @@ -337,11 +338,14 @@ export default function Page() { ) : ( - - - {(list) => (list ? : null)} - - + <> + + + + {(list) => (list ? : null)} + + + )}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.$fingerprint/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.$fingerprint/route.tsx index 12b4cda2127..3aa7f10026e 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.$fingerprint/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.errors.$fingerprint/route.tsx @@ -75,6 +75,7 @@ import { } from "~/presenters/v3/ErrorGroupPresenter.server"; import { type NextRunList } from "~/presenters/v3/NextRunListPresenter.server"; import { getRunColumnsForSelect } from "~/presenters/v3/runColumnsFromRequest.server"; +import { RunsDisplayOptions } from "~/components/runs/v3/RunsDisplayOptions"; import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server"; import { requireUser, requireUserId } from "~/services/session.server"; import { rbac } from "~/services/rbac.server"; @@ -536,6 +537,7 @@ function ErrorGroupDetail({ > Bulk replay… +
)} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx index 2a090b8efa1..7b3ba95c1da 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx @@ -39,6 +39,7 @@ import { StepNumber } from "~/components/primitives/StepNumber"; import { TextLink } from "~/components/primitives/TextLink"; import { SimpleTooltip } from "~/components/primitives/Tooltip"; import { RunsFilters, type TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters"; +import { RunsDisplayOptions } from "~/components/runs/v3/RunsDisplayOptions"; import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable"; import { BULK_ACTION_RUN_LIMIT } from "~/consts"; import { $replica } from "~/db.server"; @@ -404,6 +405,7 @@ function RunsList({ )} +
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx index 58f99cfce0c..9b5d9cd728d 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.scheduled.$taskParam/route.tsx @@ -76,6 +76,7 @@ import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server"; import { getRunColumnsForSelect } from "~/presenters/v3/runColumnsFromRequest.server"; +import { RunsDisplayOptions } from "~/components/runs/v3/RunsDisplayOptions"; import { ScheduleListPresenter } from "~/presenters/v3/ScheduleListPresenter.server"; import { TaskDetailPresenter, @@ -371,6 +372,7 @@ export default function Page() { onClick={() => showNewRunsRef.current()} /> ) : null} + {(list) => (list ? : null)} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.standard.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.standard.$taskParam/route.tsx index 715a09f14ce..673469039b2 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.standard.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.tasks.standard.$taskParam/route.tsx @@ -47,6 +47,7 @@ import { findProjectBySlug } from "~/models/project.server"; import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server"; import { getRunColumnsForSelect } from "~/presenters/v3/runColumnsFromRequest.server"; +import { RunsDisplayOptions } from "~/components/runs/v3/RunsDisplayOptions"; import { TaskDetailPresenter, type TaskActivity, @@ -268,6 +269,7 @@ export default function Page() { onClick={() => showNewRunsRef.current()} /> ) : null} + {(list) => (list ? : null)} From f9131758986d9ef2d4e4e911c94dafee6ff0d307 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Tue, 18 Aug 2026 10:23:17 +0100 Subject: [PATCH 25/35] feat(webapp): label the runs columns control "Columns" --- apps/webapp/app/components/runs/v3/RunsDisplayOptions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/components/runs/v3/RunsDisplayOptions.tsx b/apps/webapp/app/components/runs/v3/RunsDisplayOptions.tsx index 29fe86d9996..2abcb17ef99 100644 --- a/apps/webapp/app/components/runs/v3/RunsDisplayOptions.tsx +++ b/apps/webapp/app/components/runs/v3/RunsDisplayOptions.tsx @@ -129,7 +129,7 @@ export function RunsDisplayOptions() { From 054e39b164c8803f09cd8176693dd9a34262e924 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Tue, 18 Aug 2026 10:28:41 +0100 Subject: [PATCH 26/35] fix(webapp): preserve columns on clear-filters and read escaped sample paths - Carry the current cols/hide/sc params through the clear-filters form as hidden inputs so clearing filters no longer wipes a customized column layout. - Make getAtPath (and labelFromPath) understand the backslash escaping that childPath emits for bracket keys, so picking a sample value whose key contains a quote or backslash resolves instead of showing an empty column. Adds a round-trip test. --- .../webapp/app/components/runs/v3/RunFilters.tsx | 9 +++++++++ .../components/runs/v3/smartColumnData.test.ts | 5 +++++ .../app/components/runs/v3/smartColumnData.ts | 16 +++++++++++----- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/apps/webapp/app/components/runs/v3/RunFilters.tsx b/apps/webapp/app/components/runs/v3/RunFilters.tsx index 7dd6e7d9a61..2091d3e0f5b 100644 --- a/apps/webapp/app/components/runs/v3/RunFilters.tsx +++ b/apps/webapp/app/components/runs/v3/RunFilters.tsx @@ -406,6 +406,15 @@ export function RunsFilters(props: RunFiltersProps) { {hasFilters && (
+ {searchParams.getAll("cols").map((v, i) => ( + + ))} + {searchParams.getAll("hide").map((v, i) => ( + + ))} + {searchParams.getAll("sc").map((v, i) => ( + + ))}