@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
@@ -0,0 +1,266 @@
1
+ import type {LogRecord} from '@shipfox/api-logs-dto';
2
+ import {fireEvent, render, screen, waitFor} from '@testing-library/react';
3
+ import {LogView, LogViewSkeleton} from './log-view.js';
4
+
5
+ const ts = new Date('2026-06-23T10:00:00.000Z').getTime();
6
+ const THINKING_BUTTON_NAME = /thinking/i;
7
+
8
+ const output = (data: string): LogRecord => ({
9
+ v: 1,
10
+ ts,
11
+ type: 'output',
12
+ stream: 'stdout',
13
+ data,
14
+ });
15
+ type AgentSessionRow = Extract<LogRecord, {type: 'agent_session'}>['row'];
16
+
17
+ const agentSession = (row: AgentSessionRow, offsetMs = 0): LogRecord => ({
18
+ v: 1,
19
+ ts: ts + offsetMs,
20
+ type: 'agent_session',
21
+ row,
22
+ });
23
+
24
+ describe('LogView', () => {
25
+ let scrollIntoViewDescriptor: PropertyDescriptor | undefined;
26
+ let scrollIntoViewWasStubbed = false;
27
+
28
+ afterEach(() => {
29
+ vi.unstubAllGlobals();
30
+ vi.restoreAllMocks();
31
+ if (scrollIntoViewWasStubbed) {
32
+ if (scrollIntoViewDescriptor != null) {
33
+ Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', scrollIntoViewDescriptor);
34
+ } else {
35
+ Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView');
36
+ }
37
+ }
38
+ scrollIntoViewDescriptor = undefined;
39
+ scrollIntoViewWasStubbed = false;
40
+ });
41
+
42
+ test('renders the complete empty state for an empty closed stream', () => {
43
+ render(<LogView records={[]} />);
44
+
45
+ expect(screen.getByText('Step produced no output')).toBeDefined();
46
+ expect(
47
+ screen.getByText('This log stream closed without session entries or process output.'),
48
+ ).toBeDefined();
49
+ expect(screen.getByRole('log')).toBeDefined();
50
+ });
51
+
52
+ test('renders the pending empty state for an empty open stream', () => {
53
+ render(<LogView records={[]} emptyState="pending" />);
54
+
55
+ expect(screen.getByText('No output yet')).toBeDefined();
56
+ expect(screen.getByText('New lines will appear here as the step writes them.')).toBeDefined();
57
+ expect(screen.queryByText('Step produced no output')).toBeNull();
58
+ });
59
+
60
+ test('renders no-output copy before the end marker for an end-marker-only stream', () => {
61
+ render(<LogView records={[{v: 1, ts, type: 'end', total_bytes: 0}]} />);
62
+
63
+ expect(screen.getByText('Step produced no output')).toBeDefined();
64
+ expect(screen.getByText('End of log')).toBeDefined();
65
+ expect(screen.getByText('0 lines · 0 B · 0ms')).toBeDefined();
66
+ });
67
+
68
+ test.each([
69
+ {record: {v: 1, ts, type: 'runner_lost'} as const, label: 'Runner disconnected'},
70
+ {record: {v: 1, ts, type: 'gap', dropped_bytes: 2048} as const, label: 'Output missing'},
71
+ {record: {v: 1, ts, type: 'capped'} as const, label: 'Log size limit reached'},
72
+ ])('does not show no-output copy for a $record.type marker-only stream', ({record, label}) => {
73
+ render(<LogView records={[record]} />);
74
+
75
+ expect(screen.getByText(label)).toBeDefined();
76
+ expect(screen.queryByText('Step produced no output')).toBeNull();
77
+ expect(screen.queryByText('No output yet')).toBeNull();
78
+ });
79
+
80
+ test('does not render empty copy when output exists', () => {
81
+ render(<LogView records={[output('hello\n')]} />);
82
+
83
+ expect(screen.getByText('hello')).toBeDefined();
84
+ expect(screen.queryByText('Step produced no output')).toBeNull();
85
+ expect(screen.queryByText('No output yet')).toBeNull();
86
+ });
87
+
88
+ test('renders assistant session text and collapsed thinking', () => {
89
+ render(
90
+ <LogView
91
+ records={[
92
+ agentSession({
93
+ kind: 'message',
94
+ timestamp: ts,
95
+ role: 'assistant',
96
+ label: 'assistant',
97
+ meta: [],
98
+ text: 'I will inspect the failure.',
99
+ terminalFailure: false,
100
+ }),
101
+ agentSession({
102
+ kind: 'thinking',
103
+ timestamp: ts,
104
+ text: 'The stack trace points at validation.',
105
+ }),
106
+ ]}
107
+ />,
108
+ );
109
+
110
+ expect(screen.getByText('I will inspect the failure.')).toBeDefined();
111
+ expect(screen.getByRole('button', {name: THINKING_BUTTON_NAME})).toBeDefined();
112
+ expect(screen.queryByText('The stack trace points at validation.')).toBeNull();
113
+
114
+ fireEvent.click(screen.getByRole('button', {name: THINKING_BUTTON_NAME}));
115
+
116
+ expect(screen.getByText('The stack trace points at validation.')).toBeDefined();
117
+ });
118
+
119
+ test('renders tool calls with awaiting state until a result appears later in the stream', () => {
120
+ render(
121
+ <LogView
122
+ records={[
123
+ agentSession({
124
+ kind: 'tool-call',
125
+ timestamp: ts,
126
+ id: 'call-1',
127
+ name: 'edit_file',
128
+ input: '{}',
129
+ }),
130
+ output('stdout between call and result\n'),
131
+ agentSession({
132
+ kind: 'tool-result',
133
+ timestamp: ts + 1,
134
+ toolCallId: 'call-1',
135
+ toolName: 'edit_file',
136
+ output: 'patched',
137
+ isError: false,
138
+ }),
139
+ ]}
140
+ />,
141
+ );
142
+
143
+ expect(screen.getByText('tool edit_file')).toBeDefined();
144
+ expect(screen.getByText('stdout between call and result')).toBeDefined();
145
+ expect(screen.getByText('result edit_file')).toBeDefined();
146
+ expect(screen.queryByText('awaiting result')).toBeNull();
147
+ });
148
+
149
+ test('shows the awaiting-result state for a tool call with no matching result', () => {
150
+ render(
151
+ <LogView
152
+ records={[
153
+ agentSession({
154
+ kind: 'tool-call',
155
+ timestamp: ts,
156
+ id: 'call-1',
157
+ name: 'edit_file',
158
+ input: '{}',
159
+ }),
160
+ ]}
161
+ />,
162
+ );
163
+
164
+ expect(screen.getByText('tool edit_file')).toBeDefined();
165
+ expect(screen.getByText('awaiting result')).toBeDefined();
166
+ });
167
+
168
+ test('renders unknown session entries without crashing', () => {
169
+ render(
170
+ <LogView
171
+ records={[
172
+ agentSession({
173
+ kind: 'raw',
174
+ timestamp: ts,
175
+ label: 'Unknown session entry: future_entry',
176
+ raw: '{"type":"future_entry","payload":{"value":true}}',
177
+ }),
178
+ ]}
179
+ />,
180
+ );
181
+
182
+ expect(screen.getByText('Unknown session entry: future_entry')).toBeDefined();
183
+ });
184
+
185
+ test('truncates large payloads with a show-more control', () => {
186
+ render(
187
+ <LogView
188
+ records={[
189
+ agentSession({
190
+ kind: 'message',
191
+ timestamp: ts,
192
+ role: 'assistant',
193
+ label: 'assistant',
194
+ meta: [],
195
+ text: 'x'.repeat(1500),
196
+ terminalFailure: false,
197
+ }),
198
+ ]}
199
+ />,
200
+ );
201
+
202
+ const toggle = screen.getByRole('button', {name: 'show more'});
203
+ expect(toggle.getAttribute('aria-expanded')).toBe('false');
204
+
205
+ fireEvent.click(toggle);
206
+
207
+ expect(screen.getByRole('button', {name: 'show less'}).getAttribute('aria-expanded')).toBe(
208
+ 'true',
209
+ );
210
+ });
211
+
212
+ test('anchors terminal failures when requested', async () => {
213
+ vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
214
+ callback(0);
215
+ return 1;
216
+ });
217
+ vi.stubGlobal('cancelAnimationFrame', vi.fn());
218
+ scrollIntoViewDescriptor = Object.getOwnPropertyDescriptor(
219
+ HTMLElement.prototype,
220
+ 'scrollIntoView',
221
+ );
222
+ scrollIntoViewWasStubbed = true;
223
+ if (scrollIntoViewDescriptor == null) {
224
+ Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
225
+ configurable: true,
226
+ value: () => undefined,
227
+ });
228
+ }
229
+ const scrollIntoView = vi
230
+ .spyOn(HTMLElement.prototype, 'scrollIntoView')
231
+ .mockImplementation(() => undefined);
232
+
233
+ render(
234
+ <LogView
235
+ anchorToFailure
236
+ records={[
237
+ output('setup\n'),
238
+ agentSession({
239
+ kind: 'message',
240
+ timestamp: ts,
241
+ role: 'assistant',
242
+ label: 'assistant',
243
+ meta: [],
244
+ text: 'I cannot continue.',
245
+ terminalFailure: true,
246
+ }),
247
+ ]}
248
+ />,
249
+ );
250
+
251
+ await waitFor(() => expect(scrollIntoView).toHaveBeenCalledWith({block: 'center'}));
252
+ });
253
+ });
254
+
255
+ describe('LogViewSkeleton', () => {
256
+ test('keeps visual log chrome without exposing fake log content', () => {
257
+ const {container} = render(<LogViewSkeleton rows={3} />);
258
+
259
+ expect(screen.queryByRole('log')).toBeNull();
260
+ expect(container.querySelector('[data-slot="log-rows"]')?.getAttribute('aria-hidden')).toBe(
261
+ 'true',
262
+ );
263
+ expect(container.querySelectorAll('[data-slot="log-row"]')).toHaveLength(3);
264
+ expect(container.querySelectorAll('[data-slot="skeleton"]')).toHaveLength(3);
265
+ });
266
+ });
@@ -0,0 +1,281 @@
1
+ 'use client';
2
+
3
+ import type {LogRecord} from '@shipfox/api-logs-dto';
4
+ import {Icon} from '@shipfox/react-ui/icon';
5
+ import {LogContent, LogRow, LogRows, type LogTimestampMode} from '@shipfox/react-ui/log';
6
+ import {Skeleton} from '@shipfox/react-ui/skeleton';
7
+ import {type ReactNode, type UIEventHandler, useEffect, useMemo, useRef} from 'react';
8
+ import {
9
+ assertNever,
10
+ buildLogTree,
11
+ type LogNode,
12
+ type LogTree,
13
+ type MarkerLogRecord,
14
+ } from '#core/log-tree.js';
15
+ import {AgentSessionRows} from './agent-session-rows.js';
16
+ import {LogGroup} from './log-group.js';
17
+ import {OutputLogRow} from './output-log-row.js';
18
+ import {CappedMarker, EndMarker, GapMarker, RunnerLostMarker} from './system-markers.js';
19
+
20
+ export interface LogViewProps {
21
+ records: readonly LogRecord[];
22
+ timestamps?: LogTimestampMode;
23
+ wrap?: boolean;
24
+ showLineNumbers?: boolean;
25
+ emptyState?: 'complete' | 'pending';
26
+ defaultGroupsOpen?: boolean;
27
+ anchorToFailure?: boolean;
28
+ className?: string | undefined;
29
+ onScroll?: UIEventHandler<HTMLDivElement> | undefined;
30
+ }
31
+
32
+ export interface LogViewSkeletonProps
33
+ extends Pick<LogViewProps, 'timestamps' | 'wrap' | 'showLineNumbers' | 'className'> {
34
+ rows?: number;
35
+ }
36
+
37
+ export function LogView({
38
+ records,
39
+ timestamps = 'off',
40
+ wrap = false,
41
+ showLineNumbers = true,
42
+ emptyState = 'complete',
43
+ defaultGroupsOpen = false,
44
+ anchorToFailure = false,
45
+ className,
46
+ onScroll,
47
+ }: LogViewProps) {
48
+ const rowsRef = useRef<HTMLDivElement>(null);
49
+ const tree = useMemo(() => buildLogTree(records), [records]);
50
+ const resolvedToolCallIds = useMemo(() => collectResolvedToolCallIds(tree.nodes), [tree.nodes]);
51
+ const noOutputState = getNoOutputState(tree, emptyState);
52
+ const anchorRecordCount = records.length;
53
+
54
+ useEffect(() => {
55
+ if (!anchorToFailure) return;
56
+ if (anchorRecordCount === 0) return;
57
+
58
+ const frame = scheduleAnimationFrame(() => {
59
+ const rows = rowsRef.current;
60
+ if (!rows) return;
61
+
62
+ const failure = rows.querySelector<HTMLElement>('[data-log-terminal-failure="true"]');
63
+ if (failure) {
64
+ failure.scrollIntoView({block: 'center'});
65
+ return;
66
+ }
67
+
68
+ rows.scrollTop = rows.scrollHeight;
69
+ });
70
+
71
+ return () => cancelScheduledFrame(frame);
72
+ }, [anchorToFailure, anchorRecordCount]);
73
+
74
+ return (
75
+ <LogRows
76
+ ref={rowsRef}
77
+ timestamps={timestamps}
78
+ wrap={wrap}
79
+ showLineNumbers={showLineNumbers}
80
+ className={className}
81
+ onScroll={onScroll}
82
+ {...(tree.originTs != null ? {timestampOrigin: new Date(tree.originTs)} : {})}
83
+ >
84
+ {noOutputState ? <NoOutputRow state={noOutputState} /> : null}
85
+ {renderNodes(tree.nodes, 0, tree, defaultGroupsOpen, resolvedToolCallIds)}
86
+ </LogRows>
87
+ );
88
+ }
89
+
90
+ export function LogViewSkeleton({
91
+ rows = 5,
92
+ timestamps = 'off',
93
+ wrap = false,
94
+ showLineNumbers = true,
95
+ className,
96
+ }: LogViewSkeletonProps) {
97
+ const widths = ['w-[62%]', 'w-[44%]', 'w-[74%]', 'w-[36%]', 'w-[55%]'];
98
+ const skeletonRows = getSkeletonRows(rows);
99
+
100
+ return (
101
+ <LogRows
102
+ timestamps={timestamps}
103
+ wrap={wrap}
104
+ showLineNumbers={showLineNumbers}
105
+ className={className}
106
+ role="presentation"
107
+ aria-live="off"
108
+ aria-hidden="true"
109
+ >
110
+ {skeletonRows.map((row) => (
111
+ <LogRow key={row.id} lineNumber={row.lineNumber}>
112
+ <Skeleton
113
+ className={`my-4 h-12 ${widths[(row.lineNumber - 1) % widths.length] ?? 'w-[48%]'}`}
114
+ />
115
+ </LogRow>
116
+ ))}
117
+ </LogRows>
118
+ );
119
+ }
120
+
121
+ function getSkeletonRows(rows: number): {id: string; lineNumber: number}[] {
122
+ return Array.from({length: rows}, (_, index) => {
123
+ const lineNumber = index + 1;
124
+ return {id: `log-view-skeleton-row-${lineNumber}`, lineNumber};
125
+ });
126
+ }
127
+
128
+ function getNoOutputState(
129
+ tree: LogTree,
130
+ emptyState: NonNullable<LogViewProps['emptyState']>,
131
+ ): LogViewProps['emptyState'] | null {
132
+ if (tree.nodes.length === 0) return emptyState;
133
+
134
+ if (tree.lineCount !== 0) return null;
135
+ if (tree.nodes.length !== 1) return null;
136
+
137
+ const [node] = tree.nodes;
138
+ if (node?.kind === 'marker' && node.record.type === 'end') return 'complete';
139
+
140
+ return null;
141
+ }
142
+
143
+ function NoOutputRow({state}: {state: NonNullable<LogViewProps['emptyState']>}) {
144
+ const copy =
145
+ state === 'pending'
146
+ ? {
147
+ title: 'No output yet',
148
+ detail: 'New lines will appear here as the step writes them.',
149
+ }
150
+ : {
151
+ title: 'Step produced no output',
152
+ detail: 'This log stream closed without session entries or process output.',
153
+ };
154
+
155
+ return (
156
+ <LogRow lineNumber={null}>
157
+ <LogContent className="text-foreground-neutral-muted">
158
+ <span className="inline-flex min-w-0 items-center gap-8">
159
+ <Icon name="info" className="size-14 flex-none" aria-hidden="true" />
160
+ <span className="min-w-0">
161
+ <span className="font-medium">{copy.title}</span>
162
+ {' · '}
163
+ <span className="text-foreground-neutral-subtle">{copy.detail}</span>
164
+ </span>
165
+ </span>
166
+ </LogContent>
167
+ </LogRow>
168
+ );
169
+ }
170
+
171
+ function renderNodes(
172
+ nodes: readonly LogNode[],
173
+ depth: number,
174
+ tree: LogTree,
175
+ defaultGroupsOpen: boolean,
176
+ resolvedToolCallIds: ReadonlySet<string>,
177
+ ): ReactNode[] {
178
+ // `node.seq` is the stable, unique render key (see `LogNodeBase`): a concatenated
179
+ // multi-step/retry stream can repeat a `group_id` or a marker's `(type, ts)` at one
180
+ // level, which a key derived from those fields would collide on.
181
+ return nodes.map((node): ReactNode => {
182
+ switch (node.kind) {
183
+ case 'output':
184
+ return (
185
+ <OutputLogRow
186
+ key={node.seq}
187
+ record={node.record}
188
+ lineNumber={node.lineNumber}
189
+ indent={depth}
190
+ />
191
+ );
192
+ case 'group':
193
+ return (
194
+ <LogGroup
195
+ key={node.seq}
196
+ node={node}
197
+ depth={depth}
198
+ terminated={tree.terminated}
199
+ defaultOpen={defaultGroupsOpen}
200
+ >
201
+ {renderNodes(node.children, depth + 1, tree, defaultGroupsOpen, resolvedToolCallIds)}
202
+ </LogGroup>
203
+ );
204
+ case 'marker':
205
+ return <MarkerRow key={node.seq} record={node.record} tree={tree} />;
206
+ case 'session':
207
+ return (
208
+ <AgentSessionRows
209
+ key={node.seq}
210
+ rows={[node.record.row]}
211
+ resolvedToolCallIds={resolvedToolCallIds}
212
+ indent={depth}
213
+ />
214
+ );
215
+ default:
216
+ return assertNever(node);
217
+ }
218
+ });
219
+ }
220
+
221
+ function collectResolvedToolCallIds(nodes: readonly LogNode[]): ReadonlySet<string> {
222
+ const ids = new Set<string>();
223
+ collectResolvedToolCallIdsInto(nodes, ids);
224
+ return ids;
225
+ }
226
+
227
+ function collectResolvedToolCallIdsInto(nodes: readonly LogNode[], ids: Set<string>): void {
228
+ for (const node of nodes) {
229
+ switch (node.kind) {
230
+ case 'session':
231
+ if (node.record.row.kind === 'tool-result' && node.record.row.toolCallId != null) {
232
+ ids.add(node.record.row.toolCallId);
233
+ }
234
+ break;
235
+ case 'group':
236
+ collectResolvedToolCallIdsInto(node.children, ids);
237
+ break;
238
+ case 'output':
239
+ case 'marker':
240
+ break;
241
+ default:
242
+ assertNever(node);
243
+ }
244
+ }
245
+ }
246
+
247
+ function scheduleAnimationFrame(callback: FrameRequestCallback): number {
248
+ if (typeof globalThis.requestAnimationFrame === 'function') {
249
+ return globalThis.requestAnimationFrame(callback);
250
+ }
251
+ return window.setTimeout(() => callback(Date.now()), 0);
252
+ }
253
+
254
+ function cancelScheduledFrame(frame: number) {
255
+ if (typeof globalThis.cancelAnimationFrame === 'function') {
256
+ globalThis.cancelAnimationFrame(frame);
257
+ return;
258
+ }
259
+ window.clearTimeout(frame);
260
+ }
261
+
262
+ function MarkerRow({record, tree}: {record: MarkerLogRecord; tree: LogTree}): ReactNode {
263
+ switch (record.type) {
264
+ case 'end':
265
+ return (
266
+ <EndMarker
267
+ record={record}
268
+ lineCount={tree.lineCount}
269
+ durationMs={tree.originTs != null ? record.ts - tree.originTs : null}
270
+ />
271
+ );
272
+ case 'gap':
273
+ return <GapMarker record={record} />;
274
+ case 'capped':
275
+ return <CappedMarker record={record} />;
276
+ case 'runner_lost':
277
+ return <RunnerLostMarker record={record} />;
278
+ default:
279
+ return assertNever(record);
280
+ }
281
+ }
@@ -0,0 +1,76 @@
1
+ import {LogRows} from '@shipfox/react-ui/log';
2
+ import type {Meta, StoryObj} from '@storybook/react';
3
+ import type {OutputLogRecord} from '#core/log-tree.js';
4
+ import {OutputLogRow} from './output-log-row.js';
5
+
6
+ const ESC = String.fromCharCode(27);
7
+ const origin = new Date('2026-06-23T10:00:00.000Z').getTime();
8
+
9
+ const record = (
10
+ data: string,
11
+ stream: 'stdout' | 'stderr' = 'stdout',
12
+ offsetSeconds = 0,
13
+ ): OutputLogRecord => ({
14
+ v: 1,
15
+ ts: origin + offsetSeconds * 1000,
16
+ type: 'output',
17
+ stream,
18
+ data,
19
+ });
20
+
21
+ const meta = {
22
+ title: 'Logs/OutputLogRow',
23
+ component: OutputLogRow,
24
+ parameters: {layout: 'padded'},
25
+ tags: ['autodocs'],
26
+ } satisfies Meta<typeof OutputLogRow>;
27
+
28
+ export default meta;
29
+ type Story = StoryObj<typeof meta>;
30
+
31
+ export const Playground: Story = {
32
+ render: () => (
33
+ <div className="max-w-3xl">
34
+ <LogRows>
35
+ <OutputLogRow
36
+ record={record('transforming (1284) src/index.tsx\n', 'stdout', 0)}
37
+ lineNumber={1}
38
+ />
39
+ <OutputLogRow
40
+ record={record(
41
+ `${ESC}[32m✓${ESC}[0m built ${ESC}[34m1284${ESC}[0m modules\n`,
42
+ 'stdout',
43
+ 1,
44
+ )}
45
+ lineNumber={2}
46
+ />
47
+ <OutputLogRow
48
+ record={record('warn: deprecated glob@7, upgrade to glob@10\n', 'stderr', 2)}
49
+ lineNumber={3}
50
+ />
51
+ <OutputLogRow
52
+ record={record('FAIL client.test.ts > retries on 503\n', 'stderr', 3)}
53
+ lineNumber={4}
54
+ />
55
+ <OutputLogRow record={record('done in 412ms\n', 'stdout', 4)} lineNumber={5} />
56
+ </LogRows>
57
+ </div>
58
+ ),
59
+ };
60
+
61
+ export const LongLine: Story = {
62
+ render: () => (
63
+ <div className="max-w-md">
64
+ <LogRows>
65
+ <OutputLogRow
66
+ record={record(
67
+ 'ERROR TypeError: Cannot read properties of undefined (reading "id") at withRetry (src/api/retry.ts:42:18) at async runStep (src/runner/step.ts:118:7)\n',
68
+ 'stderr',
69
+ 0,
70
+ )}
71
+ lineNumber={120}
72
+ />
73
+ </LogRows>
74
+ </div>
75
+ ),
76
+ };
@@ -0,0 +1,35 @@
1
+ import {LogContent, LogRow} from '@shipfox/react-ui/log';
2
+ import {cn} from '@shipfox/react-ui/utils';
3
+ import {type OutputLogRecord, stripTrailingNewline} from '#core/log-tree.js';
4
+
5
+ export interface OutputLogRowProps {
6
+ record: OutputLogRecord;
7
+ lineNumber?: number | null;
8
+ indent?: number;
9
+ selected?: boolean;
10
+ }
11
+
12
+ export function OutputLogRow({
13
+ record,
14
+ lineNumber = null,
15
+ indent = 0,
16
+ selected = false,
17
+ }: OutputLogRowProps) {
18
+ const isStderr = record.stream === 'stderr';
19
+ // Stderr uses a neutral channel rule; it is a stream, not a severity, so it never reads as an error.
20
+
21
+ return (
22
+ <LogRow
23
+ lineNumber={lineNumber}
24
+ timestamp={new Date(record.ts)}
25
+ indent={indent}
26
+ selected={selected}
27
+ data-stream={record.stream}
28
+ className={cn(isStderr && 'shadow-[inset_2px_0_0_var(--color-border-neutral-strong)]')}
29
+ >
30
+ <LogContent variant="code" ansi className={cn(isStderr && 'text-foreground-neutral-subtle')}>
31
+ {stripTrailingNewline(record.data)}
32
+ </LogContent>
33
+ </LogRow>
34
+ );
35
+ }
@@ -0,0 +1,46 @@
1
+ import {LogRows} from '@shipfox/react-ui/log';
2
+ import type {Meta, StoryObj} from '@storybook/react';
3
+ import {CappedMarker, EndMarker, GapMarker, RunnerLostMarker} from './system-markers.js';
4
+
5
+ const ts = new Date('2026-06-23T10:00:00.000Z').getTime();
6
+
7
+ const meta = {
8
+ title: 'Logs/SystemMarkers',
9
+ component: EndMarker,
10
+ parameters: {layout: 'padded'},
11
+ tags: ['autodocs'],
12
+ } satisfies Meta<typeof EndMarker>;
13
+
14
+ export default meta;
15
+ type Story = StoryObj<typeof meta>;
16
+
17
+ export const Playground: Story = {
18
+ render: () => (
19
+ <div className="max-w-3xl">
20
+ <LogRows>
21
+ <EndMarker
22
+ record={{v: 1, ts, type: 'end', total_bytes: 15_360}}
23
+ lineCount={412}
24
+ durationMs={2100}
25
+ />
26
+ </LogRows>
27
+ </div>
28
+ ),
29
+ };
30
+
31
+ export const Variants: Story = {
32
+ render: () => (
33
+ <div className="max-w-3xl">
34
+ <LogRows>
35
+ <GapMarker record={{v: 1, ts, type: 'gap', dropped_bytes: 2048}} />
36
+ <CappedMarker record={{v: 1, ts, type: 'capped'}} />
37
+ <RunnerLostMarker record={{v: 1, ts, type: 'runner_lost'}} />
38
+ <EndMarker
39
+ record={{v: 1, ts, type: 'end', total_bytes: 15_360}}
40
+ lineCount={412}
41
+ durationMs={2100}
42
+ />
43
+ </LogRows>
44
+ </div>
45
+ ),
46
+ };