From f8835e67ba59c3075e831eebbfe1f775ad70db09 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Wed, 30 Sep 2026 14:17:21 -0700 Subject: [PATCH] fix(web): Workflows, Artifacts and Skills lists match the mockup rows, columns and filter placement (CL-9660) --- apps/web/src/bench/information-tab.tsx | 36 ++- apps/web/src/library/artifact-summary.ts | 1 + apps/web/src/library/index.ts | 2 +- apps/web/src/library/provenance.ts | 10 + apps/web/src/pages/library-list.css | 100 ++++++++ apps/web/src/pages/library-list.tsx | 48 ++++ apps/web/src/pages/library-page.css | 16 -- apps/web/src/pages/library-page.tsx | 282 ++++++----------------- apps/web/src/pages/routines-page.tsx | 34 +-- apps/web/src/pages/routines.css | 50 ---- apps/web/src/pages/skills-page.tsx | 101 +++----- apps/web/src/shell/library-artifacts.ts | 3 + 12 files changed, 326 insertions(+), 357 deletions(-) create mode 100644 apps/web/src/pages/library-list.css create mode 100644 apps/web/src/pages/library-list.tsx delete mode 100644 apps/web/src/pages/library-page.css diff --git a/apps/web/src/bench/information-tab.tsx b/apps/web/src/bench/information-tab.tsx index bda65bb1a5..5be8cb8af6 100644 --- a/apps/web/src/bench/information-tab.tsx +++ b/apps/web/src/bench/information-tab.tsx @@ -8,13 +8,15 @@ import { ApprovalRow } from "@/chat/approval-row"; import { IdentityAvatar } from "@/chat/avatar"; import type { WorkbenchParticipant } from "@/chat/threads-api"; import { libraryArtifactPath } from "@/library"; -import { FileText, FlowArrow } from "@/lib/icons"; +import { FileText, FlowArrow, Lightning } from "@/lib/icons"; import { Link } from "@/navigation"; -import { ARTIFACTS_PATH_PREFIX, WORKFLOWS_PATH_PREFIX } from "@/path-ids"; +import { ARTIFACTS_PATH_PREFIX, SKILLS_PATH_PREFIX, WORKFLOWS_PATH_PREFIX } from "@/path-ids"; import { tenantKeys } from "@/query-client"; import { routineDetailPath } from "@/global-routines"; import { scheduleSentence } from "@/pages/routines-page"; import { listScheduledWorkflows } from "@/routines-api"; +import { skillDisplayName } from "@/skill-display-name"; +import { listSkills } from "@/skills-api"; import { WorkbenchSchedulesPanel } from "../pages/workbench-schedules-panel"; import { usePendingApprovals } from "../pending-approvals"; import { benchLink } from "../shell/page-crumbs"; @@ -149,6 +151,12 @@ export function InformationTab({ .slice(0, 2) : []; + const skills = useQuery({ + queryKey: tenantKeys.skills(workbenchTenantId), + queryFn: () => listSkills(workbenchTenantId), + }); + const shownSkills = (skills.data ?? []).slice(0, 2); + return ( <> @@ -238,6 +246,30 @@ export function InformationTab({ ))} + +
+ {skills.isLoading ? : null} + {skills.isSuccess && shownSkills.length === 0 ? ( +

No skills here yet.

+ ) : null} +
+ {shownSkills.map((skill) => ( + +
+
); } diff --git a/apps/web/src/library/artifact-summary.ts b/apps/web/src/library/artifact-summary.ts index 50d37bbfcf..44b8b13a26 100644 --- a/apps/web/src/library/artifact-summary.ts +++ b/apps/web/src/library/artifact-summary.ts @@ -13,6 +13,7 @@ export const ArtifactSummary = type({ kind: "string", createdAt: "string", "updatedAt?": "string | null", + "from?": "string | null", }); export type ArtifactSummary = typeof ArtifactSummary.infer; diff --git a/apps/web/src/library/index.ts b/apps/web/src/library/index.ts index 461a6c15b3..0af91965be 100644 --- a/apps/web/src/library/index.ts +++ b/apps/web/src/library/index.ts @@ -9,7 +9,7 @@ export { libraryArtifactIdFromPath, } from "./kind-filter"; export type { LibraryKindSegment } from "./kind-filter"; -export { workflowRunIdFromSource } from "./provenance"; +export { artifactFromLabel, workflowRunIdFromSource } from "./provenance"; export { ARTIFACT_RENDERER_KINDS, isTextDecodableMediaType, diff --git a/apps/web/src/library/provenance.ts b/apps/web/src/library/provenance.ts index 49ac2b79e1..be94424f01 100644 --- a/apps/web/src/library/provenance.ts +++ b/apps/web/src/library/provenance.ts @@ -11,3 +11,13 @@ export function workflowRunIdFromSource( const runId = source.runId; return typeof runId === "string" && runId !== "" ? runId : null; } + +/** Where an artifact came from, for the list's From column. Null when the + * `source` names no origin. */ +export function artifactFromLabel( + source: Record | null | undefined, +): string | null { + if (source === null || source === undefined) return null; + if (source.origin === "workflow") return "Workflow run"; + return typeof source.origin === "string" && source.origin !== "" ? source.origin : null; +} diff --git a/apps/web/src/pages/library-list.css b/apps/web/src/pages/library-list.css new file mode 100644 index 0000000000..78f664da19 --- /dev/null +++ b/apps/web/src/pages/library-list.css @@ -0,0 +1,100 @@ +/* The raised list card and filter the Workflows, Artifacts and Skills + pages share (mockups: workflows.html, artifacts.html, skills.html). */ +.lib-filter { + display: block; + height: 34px; + width: 240px; + max-width: 100%; + margin-bottom: 16px; + padding: 0 12px; + border: 0; + border-radius: var(--r-md); + background: var(--surface); + box-shadow: inset 0 0 0 1px var(--line-strong); + color: var(--ink); + font: inherit; + font-size: 13.5px; +} +.lib-filter:focus { + outline: none; + box-shadow: + inset 0 0 0 1px var(--ink-3), + 0 0 0 3px var(--hover); +} + +.lib-list { + margin: 0; + padding: 0; + list-style: none; + background: var(--surface); + border-radius: var(--r-lg); + box-shadow: var(--raised); + overflow: hidden; + font-size: 13.5px; +} + +.lib-head, +.lib-row { + display: grid; + grid-template-columns: var(--lib-cols); + align-items: center; + gap: 16px; + padding: 12px 16px; + border-bottom: 1px solid var(--line); +} +.lib-head { + padding-block: 10px; + background: var(--surface-sunk); + color: var(--ink-3); + font-size: 12px; + font-weight: 700; +} +.lib-row:last-child { + border-bottom: 0; +} +.lib-row[data-link] { + cursor: pointer; +} +.lib-row[data-link]:hover, +.lib-row[data-state="selected"] { + background: var(--surface-sunk); +} + +.lib-cell { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} +.lib-cell--soft { + color: var(--ink-2); +} +.lib-cell--end { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; +} +.lib-name { + font-weight: 700; + color: var(--ink); + text-decoration: none; +} +.lib-name--mono { + font-family: var(--font-mono); + font-size: 13px; +} + +@media (max-width: 720px) { + .lib-head { + display: none; + } + .lib-row { + grid-template-columns: minmax(0, 1fr); + gap: 4px; + } + .lib-cell--end { + justify-content: flex-start; + } +} diff --git a/apps/web/src/pages/library-list.tsx b/apps/web/src/pages/library-list.tsx new file mode 100644 index 0000000000..2d24573b32 --- /dev/null +++ b/apps/web/src/pages/library-list.tsx @@ -0,0 +1,48 @@ +import type { CSSProperties, ReactNode } from "react"; + +import "./library-list.css"; + +export function ListFilter({ + label, + value, + onChange, +}: { + readonly label: string; + readonly value: string; + readonly onChange: (value: string) => void; +}) { + return ( + onChange(event.target.value)} + /> + ); +} + +/** The raised card: a sunk header row, then the rows. `columns` is the + * grid-template-columns every row shares. */ +export function ListCard({ + label, + columns, + heads, + children, +}: { + readonly label: string; + readonly columns: string; + readonly heads: readonly string[]; + readonly children: ReactNode; +}) { + return ( +
    + + {children} +
+ ); +} diff --git a/apps/web/src/pages/library-page.css b/apps/web/src/pages/library-page.css deleted file mode 100644 index 241d6f84ae..0000000000 --- a/apps/web/src/pages/library-page.css +++ /dev/null @@ -1,16 +0,0 @@ -/* Artifacts and Skills list pages (mockups: artifacts.html, skills.html). */ -.library-row-title { - display: inline-flex; - align-items: center; - gap: 0.5rem; -} - -.library-row-icon { - flex: none; - color: var(--ink-soft); -} - -.skill-row-name { - font-family: var(--font-mono); - font-size: 13px; -} diff --git a/apps/web/src/pages/library-page.tsx b/apps/web/src/pages/library-page.tsx index 918a0ffdbc..f0e6cb1fc9 100644 --- a/apps/web/src/pages/library-page.tsx +++ b/apps/web/src/pages/library-page.tsx @@ -4,19 +4,12 @@ import { RichEmptyState, SelectionCheckbox, Skeleton, - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, artifactKindLabel, formatRelativeTime, useListSelection, } from "@corbits/react-ui"; -import { Menu, MenuContent, MenuItem, MenuTrigger } from "@corbits/react-ui/ui/menu"; import { toast } from "@corbits/react-ui/ui/toast"; -import type { SelectionCheckboxState, UseListSelectionResult } from "@corbits/react-ui"; +import type { UseListSelectionResult } from "@corbits/react-ui"; import { ArtifactRenderer, artifactMatchesLibraryKindSegment, @@ -28,16 +21,9 @@ import { sortArtifacts, workflowRunIdFromSource, } from "@/library"; -import type { ArtifactSort, ArtifactSummary } from "@/library"; +import type { ArtifactSummary } from "@/library"; import { useQueryClient } from "@tanstack/react-query"; -import { - ArrowsDownUp, - ArrowSquareOut, - FileText, - LinkSimple as LinkIcon, - Stack, - X, -} from "@/lib/icons"; +import { ArrowSquareOut, LinkSimple as LinkIcon, Stack, X } from "@/lib/icons"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { MouseEvent as ReactMouseEvent } from "react"; import { describeApiError, ListSkeleton, QueryView, SignedOutNotice } from "@/lib/api-query"; @@ -51,7 +37,7 @@ import { } from "../api"; import { isAdditiveSelectClick, isRowActivationKey } from "../activatable-row"; import { useBench } from "../bench-context"; -import "./library-page.css"; +import { ListCard, ListFilter } from "./library-list"; import { readLastWorkbenchId } from "../last-workbench"; import { consumePendingLibraryUpload, LIBRARY_UPLOAD_EVENT } from "../library-upload"; import { resolveLibraryWorkbenchScope } from "../library-workbench-scope"; @@ -74,11 +60,6 @@ import { import { PageLayout } from "../shell/page-layout"; import { StageTopBar } from "../shell/stage-top-bar"; -const SORT_LABEL: Record = { - newest: "Newest first", - oldest: "Oldest first", -}; - function ArtifactRows({ artifacts, now, @@ -92,9 +73,6 @@ function ArtifactRows({ readonly onSelect: (id: string) => void; readonly selection: UseListSelectionResult; }) { - const allSelected = artifacts.length > 0 && selection.selectedCount === artifacts.length; - const headerChecked: SelectionCheckboxState = - selection.selectedCount === 0 ? false : allSelected ? true : "indeterminate"; // `useListSelection` hands back ids in toggle order, not row order — a // bottom-up shift-select would otherwise copy links out of visible order. const visibleOrder = useMemo( @@ -103,78 +81,66 @@ function ArtifactRows({ ); return ( - - - - - (allSelected ? selection.clear() : selection.selectAll())} - rowLabel="all artifacts" - ariaLabel="Select all artifacts" - className="opacity-100" - /> - - Title - Kind - Updated - - - - {artifacts.map((artifact) => { - const isSelected = selection.isSelected(artifact.id); - const selectionIds = - isSelected && selection.selectedCount > 1 - ? [...selection.selectedIds].sort( - (a, b) => (visibleOrder.get(a) ?? 0) - (visibleOrder.get(b) ?? 0), - ) - : [artifact.id]; - return ( - { - if (event.shiftKey || isAdditiveSelectClick(event)) { - selection.toggle(artifact.id, { shiftKey: event.shiftKey }); - return; - } - onSelect(artifact.id); - }} - onKeyDown={(event) => { - if (!isRowActivationKey(event.key)) return; - event.preventDefault(); - onSelect(artifact.id); - }} - > - event.stopPropagation()}> - selection.toggle(artifact.id, modifiers)} - rowLabel={artifact.title} - /> - - - - - - - {artifactKindLabel(artifact.kind)} - - - {formatRelativeTime(artifact.updatedAt ?? artifact.createdAt, now)} - - - ); - })} - -
+ + {artifacts.map((artifact) => { + const isSelected = selection.isSelected(artifact.id); + const selectionIds = + isSelected && selection.selectedCount > 1 + ? [...selection.selectedIds].sort( + (a, b) => (visibleOrder.get(a) ?? 0) - (visibleOrder.get(b) ?? 0), + ) + : [artifact.id]; + return ( +
  • { + if (event.shiftKey || isAdditiveSelectClick(event)) { + selection.toggle(artifact.id, { shiftKey: event.shiftKey }); + return; + } + onSelect(artifact.id); + }} + onKeyDown={(event) => { + if (!isRowActivationKey(event.key)) return; + event.preventDefault(); + onSelect(artifact.id); + }} + > + {artifact.title} + {artifact.from ?? "—"} + + {formatRelativeTime(artifact.updatedAt ?? artifact.createdAt, now)} + + event.stopPropagation()}> + + selection.toggle(artifact.id, modifiers)} + rowLabel={artifact.title} + /> + +
  • + ); + })} +
    ); } @@ -276,8 +242,6 @@ function PreviewPane({ ); } -// Every control lives in `StageTopBar`'s action slot (DESIGN.md -> Pages -// & Routing) — a page body never floats its own. export function LibraryPage({ artifacts, now, @@ -292,9 +256,6 @@ export function LibraryPage({ previewLoading = false, previewError = null, tenantId = null, - workbenchScope = null, - scope = "all", - onScopeChange, }: { readonly artifacts: readonly ArtifactSummary[]; readonly now?: number; @@ -312,18 +273,8 @@ export function LibraryPage({ * new tab" / iframe affordance is simply absent without one (a * standalone render with no bench tenant, e.g. these page tests). */ readonly tenantId?: string | null; - /** The workbench the person just came from, if any — drives the - * "This workbench" pill. `null` when Artifacts was reached with no workbench - * in view, in which case the lens has nothing to offer and stays hidden. */ - readonly workbenchScope?: { readonly title: string } | null; - /** Which lens is active: this one workbench's files, or every workbench - * this bench owns. Uncontrolled callers (tests, standalone renders) get - * "all" and no toggle, same as every other optional-controlled prop here. */ - readonly scope?: "workbench" | "all"; - readonly onScopeChange?: (scope: "workbench" | "all") => void; }) { const [localQuery, setLocalQuery] = useState(""); - const [sort, setSort] = useState("newest"); const [localSelected, setLocalSelected] = useState(null); const fileInputRef = useRef(null); @@ -336,9 +287,9 @@ export function LibraryPage({ () => sortArtifacts( onQueryChange === undefined ? filterArtifacts(artifacts, activeQuery) : artifacts, - sort, + "newest", ), - [artifacts, activeQuery, sort, onQueryChange], + [artifacts, activeQuery, onQueryChange], ); const visibleIds = useMemo(() => visible.map((artifact) => artifact.id), [visible]); @@ -384,92 +335,14 @@ export function LibraryPage({ : `${artifacts.length} artifacts` : artifactKindLabel(selectedSummary.kind) } - filter={{ - label: "Filter artifacts", - placeholder: "Filter by name", - value: activeQuery, - onChange: setActiveQuery, - }} actions={ - <> - {selectedSummary !== null ? ( - // An action, not a filter. Used to say bare "All", which read - // as a third scope option next to "All workbenches". - - ) : null} - {workbenchScope !== null && onScopeChange !== undefined ? ( - // Below lg the segmented group is hidden; the overflow menu - // in this same slot is the way to reach All workbenches. - <> -
    - - -
    -
    - - - - - - onScopeChange("workbench")}> - {workbenchScope.title} - - onScopeChange("all")}>All workbenches - - -
    - - ) : null} - - - - - - {(Object.keys(SORT_LABEL) as ArtifactSort[]).map((option) => ( - setSort(option)}> - {SORT_LABEL[option]} - - ))} - - - {onUpload !== undefined ? ( - - ) : null} - + selectedSummary !== null ? ( + // An action, not a filter. Used to say bare "All", which read + // as a third scope option next to "All workbenches". + + ) : undefined } /> {onUpload !== undefined ? ( @@ -497,6 +370,7 @@ export function LibraryPage({
    + {artifacts.length === 0 ? ( } @@ -586,10 +460,7 @@ export function LibraryRoute({ path }: { readonly path: string }) { activity.kind === "ready" ? resolveLibraryWorkbenchScope(activity.workbenches, lastWorkbenchId) : null; - const [scopeOverride, setScopeOverride] = useState<"workbench" | "all" | null>(null); - const scope = scopeOverride ?? (workbenchScope !== null ? "workbench" : "all"); - const scopeTenantId = - scope === "workbench" && workbenchScope !== null ? workbenchScope.tenantId : selectedTenantId; + const scopeTenantId = workbenchScope !== null ? workbenchScope.tenantId : selectedTenantId; const listPath = scopeTenantId === null @@ -675,9 +546,6 @@ export function LibraryRoute({ path }: { readonly path: string }) { {row.definition.name} - {scheduleSentence(row.definition.schedule)} - + {scheduleSentence(row.definition.schedule)} + {row.tenantName === "" ? "This workbench" : row.tenantName} + {runs.kind === "ready" ? formatWhen(lastRun?.createdAt) : "…"} - {state.label} -
    + + {state.label} + +
    onRunNow(row)} />
    @@ -86,9 +90,11 @@ export function GlobalRoutinesList({ }) { const [query, setQuery] = useState(""); const fromId = useFromBench(); + const filter = ; if (rows.length === 0) { return ( + {filter} } title="No workflows yet" @@ -105,14 +111,12 @@ export function GlobalRoutinesList({ ); return ( - setQuery(event.target.value)} - /> -
      + {filter} + {shown.map((row) => ( ))} -
    +
    ); } diff --git a/apps/web/src/pages/routines.css b/apps/web/src/pages/routines.css index b5e2410cdd..2c94bb37ee 100644 --- a/apps/web/src/pages/routines.css +++ b/apps/web/src/pages/routines.css @@ -1,15 +1,3 @@ -.routines-filter { - width: 100%; - max-width: 320px; - padding: 8px 12px; - border: 1px solid var(--line-strong); - border-radius: var(--r-md); - background: var(--surface); - color: var(--ink); - font: inherit; - font-size: 13.5px; -} - .routine-rows { list-style: none; margin: 0; @@ -20,35 +8,6 @@ overflow: hidden; } -.routine-row { - display: grid; - grid-template-columns: minmax(0, 1.4fr) minmax(0, 1.2fr) minmax(0, 1fr) auto auto; - align-items: center; - gap: 16px; - padding: 12px 16px; - border-bottom: 1px solid var(--line); - font-size: 13.5px; -} - -.routine-row:last-child { - border-bottom: 0; -} - -.routine-row:hover { - background: var(--surface-sunk); -} - -.routine-name { - font-family: var(--font-mono); - font-size: 13px; - font-weight: 700; - color: var(--ink); - text-decoration: none; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - .routine-meta { color: var(--ink-2); overflow: hidden; @@ -181,12 +140,3 @@ font-family: var(--font-mono); font-size: 12px; } - -@media (max-width: 720px) { - .routine-row { - grid-template-columns: minmax(0, 1fr) auto; - } - .routine-row .routine-meta { - grid-column: 1 / -1; - } -} diff --git a/apps/web/src/pages/skills-page.tsx b/apps/web/src/pages/skills-page.tsx index 7da5bc353c..c7188009a8 100644 --- a/apps/web/src/pages/skills-page.tsx +++ b/apps/web/src/pages/skills-page.tsx @@ -2,32 +2,24 @@ // created; its version history is that asset's git history. No external // catalog — skills are authored here, private by default or shared. -import { - Button, - EmptyState, - RichEmptyState, - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@corbits/react-ui"; -import { Lightning, Plus } from "@/lib/icons"; +import { EmptyState, RichEmptyState } from "@corbits/react-ui"; +import { Lightning } from "@/lib/icons"; import { WorkbenchLoadingState } from "@/chat"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useState, type ReactNode } from "react"; import { tenantKeys } from "../query-client"; -import "./library-page.css"; import { rowActivationProps } from "../activatable-row"; +import { ListCard, ListFilter } from "./library-list"; +import { RoutinePill } from "./routine-ui"; import { consumePendingNewSkill } from "../command-palette-actions"; import { createSkill, listSkills, type SkillSummary } from "../skills-api"; import { CreateSkillDialog, type SkillCreateInput } from "./create-skill-dialog"; import { useBench } from "../bench-context"; import { SKILLS_PATH_PREFIX } from "../path-ids"; import { skillDisplayName } from "../skill-display-name"; +import { useFromBench } from "../shell/page-crumbs"; import { PageLayout } from "../shell/page-layout"; import { StageTopBar } from "../shell/stage-top-bar"; @@ -100,25 +92,14 @@ export function SkillsPage({ const crumbs = [{ label: "Skills" }]; - function stage( - actions: ReactNode, - body: ReactNode, - filter?: { - readonly value: string; - readonly onChange: (value: string) => void; - }, - ) { + function stage(body: ReactNode) { return (
    - +
    {body} @@ -127,26 +108,20 @@ export function SkillsPage({ ); } - const newSkillButton = ( - - ); + const filterInput = ; if (tenantId === null) { return stage( - null,

    Pick a workbench to see its skills.

    , ); } if (state.status === "loading") { - return stage(null, ); + return stage(); } if (state.status === "error") { return stage( - null, } title="Couldn't load your skills" @@ -160,8 +135,8 @@ export function SkillsPage({ if (skills.length === 0) { return stage( - newSkillButton,
    + {filterInput} } title="No skills yet" @@ -183,8 +158,8 @@ export function SkillsPage({ ); return stage( - newSkillButton, -
    + <> + {filterInput} {filtered.length === 0 ? ( } @@ -192,42 +167,36 @@ export function SkillsPage({ description={`Nothing matches “${query.trim()}”.`} /> ) : ( -
    - - - - Skill - What it teaches - - - - {filtered.map((skill) => ( - open(skill.name))} - > - - {skillDisplayName(skill)} - - - {skill.description} - - - ))} - -
    -
    + + {filtered.map((skill) => ( +
  • open(skill.name))} + > + {skillDisplayName(skill)} + {skill.description} + + installed + +
  • + ))} +
    )} {createDialog} -
    , - { value: query, onChange: setQuery }, + , ); } // A thin adapter that resolves which workbench's registry is listed. export function SkillsRoute({ navigate }: { readonly navigate: (to: string) => void }) { const { selectedTenantId } = useBench(); + const fromBench = useFromBench(); - return ; + return ; } diff --git a/apps/web/src/shell/library-artifacts.ts b/apps/web/src/shell/library-artifacts.ts index ced68f2d8f..482d3c5c74 100644 --- a/apps/web/src/shell/library-artifacts.ts +++ b/apps/web/src/shell/library-artifacts.ts @@ -1,3 +1,4 @@ +import { artifactFromLabel } from "@/library"; import type { ArtifactSummary } from "@/library"; import { ApiQueryError, UnauthenticatedError } from "@/lib/api-query"; @@ -10,6 +11,7 @@ export type ArtifactListRow = { readonly title: string; readonly createdAt: string; readonly updatedAt: string; + readonly source?: Record; }; /** Detail body — list metadata plus the stored content string. */ @@ -26,6 +28,7 @@ export function artifactListRowToSummary(row: ArtifactListRow): ArtifactSummary kind: row.kind, createdAt: row.createdAt, updatedAt: row.updatedAt, + from: artifactFromLabel(row.source), }; }