@shipfox/client-logs 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/.storybook/main.ts +1 -0
  2. package/.storybook/preview.css +9 -0
  3. package/.storybook/preview.tsx +56 -0
  4. package/.swcrc +42 -0
  5. package/.turbo/turbo-build.log +2 -0
  6. package/.turbo/turbo-type$colon$emit.log +1 -0
  7. package/.turbo/turbo-type.log +1 -0
  8. package/CHANGELOG.md +93 -0
  9. package/LICENSE +21 -0
  10. package/dist/components/agent-session-rows.d.ts +8 -0
  11. package/dist/components/agent-session-rows.d.ts.map +1 -0
  12. package/dist/components/agent-session-rows.js +374 -0
  13. package/dist/components/agent-session-rows.js.map +1 -0
  14. package/dist/components/index.d.ts +5 -0
  15. package/dist/components/index.d.ts.map +1 -0
  16. package/dist/components/index.js +6 -0
  17. package/dist/components/index.js.map +1 -0
  18. package/dist/components/log-group.d.ts +11 -0
  19. package/dist/components/log-group.d.ts.map +1 -0
  20. package/dist/components/log-group.js +57 -0
  21. package/dist/components/log-group.js.map +1 -0
  22. package/dist/components/log-view.d.ts +20 -0
  23. package/dist/components/log-view.d.ts.map +1 -0
  24. package/dist/components/log-view.js +243 -0
  25. package/dist/components/log-view.js.map +1 -0
  26. package/dist/components/output-log-row.d.ts +9 -0
  27. package/dist/components/output-log-row.d.ts.map +1 -0
  28. package/dist/components/output-log-row.js +24 -0
  29. package/dist/components/output-log-row.js.map +1 -0
  30. package/dist/components/system-markers.d.ts +21 -0
  31. package/dist/components/system-markers.d.ts.map +1 -0
  32. package/dist/components/system-markers.js +113 -0
  33. package/dist/components/system-markers.js.map +1 -0
  34. package/dist/core/log-read.d.ts +33 -0
  35. package/dist/core/log-read.d.ts.map +1 -0
  36. package/dist/core/log-read.js +49 -0
  37. package/dist/core/log-read.js.map +1 -0
  38. package/dist/core/log-tree.d.ts +85 -0
  39. package/dist/core/log-tree.d.ts.map +1 -0
  40. package/dist/core/log-tree.js +139 -0
  41. package/dist/core/log-tree.js.map +1 -0
  42. package/dist/env.d.js +2 -0
  43. package/dist/env.d.js.map +1 -0
  44. package/dist/hooks/api/step-logs.d.ts +24 -0
  45. package/dist/hooks/api/step-logs.d.ts.map +1 -0
  46. package/dist/hooks/api/step-logs.js +129 -0
  47. package/dist/hooks/api/step-logs.js.map +1 -0
  48. package/dist/index.d.ts +5 -0
  49. package/dist/index.d.ts.map +1 -0
  50. package/dist/index.js +5 -0
  51. package/dist/index.js.map +1 -0
  52. package/dist/tsconfig.test.tsbuildinfo +1 -0
  53. package/package.json +84 -0
  54. package/src/components/agent-session-rows.tsx +341 -0
  55. package/src/components/index.ts +15 -0
  56. package/src/components/log-group.stories.tsx +204 -0
  57. package/src/components/log-group.tsx +54 -0
  58. package/src/components/log-view.stories.tsx +584 -0
  59. package/src/components/log-view.test.tsx +266 -0
  60. package/src/components/log-view.tsx +281 -0
  61. package/src/components/output-log-row.stories.tsx +76 -0
  62. package/src/components/output-log-row.tsx +35 -0
  63. package/src/components/system-markers.stories.tsx +46 -0
  64. package/src/components/system-markers.tsx +148 -0
  65. package/src/core/log-read.test.ts +219 -0
  66. package/src/core/log-read.ts +79 -0
  67. package/src/core/log-tree.test.ts +380 -0
  68. package/src/core/log-tree.ts +200 -0
  69. package/src/env.d.ts +7 -0
  70. package/src/hooks/api/step-logs-query.test.tsx +283 -0
  71. package/src/hooks/api/step-logs.test.ts +66 -0
  72. package/src/hooks/api/step-logs.ts +166 -0
  73. package/src/index.ts +18 -0
  74. package/test/setup.ts +3 -0
  75. package/tsconfig.build.json +9 -0
  76. package/tsconfig.build.tsbuildinfo +1 -0
  77. package/tsconfig.json +3 -0
  78. package/tsconfig.test.json +8 -0
  79. package/vercel.json +8 -0
  80. package/vitest.config.ts +70 -0
package/package.json ADDED
@@ -0,0 +1,84 @@
1
+ {
2
+ "name": "@shipfox/client-logs",
3
+ "license": "MIT",
4
+ "version": "0.2.0",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
8
+ "directory": "libs/client/logs"
9
+ },
10
+ "type": "module",
11
+ "main": "dist/index.js",
12
+ "types": "dist/index.d.ts",
13
+ "imports": {
14
+ "#*": {
15
+ "types": "./src/*",
16
+ "workspace-source": "./src/*",
17
+ "development": "./src/*",
18
+ "default": "./dist/*"
19
+ }
20
+ },
21
+ "exports": {
22
+ ".": {
23
+ "development": {
24
+ "types": "./src/index.ts",
25
+ "default": "./src/index.ts"
26
+ },
27
+ "default": {
28
+ "types": "./dist/index.d.ts",
29
+ "default": "./dist/index.js"
30
+ }
31
+ }
32
+ },
33
+ "dependencies": {
34
+ "@swc/helpers": "^0.5.17",
35
+ "@shipfox/api-logs-dto": "2.0.0",
36
+ "@shipfox/client-api": "0.2.0",
37
+ "@shipfox/react-ui": "0.3.1"
38
+ },
39
+ "peerDependencies": {
40
+ "@tanstack/react-query": "^5.101.0",
41
+ "react": "^19.0.0",
42
+ "react-dom": "^19.0.0"
43
+ },
44
+ "devDependencies": {
45
+ "@argos-ci/cli": "^5.0.0",
46
+ "@argos-ci/storybook": "^6.0.0",
47
+ "@storybook/addon-vitest": "^10.3.6",
48
+ "@storybook/react": "^10.0.0",
49
+ "@storybook/react-vite": "^10.0.0",
50
+ "@tailwindcss/vite": "^4.1.13",
51
+ "@tanstack/react-query": "^5.101.0",
52
+ "@testing-library/dom": "^10.0.0",
53
+ "@testing-library/react": "^16.3.2",
54
+ "@types/react": "^19.1.11",
55
+ "@types/react-dom": "^19.1.7",
56
+ "@vitejs/plugin-react": "^6.0.0",
57
+ "@vitest/browser-playwright": "^4.1.5",
58
+ "jsdom": "^29.0.0",
59
+ "react": "^19.1.1",
60
+ "react-dom": "^19.1.1",
61
+ "storybook": "^10.0.0",
62
+ "storybook-addon-pseudo-states": "^10.0.0",
63
+ "tailwindcss": "^4.1.13",
64
+ "vite": "^8.0.3",
65
+ "vitest": "^4.1.5",
66
+ "@shipfox/biome": "1.8.1",
67
+ "@shipfox/client-test-setup": "0.0.2",
68
+ "@shipfox/swc": "1.2.5",
69
+ "@shipfox/ts-config": "1.3.8",
70
+ "@shipfox/typescript": "1.1.6",
71
+ "@shipfox/vitest": "1.2.2"
72
+ },
73
+ "scripts": {
74
+ "build": "shipfox-swc",
75
+ "check": "shipfox-biome-check",
76
+ "check:fix": "shipfox-biome-check --write",
77
+ "storybook": "storybook dev -p 6008",
78
+ "storybook:build": "storybook build -o storybook-static",
79
+ "test": "shipfox-vitest-run",
80
+ "test:watch": "shipfox-vitest-watch",
81
+ "type": "shipfox-tsc-check",
82
+ "type:emit": "shipfox-tsc-emit"
83
+ }
84
+ }
@@ -0,0 +1,341 @@
1
+ 'use client';
2
+
3
+ import type {SessionViewRow, SessionViewRowMeta} from '@shipfox/api-logs-dto';
4
+ import {Icon} from '@shipfox/react-ui/icon';
5
+ import {
6
+ LogContent,
7
+ LogDisclosure,
8
+ LogDisclosureContent,
9
+ LogDisclosureTrigger,
10
+ LogRow,
11
+ } from '@shipfox/react-ui/log';
12
+ import {Tooltip, TooltipContent, TooltipTrigger} from '@shipfox/react-ui/tooltip';
13
+ import {cn} from '@shipfox/react-ui/utils';
14
+ import {Fragment, useState} from 'react';
15
+
16
+ const PREVIEW_CHAR_LIMIT = 1200;
17
+ const WORD_SUMMARY_CHAR_LIMIT = 5000;
18
+ const WHITESPACE = /\s+/g;
19
+ const WORD_SEPARATOR = /\s+/;
20
+
21
+ export interface AgentSessionRowsProps {
22
+ rows: readonly SessionViewRow[];
23
+ resolvedToolCallIds: ReadonlySet<string>;
24
+ indent: number;
25
+ }
26
+
27
+ export function AgentSessionRows({rows, resolvedToolCallIds, indent}: AgentSessionRowsProps) {
28
+ return rows.map((row, index) => (
29
+ <AgentSessionRowView
30
+ // biome-ignore lint/suspicious/noArrayIndexKey: session rows are immutable and never reordered, so the index is stable; content keys would balloon to megabyte strings and collide on repeated id-less tool calls.
31
+ key={`${row.kind}-${index}`}
32
+ row={row}
33
+ resolvedToolCallIds={resolvedToolCallIds}
34
+ indent={indent}
35
+ />
36
+ ));
37
+ }
38
+
39
+ function AgentSessionRowView({
40
+ row,
41
+ resolvedToolCallIds,
42
+ indent,
43
+ }: {
44
+ row: SessionViewRow;
45
+ resolvedToolCallIds: ReadonlySet<string>;
46
+ indent: number;
47
+ }) {
48
+ switch (row.kind) {
49
+ case 'message':
50
+ return (
51
+ <LogRow
52
+ lineNumber={null}
53
+ timestamp={new Date(row.timestamp)}
54
+ indent={indent}
55
+ tone={row.terminalFailure ? 'error' : 'default'}
56
+ data-log-terminal-failure={row.terminalFailure ? 'true' : undefined}
57
+ >
58
+ <LogContent className="text-foreground-neutral-base">
59
+ <span className="flex min-w-0 items-start gap-8">
60
+ <MessageIcon role={row.role} terminalFailure={row.terminalFailure} />
61
+ <span className="flex min-w-0 flex-1 flex-col gap-2">
62
+ <span className="flex min-w-0 items-center gap-8">
63
+ <MessageRoleLabel label={row.label} terminalFailure={row.terminalFailure} />
64
+ <RowMetadata meta={row.meta} className="ml-auto flex-none" />
65
+ </span>
66
+ <span className="block min-w-0">
67
+ <PreviewText text={row.text} />
68
+ </span>
69
+ </span>
70
+ </span>
71
+ </LogContent>
72
+ </LogRow>
73
+ );
74
+ case 'thinking':
75
+ return (
76
+ <LogDisclosure indent={indent}>
77
+ <LogDisclosureTrigger
78
+ summary={wordSummary(row.text)}
79
+ timestamp={new Date(row.timestamp)}
80
+ className="text-foreground-neutral-subtle"
81
+ >
82
+ thinking
83
+ </LogDisclosureTrigger>
84
+ <LogDisclosureContent className="text-foreground-neutral-subtle">
85
+ <LogContent className="text-foreground-neutral-subtle">
86
+ <PreviewText text={row.text} />
87
+ </LogContent>
88
+ </LogDisclosureContent>
89
+ </LogDisclosure>
90
+ );
91
+ case 'tool-call': {
92
+ const awaitingResult = row.id != null && !resolvedToolCallIds.has(row.id);
93
+ return (
94
+ <LogDisclosure indent={indent}>
95
+ <LogDisclosureTrigger
96
+ timestamp={new Date(row.timestamp)}
97
+ summary={compactPreview(row.input)}
98
+ trailing={
99
+ awaitingResult ? (
100
+ <span className="inline-flex items-center gap-4">
101
+ <Icon
102
+ name="loader4Line"
103
+ className="size-12 motion-safe:animate-spin"
104
+ aria-hidden="true"
105
+ />
106
+ awaiting result
107
+ </span>
108
+ ) : null
109
+ }
110
+ >
111
+ <span className="inline-flex min-w-0 items-center gap-6">
112
+ <Icon name="terminalBoxLine" className="size-14 flex-none" aria-hidden="true" />
113
+ <span className="truncate">tool {row.name}</span>
114
+ </span>
115
+ </LogDisclosureTrigger>
116
+ <LogDisclosureContent>
117
+ <LogContent variant="code">
118
+ <PreviewText text={row.input} />
119
+ </LogContent>
120
+ </LogDisclosureContent>
121
+ </LogDisclosure>
122
+ );
123
+ }
124
+ case 'tool-result':
125
+ return (
126
+ <LogDisclosure indent={indent}>
127
+ <LogDisclosureTrigger
128
+ timestamp={new Date(row.timestamp)}
129
+ summary={compactPreview(row.output)}
130
+ trailing={
131
+ <span
132
+ className={cn(
133
+ 'inline-flex items-center gap-4',
134
+ row.isError ? 'text-red-600 dark:text-red-400' : 'text-foreground-neutral-muted',
135
+ )}
136
+ >
137
+ <Icon
138
+ name={row.isError ? 'closeCircleLine' : 'checkLine'}
139
+ className="size-12"
140
+ aria-hidden="true"
141
+ />
142
+ {row.isError ? 'error' : 'ok'}
143
+ </span>
144
+ }
145
+ >
146
+ <span className="inline-flex min-w-0 items-center gap-6">
147
+ <Icon name="terminalWindowLine" className="size-14 flex-none" aria-hidden="true" />
148
+ <span className="truncate">result {row.toolName}</span>
149
+ </span>
150
+ </LogDisclosureTrigger>
151
+ <LogDisclosureContent>
152
+ <LogContent
153
+ variant="code"
154
+ className={cn(row.isError && 'text-red-600 dark:text-red-400')}
155
+ >
156
+ <PreviewText text={row.output} />
157
+ </LogContent>
158
+ </LogDisclosureContent>
159
+ </LogDisclosure>
160
+ );
161
+ case 'lifecycle':
162
+ return (
163
+ <LogRow
164
+ lineNumber={null}
165
+ timestamp={new Date(row.timestamp)}
166
+ indent={indent}
167
+ tone={row.tone}
168
+ data-log-terminal-failure={row.terminalFailure ? 'true' : undefined}
169
+ >
170
+ <LogContent className="text-foreground-neutral-muted">
171
+ <span className="inline-flex w-full items-center gap-8">
172
+ <Icon name="informationLine" className="size-14 flex-none" aria-hidden="true" />
173
+ <span className="min-w-0">
174
+ <span className="font-medium">{row.label}</span>
175
+ {row.detail != null ? (
176
+ <>
177
+ {' · '}
178
+ <span className="text-foreground-neutral-subtle">{row.detail}</span>
179
+ </>
180
+ ) : null}
181
+ </span>
182
+ <span
183
+ aria-hidden="true"
184
+ className="h-px flex-1 border-t border-dashed border-current opacity-30"
185
+ />
186
+ <RowMetadata meta={row.meta} />
187
+ </span>
188
+ </LogContent>
189
+ </LogRow>
190
+ );
191
+ case 'raw':
192
+ return (
193
+ <LogDisclosure indent={indent}>
194
+ <LogDisclosureTrigger
195
+ timestamp={new Date(row.timestamp)}
196
+ summary={compactPreview(row.raw)}
197
+ className="text-orange-600 dark:text-orange-400"
198
+ >
199
+ <span className="inline-flex min-w-0 items-center gap-6">
200
+ <Icon name="errorWarningLine" className="size-14 flex-none" aria-hidden="true" />
201
+ <span className="truncate">{row.label}</span>
202
+ </span>
203
+ </LogDisclosureTrigger>
204
+ <LogDisclosureContent>
205
+ <LogContent variant="code">
206
+ <PreviewText text={row.raw} />
207
+ </LogContent>
208
+ </LogDisclosureContent>
209
+ </LogDisclosure>
210
+ );
211
+ default:
212
+ return assertNever(row);
213
+ }
214
+ }
215
+
216
+ function MessageIcon({role, terminalFailure}: {role: string; terminalFailure: boolean}) {
217
+ const name = terminalFailure
218
+ ? 'closeCircleLine'
219
+ : role === 'user'
220
+ ? 'userLine'
221
+ : role === 'assistant'
222
+ ? 'robot2Line'
223
+ : 'message2Line';
224
+
225
+ return (
226
+ <Icon
227
+ name={name}
228
+ className={cn(
229
+ 'mt-2 size-14 flex-none',
230
+ terminalFailure ? 'text-red-600 dark:text-red-400' : 'text-foreground-neutral-muted',
231
+ )}
232
+ aria-hidden="true"
233
+ />
234
+ );
235
+ }
236
+
237
+ function MessageRoleLabel({label, terminalFailure}: {label: string; terminalFailure: boolean}) {
238
+ return (
239
+ <span
240
+ className={cn(
241
+ 'min-w-0 font-code text-foreground-neutral-muted',
242
+ terminalFailure && 'text-foreground-highlight-error',
243
+ )}
244
+ >
245
+ <span className="truncate">{label}</span>
246
+ </span>
247
+ );
248
+ }
249
+
250
+ function RowMetadata({meta, className}: {meta: readonly SessionViewRowMeta[]; className?: string}) {
251
+ if (meta.length === 0) return null;
252
+
253
+ const inlineMeta = meta.length === 1 && meta[0]?.inline !== false ? meta[0] : null;
254
+ if (inlineMeta != null) {
255
+ return (
256
+ <span
257
+ className={cn('font-code text-xs text-foreground-neutral-muted', className)}
258
+ title={`${inlineMeta.label}: ${inlineMeta.value}`}
259
+ >
260
+ {inlineMeta.value}
261
+ </span>
262
+ );
263
+ }
264
+
265
+ return (
266
+ <span className={className}>
267
+ <MetadataTrigger meta={meta} />
268
+ </span>
269
+ );
270
+ }
271
+
272
+ function MetadataTrigger({meta}: {meta: readonly SessionViewRowMeta[]}) {
273
+ return (
274
+ <Tooltip>
275
+ <TooltipTrigger asChild>
276
+ <button
277
+ type="button"
278
+ className="inline-flex size-20 flex-none items-center justify-center rounded-4 text-foreground-neutral-muted opacity-60 transition-opacity hover:bg-background-components-hover hover:text-foreground-neutral-base hover:opacity-100 focus-visible:opacity-100 focus-visible:shadow-[inset_0_0_0_2px_var(--color-primary-500)] group-hover/log-row:opacity-100"
279
+ aria-label="Show message metadata"
280
+ >
281
+ <Icon name="informationLine" className="size-12" aria-hidden="true" />
282
+ </button>
283
+ </TooltipTrigger>
284
+ <TooltipContent align="end" variant="inverted" className="max-w-360 px-8 py-6">
285
+ <span className="grid grid-cols-[max-content_minmax(0,1fr)] gap-x-8 gap-y-2 font-code text-xs">
286
+ {meta.map((item) => (
287
+ <Fragment key={`${item.label}-${item.value}`}>
288
+ <span className="text-foreground-contrast-secondary">{item.label}</span>
289
+ <span className="min-w-0 break-all text-foreground-contrast-primary">
290
+ {item.value}
291
+ </span>
292
+ </Fragment>
293
+ ))}
294
+ </span>
295
+ </TooltipContent>
296
+ </Tooltip>
297
+ );
298
+ }
299
+
300
+ function PreviewText({text}: {text: string}) {
301
+ const [expanded, setExpanded] = useState(false);
302
+ const truncated = text.length > PREVIEW_CHAR_LIMIT;
303
+ const visible = truncated && !expanded ? `${text.slice(0, PREVIEW_CHAR_LIMIT)}…` : text;
304
+
305
+ return (
306
+ <>
307
+ {visible}
308
+ {truncated ? (
309
+ <button
310
+ type="button"
311
+ aria-expanded={expanded}
312
+ className="ml-8 inline-flex min-h-24 items-center rounded-4 px-6 font-display text-xs text-foreground-highlight-interactive focus-visible:shadow-[inset_0_0_0_2px_var(--color-primary-500)]"
313
+ onClick={() => setExpanded((value) => !value)}
314
+ >
315
+ {expanded ? 'show less' : 'show more'}
316
+ </button>
317
+ ) : null}
318
+ </>
319
+ );
320
+ }
321
+
322
+ function compactPreview(value: string): string {
323
+ // Normalize only a bounded head: tool output can be megabytes, and this runs
324
+ // per render for every disclosure trigger.
325
+ const head = value.length > 200 ? value.slice(0, 200) : value;
326
+ const singleLine = head.replace(WHITESPACE, ' ').trim();
327
+ if (singleLine.length <= 80) return singleLine;
328
+ return `${singleLine.slice(0, 80)}…`;
329
+ }
330
+
331
+ function wordSummary(value: string): string {
332
+ const truncated = value.length > WORD_SUMMARY_CHAR_LIMIT;
333
+ const head = truncated ? value.slice(0, WORD_SUMMARY_CHAR_LIMIT) : value;
334
+ const count = head.trim().split(WORD_SEPARATOR).filter(Boolean).length;
335
+ const marker = truncated ? '+' : '';
336
+ return `${count}${marker} ${count === 1 ? 'word' : 'words'}`;
337
+ }
338
+
339
+ function assertNever(value: never): never {
340
+ throw new Error(`unexpected agent session row: ${JSON.stringify(value)}`);
341
+ }
@@ -0,0 +1,15 @@
1
+ export {LogGroup, type LogGroupProps} from './log-group.js';
2
+ export {
3
+ LogView,
4
+ type LogViewProps,
5
+ LogViewSkeleton,
6
+ type LogViewSkeletonProps,
7
+ } from './log-view.js';
8
+ export {OutputLogRow, type OutputLogRowProps} from './output-log-row.js';
9
+ export {
10
+ CappedMarker,
11
+ EndMarker,
12
+ type EndMarkerProps,
13
+ GapMarker,
14
+ RunnerLostMarker,
15
+ } from './system-markers.js';
@@ -0,0 +1,204 @@
1
+ import {LogRows} from '@shipfox/react-ui/log';
2
+ import type {Meta, StoryObj} from '@storybook/react';
3
+ import {expect, userEvent, within} from 'storybook/test';
4
+ import type {GroupLogNode, GroupStartLogRecord, OutputLogRecord} from '#core/log-tree.js';
5
+ import {LogGroup} from './log-group.js';
6
+ import {OutputLogRow} from './output-log-row.js';
7
+ import {RunnerLostMarker} from './system-markers.js';
8
+
9
+ const origin = new Date('2026-06-23T10:00:00.000Z').getTime();
10
+ const at = (offsetSeconds: number) => origin + offsetSeconds * 1000;
11
+
12
+ const groupStart = (groupId: string, name: string): GroupStartLogRecord => ({
13
+ v: 1,
14
+ ts: at(0),
15
+ type: 'group_start',
16
+ group_id: groupId,
17
+ parent_group_id: null,
18
+ name,
19
+ });
20
+
21
+ const out = (
22
+ data: string,
23
+ line: number,
24
+ stream: 'stdout' | 'stderr' = 'stdout',
25
+ ): OutputLogRecord => ({
26
+ v: 1,
27
+ ts: at(line),
28
+ type: 'output',
29
+ stream,
30
+ data,
31
+ });
32
+
33
+ const group = (over: Partial<GroupLogNode> & Pick<GroupLogNode, 'record'>): GroupLogNode => ({
34
+ kind: 'group',
35
+ seq: 0,
36
+ closed: false,
37
+ endTs: null,
38
+ hasError: false,
39
+ lineCount: 0,
40
+ children: [],
41
+ ...over,
42
+ });
43
+
44
+ const rows = (records: OutputLogRecord[]) =>
45
+ records.map((record) => (
46
+ <OutputLogRow key={record.ts + record.data} record={record} lineNumber={null} indent={1} />
47
+ ));
48
+
49
+ const buildChildren = [
50
+ out('Compiling 1284 modules\n', 1),
51
+ out('Linking 318 packages\n', 2),
52
+ out('Done\n', 3),
53
+ ];
54
+
55
+ const meta = {
56
+ title: 'Logs/LogGroup',
57
+ component: LogGroup,
58
+ parameters: {layout: 'padded'},
59
+ tags: ['autodocs'],
60
+ } satisfies Meta<typeof LogGroup>;
61
+
62
+ export default meta;
63
+ type Story = StoryObj<typeof meta>;
64
+
65
+ const BUILD_TRIGGER = /Build/;
66
+
67
+ export const Playground: Story = {
68
+ render: () => (
69
+ <div className="max-w-3xl">
70
+ <LogRows>
71
+ <LogGroup
72
+ node={group({
73
+ record: groupStart('g1', 'Build'),
74
+ closed: true,
75
+ endTs: at(3),
76
+ lineCount: 3,
77
+ })}
78
+ depth={0}
79
+ terminated={false}
80
+ >
81
+ {rows(buildChildren)}
82
+ </LogGroup>
83
+ </LogRows>
84
+ </div>
85
+ ),
86
+ };
87
+
88
+ export const Open: Story = {
89
+ render: () => (
90
+ <div className="max-w-3xl">
91
+ <LogRows>
92
+ <LogGroup
93
+ node={group({
94
+ record: groupStart('g1', 'Build'),
95
+ closed: true,
96
+ endTs: at(3),
97
+ lineCount: 3,
98
+ })}
99
+ depth={0}
100
+ terminated={false}
101
+ defaultOpen
102
+ >
103
+ {rows(buildChildren)}
104
+ </LogGroup>
105
+ </LogRows>
106
+ </div>
107
+ ),
108
+ };
109
+
110
+ export const Running: Story = {
111
+ render: () => (
112
+ <div className="max-w-3xl">
113
+ <LogRows>
114
+ <LogGroup
115
+ node={group({record: groupStart('g1', 'Build'), lineCount: 2})}
116
+ depth={0}
117
+ terminated={false}
118
+ defaultOpen
119
+ >
120
+ {rows([out('Compiling...\n', 1), out('still going\n', 2)])}
121
+ </LogGroup>
122
+ </LogRows>
123
+ </div>
124
+ ),
125
+ };
126
+
127
+ export const Incomplete: Story = {
128
+ render: () => (
129
+ <div className="max-w-3xl">
130
+ <LogRows>
131
+ <LogGroup
132
+ node={group({record: groupStart('g1', 'Build'), lineCount: 1})}
133
+ depth={0}
134
+ terminated
135
+ defaultOpen
136
+ >
137
+ {rows([out('Compiling...\n', 1)])}
138
+ </LogGroup>
139
+ </LogRows>
140
+ </div>
141
+ ),
142
+ };
143
+
144
+ export const Truncated: Story = {
145
+ render: () => (
146
+ <div className="max-w-3xl">
147
+ <LogRows>
148
+ <LogGroup
149
+ node={group({
150
+ record: groupStart('g1', 'Build'),
151
+ closed: true,
152
+ endTs: null,
153
+ lineCount: 1,
154
+ })}
155
+ depth={0}
156
+ terminated={false}
157
+ defaultOpen
158
+ >
159
+ {rows([out('Compiling...\n', 1)])}
160
+ </LogGroup>
161
+ </LogRows>
162
+ </div>
163
+ ),
164
+ };
165
+
166
+ /**
167
+ * A genuine subtree failure (a `runner_lost`) draws an inset red bar on the header (a
168
+ * border, not a fill). stderr alone never triggers it — stderr is a channel, not an error.
169
+ */
170
+ export const ErrorBar: Story = {
171
+ render: () => (
172
+ <div className="max-w-3xl">
173
+ <LogRows>
174
+ <LogGroup
175
+ node={group({
176
+ record: groupStart('g1', 'Build'),
177
+ closed: true,
178
+ endTs: null,
179
+ hasError: true,
180
+ lineCount: 1,
181
+ })}
182
+ depth={0}
183
+ terminated
184
+ >
185
+ <OutputLogRow record={out('connecting to runner...\n', 1)} lineNumber={null} indent={1} />
186
+ <RunnerLostMarker record={{v: 1, ts: at(2), type: 'runner_lost'}} />
187
+ </LogGroup>
188
+ </LogRows>
189
+ </div>
190
+ ),
191
+ };
192
+
193
+ export const Toggle: Story = {
194
+ ...Playground,
195
+ play: async ({canvasElement}) => {
196
+ const canvas = within(canvasElement);
197
+ const user = userEvent.setup();
198
+ const trigger = canvas.getByRole('button', {name: BUILD_TRIGGER});
199
+
200
+ await expect(trigger).toHaveAttribute('aria-expanded', 'false');
201
+ await user.click(trigger);
202
+ await expect(trigger).toHaveAttribute('aria-expanded', 'true');
203
+ },
204
+ };
@@ -0,0 +1,54 @@
1
+ 'use client';
2
+
3
+ import {Icon} from '@shipfox/react-ui/icon';
4
+ import {LogDisclosure, LogDisclosureContent, LogDisclosureTrigger} from '@shipfox/react-ui/log';
5
+ import {cn, formatDuration} from '@shipfox/react-ui/utils';
6
+ import type {ReactNode} from 'react';
7
+ import type {GroupLogNode} from '#core/log-tree.js';
8
+
9
+ export interface LogGroupProps {
10
+ node: GroupLogNode;
11
+ depth: number;
12
+ terminated: boolean;
13
+ children: ReactNode;
14
+ defaultOpen?: boolean;
15
+ }
16
+
17
+ export function LogGroup({node, depth, terminated, children, defaultOpen = false}: LogGroupProps) {
18
+ const lineLabel = `${node.lineCount} ${node.lineCount === 1 ? 'line' : 'lines'}`;
19
+
20
+ return (
21
+ <LogDisclosure indent={depth} defaultOpen={defaultOpen}>
22
+ <LogDisclosureTrigger
23
+ summary={lineLabel}
24
+ trailing={<GroupStatus node={node} terminated={terminated} />}
25
+ className={cn(node.hasError && 'shadow-[inset_2px_0_0_var(--color-red-500)]')}
26
+ >
27
+ {node.record.name}
28
+ </LogDisclosureTrigger>
29
+ <LogDisclosureContent rail={false}>{children}</LogDisclosureContent>
30
+ </LogDisclosure>
31
+ );
32
+ }
33
+
34
+ function GroupStatus({node, terminated}: {node: GroupLogNode; terminated: boolean}): ReactNode {
35
+ if (node.closed && node.endTs != null) {
36
+ return (
37
+ <span className="font-code tabular-nums">{formatDuration(node.endTs - node.record.ts)}</span>
38
+ );
39
+ }
40
+
41
+ // No clean end: either still open under a terminated stream (runner died mid-group), or
42
+ // closed only because an ancestor's group_end cascaded (its own end was dropped, so endTs
43
+ // is null). Either way show "incomplete" rather than a blank slot or a forever spinner.
44
+ if (node.closed || terminated) {
45
+ return <span className="text-foreground-neutral-muted">incomplete</span>;
46
+ }
47
+
48
+ return (
49
+ <span className="inline-flex items-center gap-4 text-foreground-neutral-muted">
50
+ <Icon name="loader4Line" className="size-12 motion-safe:animate-spin" aria-hidden="true" />
51
+ running
52
+ </span>
53
+ );
54
+ }