@shipfox/client-logs 16.0.0 → 21.0.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/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-type.log +1 -0
- package/CHANGELOG.md +29 -0
- package/dist/components/agent-session-rows.d.ts +2 -1
- package/dist/components/agent-session-rows.d.ts.map +1 -1
- package/dist/components/agent-session-rows.js +33 -18
- package/dist/components/agent-session-rows.js.map +1 -1
- package/dist/components/log-group.d.ts +3 -2
- package/dist/components/log-group.d.ts.map +1 -1
- package/dist/components/log-group.js +12 -4
- package/dist/components/log-group.js.map +1 -1
- package/dist/components/log-view.d.ts +3 -1
- package/dist/components/log-view.d.ts.map +1 -1
- package/dist/components/log-view.js +76 -22
- package/dist/components/log-view.js.map +1 -1
- package/dist/components/output-log-row.d.ts.map +1 -1
- package/dist/components/output-log-row.js +1 -1
- package/dist/components/output-log-row.js.map +1 -1
- package/dist/components/system-markers.d.ts.map +1 -1
- package/dist/components/system-markers.js +9 -4
- package/dist/components/system-markers.js.map +1 -1
- package/dist/core/log-search.d.ts +7 -0
- package/dist/core/log-search.d.ts.map +1 -0
- package/dist/core/log-search.js +124 -0
- package/dist/core/log-search.js.map +1 -0
- package/dist/hooks/api/step-logs.d.ts +169 -1
- package/dist/hooks/api/step-logs.d.ts.map +1 -1
- package/dist/hooks/api/step-logs.js +23 -3
- package/dist/hooks/api/step-logs.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/components/agent-session-rows.tsx +35 -23
- package/src/components/log-group.tsx +18 -5
- package/src/components/log-view.test.tsx +152 -18
- package/src/components/log-view.tsx +83 -17
- package/src/components/output-log-row.tsx +5 -1
- package/src/components/system-markers.tsx +14 -4
- package/src/core/log-search.test.ts +96 -0
- package/src/core/log-search.ts +121 -0
- package/src/hooks/api/step-logs-query.test.tsx +42 -0
- package/src/hooks/api/step-logs.ts +19 -3
- package/tsconfig.build.tsbuildinfo +1 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipfox/client-logs",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "21.0.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"@swc/helpers": "^0.5.17",
|
|
21
21
|
"@shipfox/api-logs-dto": "12.0.0",
|
|
22
22
|
"@shipfox/client-api": "6.0.1",
|
|
23
|
-
"@shipfox/react-ui": "
|
|
23
|
+
"@shipfox/react-ui": "2.0.0"
|
|
24
24
|
},
|
|
25
25
|
"peerDependencies": {
|
|
26
26
|
"@tanstack/react-query": "^5.101.0",
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
} from '@shipfox/react-ui/log';
|
|
11
11
|
import {Tooltip, TooltipContent, TooltipTrigger} from '@shipfox/react-ui/tooltip';
|
|
12
12
|
import {cn} from '@shipfox/react-ui/utils';
|
|
13
|
-
import {Fragment, useState} from 'react';
|
|
13
|
+
import {Fragment, useEffect, useState} from 'react';
|
|
14
14
|
import type {SessionViewRow, SessionViewRowMeta} from '#core/log-model.js';
|
|
15
15
|
|
|
16
16
|
const PREVIEW_CHAR_LIMIT = 1200;
|
|
@@ -23,6 +23,7 @@ export interface AgentSessionRowsProps {
|
|
|
23
23
|
resolvedToolCallIds: ReadonlySet<string>;
|
|
24
24
|
toolCallNames: ReadonlyMap<string, string>;
|
|
25
25
|
indent: number;
|
|
26
|
+
forceOpen?: boolean;
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
export function AgentSessionRows({
|
|
@@ -30,6 +31,7 @@ export function AgentSessionRows({
|
|
|
30
31
|
resolvedToolCallIds,
|
|
31
32
|
toolCallNames,
|
|
32
33
|
indent,
|
|
34
|
+
forceOpen = false,
|
|
33
35
|
}: AgentSessionRowsProps) {
|
|
34
36
|
return rows.map((row, index) => (
|
|
35
37
|
<AgentSessionRowView
|
|
@@ -39,6 +41,7 @@ export function AgentSessionRows({
|
|
|
39
41
|
resolvedToolCallIds={resolvedToolCallIds}
|
|
40
42
|
toolCallNames={toolCallNames}
|
|
41
43
|
indent={indent}
|
|
44
|
+
forceOpen={forceOpen}
|
|
42
45
|
/>
|
|
43
46
|
));
|
|
44
47
|
}
|
|
@@ -48,12 +51,20 @@ function AgentSessionRowView({
|
|
|
48
51
|
resolvedToolCallIds,
|
|
49
52
|
toolCallNames,
|
|
50
53
|
indent,
|
|
54
|
+
forceOpen,
|
|
51
55
|
}: {
|
|
52
56
|
row: SessionViewRow;
|
|
53
57
|
resolvedToolCallIds: ReadonlySet<string>;
|
|
54
58
|
toolCallNames: ReadonlyMap<string, string>;
|
|
55
59
|
indent: number;
|
|
60
|
+
forceOpen: boolean;
|
|
56
61
|
}) {
|
|
62
|
+
const [open, setOpen] = useState(false);
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (forceOpen) setOpen(true);
|
|
65
|
+
}, [forceOpen]);
|
|
66
|
+
const disclosureProps = {open: forceOpen || open, onOpenChange: setOpen};
|
|
67
|
+
|
|
57
68
|
switch (row.kind) {
|
|
58
69
|
case 'message':
|
|
59
70
|
return (
|
|
@@ -64,7 +75,7 @@ function AgentSessionRowView({
|
|
|
64
75
|
tone={row.terminalFailure ? 'error' : 'default'}
|
|
65
76
|
data-log-terminal-failure={row.terminalFailure ? 'true' : undefined}
|
|
66
77
|
>
|
|
67
|
-
<LogContent className="text-foreground-
|
|
78
|
+
<LogContent className="text-foreground-contrast-primary">
|
|
68
79
|
<span className="flex min-w-0 items-start gap-inline">
|
|
69
80
|
<MessageIcon role={row.role} terminalFailure={row.terminalFailure} />
|
|
70
81
|
<span className="flex min-w-0 flex-1 flex-col gap-tight">
|
|
@@ -82,16 +93,16 @@ function AgentSessionRowView({
|
|
|
82
93
|
);
|
|
83
94
|
case 'thinking':
|
|
84
95
|
return (
|
|
85
|
-
<LogDisclosure indent={indent}>
|
|
96
|
+
<LogDisclosure indent={indent} {...disclosureProps}>
|
|
86
97
|
<LogDisclosureTrigger
|
|
87
98
|
summary={wordSummary(row.text)}
|
|
88
99
|
timestamp={new Date(row.timestamp)}
|
|
89
|
-
className="text-foreground-
|
|
100
|
+
className="text-foreground-contrast-secondary"
|
|
90
101
|
>
|
|
91
102
|
thinking
|
|
92
103
|
</LogDisclosureTrigger>
|
|
93
|
-
<LogDisclosureContent className="text-foreground-
|
|
94
|
-
<LogContent className="text-foreground-
|
|
104
|
+
<LogDisclosureContent className="text-foreground-contrast-secondary">
|
|
105
|
+
<LogContent className="text-foreground-contrast-secondary">
|
|
95
106
|
<PreviewText text={row.text} />
|
|
96
107
|
</LogContent>
|
|
97
108
|
</LogDisclosureContent>
|
|
@@ -100,7 +111,7 @@ function AgentSessionRowView({
|
|
|
100
111
|
case 'tool-call': {
|
|
101
112
|
const awaitingResult = row.id != null && !resolvedToolCallIds.has(row.id);
|
|
102
113
|
return (
|
|
103
|
-
<LogDisclosure indent={indent}>
|
|
114
|
+
<LogDisclosure indent={indent} {...disclosureProps}>
|
|
104
115
|
<LogDisclosureTrigger
|
|
105
116
|
timestamp={new Date(row.timestamp)}
|
|
106
117
|
summary={compactPreview(row.summary ?? row.input)}
|
|
@@ -148,7 +159,7 @@ function AgentSessionRowView({
|
|
|
148
159
|
'(unmatched)')
|
|
149
160
|
: row.toolName;
|
|
150
161
|
return (
|
|
151
|
-
<LogDisclosure indent={indent}>
|
|
162
|
+
<LogDisclosure indent={indent} {...disclosureProps}>
|
|
152
163
|
<LogDisclosureTrigger
|
|
153
164
|
timestamp={new Date(row.timestamp)}
|
|
154
165
|
summary={compactPreview(row.output)}
|
|
@@ -156,7 +167,7 @@ function AgentSessionRowView({
|
|
|
156
167
|
<span
|
|
157
168
|
className={cn(
|
|
158
169
|
'inline-flex items-center gap-tight',
|
|
159
|
-
row.isError ? 'text-
|
|
170
|
+
row.isError ? 'text-tag-error-icon' : 'text-foreground-contrast-secondary',
|
|
160
171
|
)}
|
|
161
172
|
>
|
|
162
173
|
<Icon
|
|
@@ -174,10 +185,7 @@ function AgentSessionRowView({
|
|
|
174
185
|
</span>
|
|
175
186
|
</LogDisclosureTrigger>
|
|
176
187
|
<LogDisclosureContent>
|
|
177
|
-
<LogContent
|
|
178
|
-
variant="code"
|
|
179
|
-
className={cn(row.isError && 'text-red-600 dark:text-red-400')}
|
|
180
|
-
>
|
|
188
|
+
<LogContent variant="code" className="text-foreground-contrast-primary">
|
|
181
189
|
<PreviewText text={row.output} />
|
|
182
190
|
</LogContent>
|
|
183
191
|
</LogDisclosureContent>
|
|
@@ -193,7 +201,7 @@ function AgentSessionRowView({
|
|
|
193
201
|
tone={row.tone}
|
|
194
202
|
data-log-terminal-failure={row.terminalFailure ? 'true' : undefined}
|
|
195
203
|
>
|
|
196
|
-
<LogContent className="text-foreground-
|
|
204
|
+
<LogContent className="text-foreground-contrast-secondary">
|
|
197
205
|
<span className="inline-flex w-full items-center gap-inline">
|
|
198
206
|
<Icon name="informationLine" className="size-14 flex-none" aria-hidden="true" />
|
|
199
207
|
<span className="min-w-0">
|
|
@@ -201,7 +209,7 @@ function AgentSessionRowView({
|
|
|
201
209
|
{row.detail != null ? (
|
|
202
210
|
<>
|
|
203
211
|
{' · '}
|
|
204
|
-
<span className="text-foreground-
|
|
212
|
+
<span className="text-foreground-contrast-secondary">{row.detail}</span>
|
|
205
213
|
</>
|
|
206
214
|
) : null}
|
|
207
215
|
</span>
|
|
@@ -216,14 +224,18 @@ function AgentSessionRowView({
|
|
|
216
224
|
);
|
|
217
225
|
case 'raw':
|
|
218
226
|
return (
|
|
219
|
-
<LogDisclosure indent={indent}>
|
|
227
|
+
<LogDisclosure indent={indent} {...disclosureProps}>
|
|
220
228
|
<LogDisclosureTrigger
|
|
221
229
|
timestamp={new Date(row.timestamp)}
|
|
222
230
|
summary={compactPreview(row.raw)}
|
|
223
|
-
className="text-
|
|
231
|
+
className="text-foreground-contrast-primary"
|
|
224
232
|
>
|
|
225
233
|
<span className="inline-flex min-w-0 items-center gap-inline">
|
|
226
|
-
<Icon
|
|
234
|
+
<Icon
|
|
235
|
+
name="errorWarningLine"
|
|
236
|
+
className="size-14 flex-none text-tag-warning-icon"
|
|
237
|
+
aria-hidden="true"
|
|
238
|
+
/>
|
|
227
239
|
<span className="truncate">{row.label}</span>
|
|
228
240
|
</span>
|
|
229
241
|
</LogDisclosureTrigger>
|
|
@@ -253,7 +265,7 @@ function MessageIcon({role, terminalFailure}: {role: string; terminalFailure: bo
|
|
|
253
265
|
name={name}
|
|
254
266
|
className={cn(
|
|
255
267
|
'mt-[2px] size-14 flex-none',
|
|
256
|
-
terminalFailure ? 'text-
|
|
268
|
+
terminalFailure ? 'text-tag-error-icon' : 'text-foreground-contrast-secondary',
|
|
257
269
|
)}
|
|
258
270
|
aria-hidden="true"
|
|
259
271
|
/>
|
|
@@ -264,8 +276,8 @@ function MessageRoleLabel({label, terminalFailure}: {label: string; terminalFail
|
|
|
264
276
|
return (
|
|
265
277
|
<span
|
|
266
278
|
className={cn(
|
|
267
|
-
'min-w-0 font-code text-foreground-
|
|
268
|
-
terminalFailure && 'text-foreground-
|
|
279
|
+
'min-w-0 font-code text-foreground-contrast-secondary',
|
|
280
|
+
terminalFailure && 'text-foreground-contrast-primary',
|
|
269
281
|
)}
|
|
270
282
|
>
|
|
271
283
|
<span className="truncate">{label}</span>
|
|
@@ -280,7 +292,7 @@ function RowMetadata({meta, className}: {meta: readonly SessionViewRowMeta[]; cl
|
|
|
280
292
|
if (inlineMeta != null) {
|
|
281
293
|
return (
|
|
282
294
|
<span
|
|
283
|
-
className={cn('font-code text-xs text-foreground-
|
|
295
|
+
className={cn('font-code text-xs text-foreground-contrast-secondary', className)}
|
|
284
296
|
title={`${inlineMeta.label}: ${inlineMeta.value}`}
|
|
285
297
|
>
|
|
286
298
|
{inlineMeta.value}
|
|
@@ -301,7 +313,7 @@ function MetadataTrigger({meta}: {meta: readonly SessionViewRowMeta[]}) {
|
|
|
301
313
|
<TooltipTrigger asChild>
|
|
302
314
|
<button
|
|
303
315
|
type="button"
|
|
304
|
-
className="inline-flex size-20 flex-none items-center justify-center rounded-4 text-foreground-
|
|
316
|
+
className="inline-flex size-20 flex-none items-center justify-center rounded-4 text-foreground-contrast-secondary opacity-60 transition-opacity hover:bg-background-components-hover hover:text-foreground-contrast-primary 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"
|
|
305
317
|
aria-label="Show message metadata"
|
|
306
318
|
>
|
|
307
319
|
<Icon name="informationLine" className="size-12" aria-hidden="true" />
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import {Icon} from '@shipfox/react-ui/icon';
|
|
4
4
|
import {LogDisclosure, LogDisclosureContent, LogDisclosureTrigger} from '@shipfox/react-ui/log';
|
|
5
5
|
import {cn, formatDuration} from '@shipfox/react-ui/utils';
|
|
6
|
-
import type
|
|
6
|
+
import {type ReactNode, useEffect, useState} from 'react';
|
|
7
7
|
import type {GroupLogNode} from '#core/log-tree.js';
|
|
8
8
|
|
|
9
9
|
export interface LogGroupProps {
|
|
@@ -12,13 +12,26 @@ export interface LogGroupProps {
|
|
|
12
12
|
terminated: boolean;
|
|
13
13
|
children: ReactNode;
|
|
14
14
|
defaultOpen?: boolean;
|
|
15
|
+
forceOpen?: boolean;
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
export function LogGroup({
|
|
18
|
+
export function LogGroup({
|
|
19
|
+
node,
|
|
20
|
+
depth,
|
|
21
|
+
terminated,
|
|
22
|
+
children,
|
|
23
|
+
defaultOpen = false,
|
|
24
|
+
forceOpen = false,
|
|
25
|
+
}: LogGroupProps) {
|
|
18
26
|
const lineLabel = `${node.lineCount} ${node.lineCount === 1 ? 'line' : 'lines'}`;
|
|
27
|
+
const [open, setOpen] = useState(defaultOpen);
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (forceOpen) setOpen(true);
|
|
31
|
+
}, [forceOpen]);
|
|
19
32
|
|
|
20
33
|
return (
|
|
21
|
-
<LogDisclosure indent={depth}
|
|
34
|
+
<LogDisclosure indent={depth} open={forceOpen || open} onOpenChange={setOpen}>
|
|
22
35
|
<LogDisclosureTrigger
|
|
23
36
|
summary={lineLabel}
|
|
24
37
|
trailing={<GroupStatus node={node} terminated={terminated} />}
|
|
@@ -42,11 +55,11 @@ function GroupStatus({node, terminated}: {node: GroupLogNode; terminated: boolea
|
|
|
42
55
|
// closed only because an ancestor's group_end cascaded (its own end was dropped, so endTs
|
|
43
56
|
// is null). Either way show "incomplete" rather than a blank slot or a forever spinner.
|
|
44
57
|
if (node.closed || terminated) {
|
|
45
|
-
return <span className="text-foreground-
|
|
58
|
+
return <span className="text-foreground-contrast-secondary">incomplete</span>;
|
|
46
59
|
}
|
|
47
60
|
|
|
48
61
|
return (
|
|
49
|
-
<span className="inline-flex items-center gap-tight text-foreground-
|
|
62
|
+
<span className="inline-flex items-center gap-tight text-foreground-contrast-secondary">
|
|
50
63
|
<Icon name="loader4Line" className="size-12 motion-safe:animate-spin" aria-hidden="true" />
|
|
51
64
|
running
|
|
52
65
|
</span>
|
|
@@ -12,6 +12,20 @@ const output = (data: string): LogRecord => ({
|
|
|
12
12
|
stream: 'stdout',
|
|
13
13
|
data,
|
|
14
14
|
});
|
|
15
|
+
const groupStart = (groupId: string, name: string): LogRecord => ({
|
|
16
|
+
v: 1,
|
|
17
|
+
ts,
|
|
18
|
+
type: 'group_start',
|
|
19
|
+
groupId,
|
|
20
|
+
parentGroupId: null,
|
|
21
|
+
name,
|
|
22
|
+
});
|
|
23
|
+
const groupEnd = (groupId: string): LogRecord => ({
|
|
24
|
+
v: 1,
|
|
25
|
+
ts,
|
|
26
|
+
type: 'group_end',
|
|
27
|
+
groupId,
|
|
28
|
+
});
|
|
15
29
|
type AgentSessionRow = Extract<LogRecord, {type: 'agent_session'}>['row'];
|
|
16
30
|
|
|
17
31
|
const agentSession = (row: AgentSessionRow, offsetMs = 0): LogRecord => ({
|
|
@@ -85,6 +99,97 @@ describe('LogView', () => {
|
|
|
85
99
|
expect(screen.queryByText('No output yet')).toBeNull();
|
|
86
100
|
});
|
|
87
101
|
|
|
102
|
+
test('filters output and session rows by the log search term', () => {
|
|
103
|
+
render(
|
|
104
|
+
<LogView
|
|
105
|
+
search="failure"
|
|
106
|
+
records={[
|
|
107
|
+
output('setup complete\n'),
|
|
108
|
+
output('failure: test failed\n'),
|
|
109
|
+
agentSession({
|
|
110
|
+
kind: 'message',
|
|
111
|
+
timestamp: ts,
|
|
112
|
+
role: 'assistant',
|
|
113
|
+
label: 'assistant',
|
|
114
|
+
meta: [],
|
|
115
|
+
text: 'The failure is in the validation step.',
|
|
116
|
+
terminalFailure: false,
|
|
117
|
+
}),
|
|
118
|
+
]}
|
|
119
|
+
/>,
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
expect(screen.getByText('failure: test failed')).toBeDefined();
|
|
123
|
+
expect(screen.getByText('The failure is in the validation step.')).toBeDefined();
|
|
124
|
+
expect(screen.queryByText('setup complete')).toBeNull();
|
|
125
|
+
expect(screen.getByRole('log')).toHaveAttribute('aria-live', 'off');
|
|
126
|
+
expect(screen.getByRole('status')).toHaveTextContent('Log search updated for “failure”.');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('opens matching groups and filters their children', () => {
|
|
130
|
+
render(
|
|
131
|
+
<LogView
|
|
132
|
+
search="success"
|
|
133
|
+
records={[
|
|
134
|
+
groupStart('build', 'Build'),
|
|
135
|
+
output('setup complete\n'),
|
|
136
|
+
output('Success: compiled\n'),
|
|
137
|
+
groupEnd('build'),
|
|
138
|
+
]}
|
|
139
|
+
/>,
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
expect(screen.getByText('Success: compiled')).toBeInTheDocument();
|
|
143
|
+
expect(screen.queryByText('setup complete')).not.toBeInTheDocument();
|
|
144
|
+
expect(screen.getByText('1 line')).toBeInTheDocument();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('normalizes search text and restores all rows when search is cleared', async () => {
|
|
148
|
+
const records = [output('Failure: validation failed\n'), output('success: recovered\n')];
|
|
149
|
+
const {rerender} = render(<LogView search=" FAILURE " records={records} />);
|
|
150
|
+
|
|
151
|
+
await waitFor(() => expect(screen.getByText('Failure: validation failed')).toBeInTheDocument());
|
|
152
|
+
expect(screen.queryByText('success: recovered')).not.toBeInTheDocument();
|
|
153
|
+
|
|
154
|
+
rerender(<LogView search="" records={records} />);
|
|
155
|
+
|
|
156
|
+
await waitFor(() => expect(screen.getByText('success: recovered')).toBeInTheDocument());
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('searches marker labels and drops groups without matching descendants', () => {
|
|
160
|
+
render(
|
|
161
|
+
<LogView
|
|
162
|
+
search="missing"
|
|
163
|
+
records={[
|
|
164
|
+
groupStart('build', 'Build'),
|
|
165
|
+
output('build succeeded\n'),
|
|
166
|
+
groupEnd('build'),
|
|
167
|
+
groupStart('deploy', 'Deploy'),
|
|
168
|
+
output('deploy started\n'),
|
|
169
|
+
groupEnd('deploy'),
|
|
170
|
+
{v: 1, ts, type: 'gap', droppedBytes: 64},
|
|
171
|
+
]}
|
|
172
|
+
/>,
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
expect(screen.getByText('Output missing')).toBeInTheDocument();
|
|
176
|
+
expect(screen.queryByText('Build')).not.toBeInTheDocument();
|
|
177
|
+
expect(screen.queryByText('Deploy')).not.toBeInTheDocument();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('shows a message when the log search has no matches', () => {
|
|
181
|
+
render(<LogView search="missing" records={[output('hello\n')]} />);
|
|
182
|
+
|
|
183
|
+
expect(screen.getByRole('status')).toHaveTextContent('No log lines match “missing”.');
|
|
184
|
+
expect(screen.queryByText('hello')).toBeNull();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('allows terminal logs to opt out of live announcements', () => {
|
|
188
|
+
render(<LogView records={[output('hello\n')]} ariaLive="off" />);
|
|
189
|
+
|
|
190
|
+
expect(screen.getByRole('log')).toHaveAttribute('aria-live', 'off');
|
|
191
|
+
});
|
|
192
|
+
|
|
88
193
|
test('renders assistant session text and collapsed thinking', () => {
|
|
89
194
|
render(
|
|
90
195
|
<LogView
|
|
@@ -184,6 +289,34 @@ describe('LogView', () => {
|
|
|
184
289
|
expect(screen.getByText('awaiting result')).toBeDefined();
|
|
185
290
|
});
|
|
186
291
|
|
|
292
|
+
test('keeps tool relationships when search matches only one side', () => {
|
|
293
|
+
const records = [
|
|
294
|
+
agentSession({
|
|
295
|
+
kind: 'tool-call',
|
|
296
|
+
timestamp: ts,
|
|
297
|
+
id: 'call-1',
|
|
298
|
+
name: 'edit_file',
|
|
299
|
+
input: '{}',
|
|
300
|
+
}),
|
|
301
|
+
agentSession({
|
|
302
|
+
kind: 'tool-result',
|
|
303
|
+
timestamp: ts + 1,
|
|
304
|
+
toolCallId: 'call-1',
|
|
305
|
+
toolName: 'edit_file',
|
|
306
|
+
output: 'patched',
|
|
307
|
+
isError: false,
|
|
308
|
+
}),
|
|
309
|
+
];
|
|
310
|
+
|
|
311
|
+
const {unmount} = render(<LogView search="{}" records={records} />);
|
|
312
|
+
expect(screen.queryByText('awaiting result')).not.toBeInTheDocument();
|
|
313
|
+
|
|
314
|
+
unmount();
|
|
315
|
+
render(<LogView search="patched" records={records} />);
|
|
316
|
+
expect(screen.getByText('result edit_file')).toBeInTheDocument();
|
|
317
|
+
expect(screen.queryByText('result (unmatched)')).not.toBeInTheDocument();
|
|
318
|
+
});
|
|
319
|
+
|
|
187
320
|
test('renders unknown session entries without crashing', () => {
|
|
188
321
|
render(
|
|
189
322
|
<LogView
|
|
@@ -228,7 +361,7 @@ describe('LogView', () => {
|
|
|
228
361
|
);
|
|
229
362
|
});
|
|
230
363
|
|
|
231
|
-
test('anchors terminal failures
|
|
364
|
+
test('anchors terminal failures once while search changes', async () => {
|
|
232
365
|
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
|
233
366
|
callback(0);
|
|
234
367
|
return 1;
|
|
@@ -249,25 +382,26 @@ describe('LogView', () => {
|
|
|
249
382
|
.spyOn(HTMLElement.prototype, 'scrollIntoView')
|
|
250
383
|
.mockImplementation(() => undefined);
|
|
251
384
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
}),
|
|
266
|
-
]}
|
|
267
|
-
/>,
|
|
268
|
-
);
|
|
385
|
+
const records = [
|
|
386
|
+
output('setup\n'),
|
|
387
|
+
agentSession({
|
|
388
|
+
kind: 'message',
|
|
389
|
+
timestamp: ts,
|
|
390
|
+
role: 'assistant',
|
|
391
|
+
label: 'assistant',
|
|
392
|
+
meta: [],
|
|
393
|
+
text: 'I cannot continue.',
|
|
394
|
+
terminalFailure: true,
|
|
395
|
+
}),
|
|
396
|
+
];
|
|
397
|
+
const {rerender} = render(<LogView anchorToFailure records={records} search="cannot" />);
|
|
269
398
|
|
|
270
399
|
await waitFor(() => expect(scrollIntoView).toHaveBeenCalledWith({block: 'center'}));
|
|
400
|
+
scrollIntoView.mockClear();
|
|
401
|
+
|
|
402
|
+
rerender(<LogView anchorToFailure records={records} search="missing" />);
|
|
403
|
+
|
|
404
|
+
expect(scrollIntoView).not.toHaveBeenCalled();
|
|
271
405
|
});
|
|
272
406
|
});
|
|
273
407
|
|
|
@@ -3,8 +3,16 @@
|
|
|
3
3
|
import {Icon} from '@shipfox/react-ui/icon';
|
|
4
4
|
import {LogContent, LogRow, LogRows, type LogTimestampMode} from '@shipfox/react-ui/log';
|
|
5
5
|
import {Skeleton} from '@shipfox/react-ui/skeleton';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
type ReactNode,
|
|
8
|
+
type UIEventHandler,
|
|
9
|
+
useDeferredValue,
|
|
10
|
+
useEffect,
|
|
11
|
+
useMemo,
|
|
12
|
+
useRef,
|
|
13
|
+
} from 'react';
|
|
7
14
|
import type {LogRecord} from '#core/log-model.js';
|
|
15
|
+
import {buildLogSearchIndex, filterLogNodes} from '#core/log-search.js';
|
|
8
16
|
import {
|
|
9
17
|
assertNever,
|
|
10
18
|
buildLogTree,
|
|
@@ -25,6 +33,8 @@ export interface LogViewProps {
|
|
|
25
33
|
emptyState?: 'complete' | 'pending';
|
|
26
34
|
defaultGroupsOpen?: boolean;
|
|
27
35
|
anchorToFailure?: boolean;
|
|
36
|
+
search?: string;
|
|
37
|
+
ariaLive?: 'off' | 'polite' | 'assertive';
|
|
28
38
|
className?: string | undefined;
|
|
29
39
|
onScroll?: UIEventHandler<HTMLDivElement> | undefined;
|
|
30
40
|
}
|
|
@@ -42,14 +52,29 @@ export function LogView({
|
|
|
42
52
|
emptyState = 'complete',
|
|
43
53
|
defaultGroupsOpen = false,
|
|
44
54
|
anchorToFailure = false,
|
|
55
|
+
search = '',
|
|
56
|
+
ariaLive = 'polite',
|
|
45
57
|
className,
|
|
46
58
|
onScroll,
|
|
47
59
|
}: LogViewProps) {
|
|
48
60
|
const rowsRef = useRef<HTMLDivElement>(null);
|
|
49
61
|
const tree = useMemo(() => buildLogTree(records), [records]);
|
|
62
|
+
const deferredSearch = useDeferredValue(search);
|
|
63
|
+
const normalizedSearch = deferredSearch.trim().toLowerCase();
|
|
64
|
+
const searchIndex = useMemo(() => buildLogSearchIndex(tree.nodes), [tree.nodes]);
|
|
65
|
+
const visibleNodes = useMemo(
|
|
66
|
+
() =>
|
|
67
|
+
normalizedSearch ? filterLogNodes(tree.nodes, normalizedSearch, searchIndex) : tree.nodes,
|
|
68
|
+
[normalizedSearch, searchIndex, tree.nodes],
|
|
69
|
+
);
|
|
50
70
|
const resolvedToolCalls = useMemo(() => collectResolvedToolCalls(tree.nodes), [tree.nodes]);
|
|
51
|
-
const noOutputState = getNoOutputState(tree, emptyState);
|
|
71
|
+
const noOutputState = normalizedSearch ? null : getNoOutputState(tree, emptyState);
|
|
52
72
|
const anchorRecordCount = records.length;
|
|
73
|
+
const searchStatus = normalizedSearch
|
|
74
|
+
? visibleNodes.length === 0
|
|
75
|
+
? `No log lines match “${deferredSearch.trim()}”.`
|
|
76
|
+
: `Log search updated for “${deferredSearch.trim()}”.`
|
|
77
|
+
: null;
|
|
53
78
|
|
|
54
79
|
useEffect(() => {
|
|
55
80
|
if (!anchorToFailure) return;
|
|
@@ -72,18 +97,36 @@ export function LogView({
|
|
|
72
97
|
}, [anchorToFailure, anchorRecordCount]);
|
|
73
98
|
|
|
74
99
|
return (
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
100
|
+
<>
|
|
101
|
+
{searchStatus ? (
|
|
102
|
+
<div role="status" aria-live="polite" aria-atomic="true" className="sr-only">
|
|
103
|
+
{searchStatus}
|
|
104
|
+
</div>
|
|
105
|
+
) : null}
|
|
106
|
+
<LogRows
|
|
107
|
+
ref={rowsRef}
|
|
108
|
+
timestamps={timestamps}
|
|
109
|
+
wrap={wrap}
|
|
110
|
+
showLineNumbers={showLineNumbers}
|
|
111
|
+
aria-live={normalizedSearch ? 'off' : ariaLive}
|
|
112
|
+
className={className}
|
|
113
|
+
onScroll={onScroll}
|
|
114
|
+
{...(tree.originTs != null ? {timestampOrigin: new Date(tree.originTs)} : {})}
|
|
115
|
+
>
|
|
116
|
+
{noOutputState ? <NoOutputRow state={noOutputState} /> : null}
|
|
117
|
+
{normalizedSearch && visibleNodes.length === 0 ? (
|
|
118
|
+
<NoSearchMatchesRow query={deferredSearch.trim()} />
|
|
119
|
+
) : null}
|
|
120
|
+
{renderNodes(
|
|
121
|
+
visibleNodes,
|
|
122
|
+
0,
|
|
123
|
+
tree,
|
|
124
|
+
defaultGroupsOpen,
|
|
125
|
+
Boolean(normalizedSearch),
|
|
126
|
+
resolvedToolCalls,
|
|
127
|
+
)}
|
|
128
|
+
</LogRows>
|
|
129
|
+
</>
|
|
87
130
|
);
|
|
88
131
|
}
|
|
89
132
|
|
|
@@ -154,13 +197,13 @@ function NoOutputRow({state}: {state: NonNullable<LogViewProps['emptyState']>})
|
|
|
154
197
|
|
|
155
198
|
return (
|
|
156
199
|
<LogRow lineNumber={null}>
|
|
157
|
-
<LogContent className="text-foreground-
|
|
200
|
+
<LogContent className="text-foreground-contrast-secondary">
|
|
158
201
|
<span className="inline-flex min-w-0 items-center gap-inline">
|
|
159
202
|
<Icon name="info" className="size-14 flex-none" aria-hidden="true" />
|
|
160
203
|
<span className="min-w-0">
|
|
161
204
|
<span className="font-medium">{copy.title}</span>
|
|
162
205
|
{' · '}
|
|
163
|
-
<span className="text-foreground-
|
|
206
|
+
<span className="text-foreground-contrast-secondary">{copy.detail}</span>
|
|
164
207
|
</span>
|
|
165
208
|
</span>
|
|
166
209
|
</LogContent>
|
|
@@ -168,11 +211,25 @@ function NoOutputRow({state}: {state: NonNullable<LogViewProps['emptyState']>})
|
|
|
168
211
|
);
|
|
169
212
|
}
|
|
170
213
|
|
|
214
|
+
function NoSearchMatchesRow({query}: {query: string}) {
|
|
215
|
+
return (
|
|
216
|
+
<LogRow lineNumber={null}>
|
|
217
|
+
<LogContent className="text-foreground-contrast-secondary">
|
|
218
|
+
<span className="inline-flex min-w-0 items-center gap-inline">
|
|
219
|
+
<Icon name="searchLine" className="size-14 flex-none" aria-hidden="true" />
|
|
220
|
+
<span>No log lines match “{query}”.</span>
|
|
221
|
+
</span>
|
|
222
|
+
</LogContent>
|
|
223
|
+
</LogRow>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
171
227
|
function renderNodes(
|
|
172
228
|
nodes: readonly LogNode[],
|
|
173
229
|
depth: number,
|
|
174
230
|
tree: LogTree,
|
|
175
231
|
defaultGroupsOpen: boolean,
|
|
232
|
+
forceOpen: boolean,
|
|
176
233
|
resolvedToolCalls: ResolvedToolCalls,
|
|
177
234
|
): ReactNode[] {
|
|
178
235
|
// `node.seq` is the stable, unique render key (see `LogNodeBase`): a concatenated
|
|
@@ -197,8 +254,16 @@ function renderNodes(
|
|
|
197
254
|
depth={depth}
|
|
198
255
|
terminated={tree.terminated}
|
|
199
256
|
defaultOpen={defaultGroupsOpen}
|
|
257
|
+
forceOpen={forceOpen}
|
|
200
258
|
>
|
|
201
|
-
{renderNodes(
|
|
259
|
+
{renderNodes(
|
|
260
|
+
node.children,
|
|
261
|
+
depth + 1,
|
|
262
|
+
tree,
|
|
263
|
+
defaultGroupsOpen,
|
|
264
|
+
forceOpen,
|
|
265
|
+
resolvedToolCalls,
|
|
266
|
+
)}
|
|
202
267
|
</LogGroup>
|
|
203
268
|
);
|
|
204
269
|
case 'marker':
|
|
@@ -211,6 +276,7 @@ function renderNodes(
|
|
|
211
276
|
resolvedToolCallIds={resolvedToolCalls.ids}
|
|
212
277
|
toolCallNames={resolvedToolCalls.names}
|
|
213
278
|
indent={depth}
|
|
279
|
+
forceOpen={forceOpen}
|
|
214
280
|
/>
|
|
215
281
|
);
|
|
216
282
|
default:
|
|
@@ -27,7 +27,11 @@ export function OutputLogRow({
|
|
|
27
27
|
data-stream={record.stream}
|
|
28
28
|
className={cn(isStderr && 'shadow-[inset_2px_0_0_var(--color-border-neutral-strong)]')}
|
|
29
29
|
>
|
|
30
|
-
<LogContent
|
|
30
|
+
<LogContent
|
|
31
|
+
variant="code"
|
|
32
|
+
ansi
|
|
33
|
+
className={cn(isStderr && 'text-foreground-contrast-secondary')}
|
|
34
|
+
>
|
|
31
35
|
{stripTrailingNewline(record.data)}
|
|
32
36
|
</LogContent>
|
|
33
37
|
</LogRow>
|