@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
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
|
+
}
|