11'use client'
22
33import type React from 'react'
4+ import { parse } from 'tldts'
45import { Badge , Checkbox , Tooltip } from '@/components/emcn'
56import { cn } from '@/lib/core/utils/cn'
67import type { RowExecutionMetadata } from '@/lib/table'
78import { StatusBadge } from '@/app/workspace/[workspaceId]/logs/utils'
89import { storageToDisplay } from '../../../utils'
910import type { DisplayColumn } from '../types'
1011
11- /**
12- * Discriminated union describing every shape a table cell can take.
13- *
14- * Workflow-output cells follow a status state machine: they always render
15- * *something* (a value, a status pill, or a dash), driven by the combination
16- * of `executions[groupId]` state and dep satisfaction. Plain (non-workflow)
17- * cells just render the typed value or empty.
18- *
19- * `'empty'` is the universal fallback used by both workflow cells (no exec,
20- * no value, no waiting) and plain cells (null/undefined value).
21- *
22- * Adding a new cell appearance is a three-step mechanical change: add a
23- * variant here, pick it in `resolveCellRender`, render it in `CellRender`.
24- * TypeScript's exhaustiveness check on the renderer's `switch` (the
25- * unreachable default) flags any branch you forgot.
26- */
2712export type CellRenderKind =
2813 // Workflow-output cells
2914 | { kind : 'value' ; text : string }
@@ -38,6 +23,7 @@ export type CellRenderKind =
3823 | { kind : 'boolean' ; checked : boolean }
3924 | { kind : 'json' ; text : string }
4025 | { kind : 'date' ; text : string }
26+ | { kind : 'url' ; text : string ; href : string ; domain : string }
4127 | { kind : 'text' ; text : string }
4228 // Universal fallback
4329 | { kind : 'empty' }
@@ -46,20 +32,9 @@ interface ResolveCellRenderInput {
4632 value : unknown
4733 exec : RowExecutionMetadata | undefined
4834 column : DisplayColumn
49- /** Empty / undefined → not waiting; non-empty → render the Waiting pill. */
5035 waitingOnLabels : string [ ] | undefined
5136}
5237
53- /**
54- * Decide which `CellRenderKind` to render for a cell. Pure — easily
55- * unit-testable in isolation, no JSX involved.
56- *
57- * Order matters for workflow cells: block-error wins over a value (the user
58- * cares about the failure), value wins over running/queued (we have data
59- * already), and the running/queued branch deliberately collapses pre-enqueue
60- * `pending` and post-enqueue `queued` into one `Queued` pill so the cell
61- * doesn't flicker as the row transitions from one to the other.
62- */
6338export function resolveCellRender ( {
6439 value,
6540 exec,
@@ -76,31 +51,20 @@ export function resolveCellRender({
7651
7752 if ( blockError ) return { kind : 'block-error' }
7853
79- // Active re-run of THIS column wins over its prior value — the value is
80- // about to be overwritten and the user should see the cell is changing.
8154 const inFlight =
8255 exec ?. status === 'running' || exec ?. status === 'queued' || exec ?. status === 'pending'
8356 if ( inFlight && blockRunning ) return { kind : 'running' }
8457
85- // Value wins over `pending-upstream`: once this column's output has
86- // landed, the cell is done from the user's perspective — even if the
87- // group is still running other blocks downstream. Without this, mid-run
88- // partial-write events (`status: 'running'` carrying outputs but tagging
89- // a different block as running) would flip a finished column back to the
90- // amber Pending pill until the terminal `completed` event arrives.
58+ // Value wins over pending-upstream: a finished column stays finished even
59+ // while other blocks in the group are still running.
9160 if ( ! isNull ) return { kind : 'value' , text : stringifyValue ( value ) }
9261
9362 if ( inFlight && ! ( groupHasBlockErrors && ! blockRunning ) ) {
9463 if ( exec ?. status === 'queued' || exec ?. status === 'pending' ) return { kind : 'queued' }
95- // `running` with this block not in `runningBlockIds` and no value yet =
96- // upstream block still going; surface as the amber Pending pill.
9764 return { kind : 'pending-upstream' }
9865 }
9966
100- // Waiting wins over a stale terminal state: if deps are unmet right now,
101- // the prior `cancelled` / `error` is informational at best — the cell
102- // can't actually run until the user fills the missing input. Surface the
103- // actionable state instead of the stale one.
67+ // Waiting wins over a stale terminal status — show the actionable state.
10468 if ( waitingOnLabels && waitingOnLabels . length > 0 ) {
10569 return { kind : 'waiting' , labels : waitingOnLabels }
10670 }
@@ -113,6 +77,12 @@ export function resolveCellRender({
11377 if ( isNull ) return { kind : 'empty' }
11478 if ( column . type === 'json' ) return { kind : 'json' , text : JSON . stringify ( value ) }
11579 if ( column . type === 'date' ) return { kind : 'date' , text : String ( value ) }
80+ if ( column . type === 'string' ) {
81+ const text = stringifyValue ( value )
82+ const urlInfo = extractUrlInfo ( text )
83+ if ( urlInfo ) return { kind : 'url' , text, href : urlInfo . href , domain : urlInfo . domain }
84+ return { kind : 'text' , text }
85+ }
11686 return { kind : 'text' , text : stringifyValue ( value ) }
11787}
11888
@@ -122,19 +92,32 @@ function stringifyValue(value: unknown): string {
12292 return JSON . stringify ( value )
12393}
12494
95+ const BARE_DOMAIN_RE = / ^ ( [ a - z A - Z 0 - 9 ] ( [ a - z A - Z 0 - 9 - ] { 0 , 61 } [ a - z A - Z 0 - 9 ] ) ? \. ) + [ a - z A - Z ] { 2 , } $ /
96+
97+ function extractUrlInfo ( text : string ) : { href : string ; domain : string } | null {
98+ const trimmed = text . trim ( )
99+ if ( ! trimmed ) return null
100+ if ( / ^ h t t p s ? : \/ \/ / i. test ( trimmed ) ) {
101+ try {
102+ const url = new URL ( trimmed )
103+ return { href : trimmed , domain : url . hostname }
104+ } catch {
105+ return null
106+ }
107+ }
108+ if ( BARE_DOMAIN_RE . test ( trimmed ) ) {
109+ const parsed = parse ( trimmed )
110+ if ( ! parsed . isIcann ) return null
111+ return { href : `https://${ trimmed } ` , domain : trimmed }
112+ }
113+ return null
114+ }
115+
125116interface CellRenderProps {
126117 kind : CellRenderKind
127- /** When true the static content sits underneath the InlineEditor overlay
128- * and should be visually hidden (but kept in flow to preserve cell size). */
129118 isEditing : boolean
130119}
131120
132- /**
133- * Pure renderer: takes a `CellRenderKind` and returns the JSX. No business
134- * logic — adding a new cell appearance means adding a new `case` here. The
135- * exhaustiveness check on the `switch` (the unreachable default) flags any
136- * variant you forgot to handle.
137- */
138121export function CellRender ( { kind, isEditing } : CellRenderProps ) : React . ReactElement | null {
139122 switch ( kind . kind ) {
140123 case 'value' :
@@ -237,6 +220,35 @@ export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactEle
237220 </ span >
238221 )
239222
223+ case 'url' :
224+ return (
225+ < span className = { cn ( 'flex min-w-0 items-center gap-1.5' , isEditing && 'invisible' ) } >
226+ < img
227+ src = { `https://www.google.com/s2/favicons?domain=${ encodeURIComponent ( kind . domain ) } &sz=16` }
228+ alt = ''
229+ width = { 12 }
230+ height = { 12 }
231+ className = 'shrink-0 rounded-[2px]'
232+ onError = { ( e ) => {
233+ e . currentTarget . style . display = 'none'
234+ } }
235+ />
236+ < a
237+ href = { kind . href }
238+ target = '_blank'
239+ rel = 'noopener noreferrer'
240+ className = { cn (
241+ 'min-w-0 overflow-clip text-ellipsis text-[var(--text-primary)] underline underline-offset-2 hover:opacity-70' ,
242+ isEditing && 'pointer-events-none'
243+ ) }
244+ onClick = { ( e ) => e . stopPropagation ( ) }
245+ onDoubleClick = { ( e ) => e . stopPropagation ( ) }
246+ >
247+ { kind . text }
248+ </ a >
249+ </ span >
250+ )
251+
240252 case 'text' :
241253 return (
242254 < span
@@ -253,20 +265,12 @@ export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactEle
253265 return null
254266
255267 default : {
256- // Exhaustiveness guard: TypeScript flags this branch if a new
257- // `CellRenderKind` variant is added without a matching `case` above.
258268 const _exhaustive : never = kind
259269 return _exhaustive
260270 }
261271 }
262272}
263273
264- /**
265- * Workflow-output cells are hand-editable; while editing, the static content
266- * must stay in flow (so the cell doesn't collapse) but be visually hidden so
267- * the InlineEditor overlay shows through. Plain wrapper around any non-text
268- * variant.
269- */
270274function Wrap ( { isEditing, children } : { isEditing : boolean ; children : React . ReactNode } ) {
271275 if ( ! isEditing ) return < > { children } </ >
272276 return < div className = 'invisible' > { children } </ div >
0 commit comments