@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.
- package/.storybook/main.ts +1 -0
- package/.storybook/preview.css +9 -0
- package/.storybook/preview.tsx +56 -0
- package/.swcrc +42 -0
- package/.turbo/turbo-build.log +2 -0
- package/.turbo/turbo-type$colon$emit.log +1 -0
- package/.turbo/turbo-type.log +1 -0
- package/CHANGELOG.md +93 -0
- package/LICENSE +21 -0
- package/dist/components/agent-session-rows.d.ts +8 -0
- package/dist/components/agent-session-rows.d.ts.map +1 -0
- package/dist/components/agent-session-rows.js +374 -0
- package/dist/components/agent-session-rows.js.map +1 -0
- package/dist/components/index.d.ts +5 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/index.js +6 -0
- package/dist/components/index.js.map +1 -0
- package/dist/components/log-group.d.ts +11 -0
- package/dist/components/log-group.d.ts.map +1 -0
- package/dist/components/log-group.js +57 -0
- package/dist/components/log-group.js.map +1 -0
- package/dist/components/log-view.d.ts +20 -0
- package/dist/components/log-view.d.ts.map +1 -0
- package/dist/components/log-view.js +243 -0
- package/dist/components/log-view.js.map +1 -0
- package/dist/components/output-log-row.d.ts +9 -0
- package/dist/components/output-log-row.d.ts.map +1 -0
- package/dist/components/output-log-row.js +24 -0
- package/dist/components/output-log-row.js.map +1 -0
- package/dist/components/system-markers.d.ts +21 -0
- package/dist/components/system-markers.d.ts.map +1 -0
- package/dist/components/system-markers.js +113 -0
- package/dist/components/system-markers.js.map +1 -0
- package/dist/core/log-read.d.ts +33 -0
- package/dist/core/log-read.d.ts.map +1 -0
- package/dist/core/log-read.js +49 -0
- package/dist/core/log-read.js.map +1 -0
- package/dist/core/log-tree.d.ts +85 -0
- package/dist/core/log-tree.d.ts.map +1 -0
- package/dist/core/log-tree.js +139 -0
- package/dist/core/log-tree.js.map +1 -0
- package/dist/env.d.js +2 -0
- package/dist/env.d.js.map +1 -0
- package/dist/hooks/api/step-logs.d.ts +24 -0
- package/dist/hooks/api/step-logs.d.ts.map +1 -0
- package/dist/hooks/api/step-logs.js +129 -0
- package/dist/hooks/api/step-logs.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -0
- package/package.json +84 -0
- package/src/components/agent-session-rows.tsx +341 -0
- package/src/components/index.ts +15 -0
- package/src/components/log-group.stories.tsx +204 -0
- package/src/components/log-group.tsx +54 -0
- package/src/components/log-view.stories.tsx +584 -0
- package/src/components/log-view.test.tsx +266 -0
- package/src/components/log-view.tsx +281 -0
- package/src/components/output-log-row.stories.tsx +76 -0
- package/src/components/output-log-row.tsx +35 -0
- package/src/components/system-markers.stories.tsx +46 -0
- package/src/components/system-markers.tsx +148 -0
- package/src/core/log-read.test.ts +219 -0
- package/src/core/log-read.ts +79 -0
- package/src/core/log-tree.test.ts +380 -0
- package/src/core/log-tree.ts +200 -0
- package/src/env.d.ts +7 -0
- package/src/hooks/api/step-logs-query.test.tsx +283 -0
- package/src/hooks/api/step-logs.test.ts +66 -0
- package/src/hooks/api/step-logs.ts +166 -0
- package/src/index.ts +18 -0
- package/test/setup.ts +3 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +3 -0
- package/tsconfig.test.json +8 -0
- package/vercel.json +8 -0
- 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
|
+
};
|