@shipfox/client-logs 17.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 +18 -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 +2 -1
- package/dist/components/log-view.d.ts.map +1 -1
- package/dist/components/log-view.js +76 -23
- 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 +3 -3
- package/src/components/agent-session-rows.tsx +35 -23
- package/src/components/log-group.tsx +18 -5
- package/src/components/log-view.test.tsx +146 -18
- package/src/components/log-view.tsx +81 -18
- 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",
|
|
@@ -19,8 +19,8 @@
|
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"@swc/helpers": "^0.5.17",
|
|
21
21
|
"@shipfox/api-logs-dto": "12.0.0",
|
|
22
|
-
"@shipfox/
|
|
23
|
-
"@shipfox/
|
|
22
|
+
"@shipfox/client-api": "6.0.1",
|
|
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,91 @@ 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
|
+
|
|
88
187
|
test('allows terminal logs to opt out of live announcements', () => {
|
|
89
188
|
render(<LogView records={[output('hello\n')]} ariaLive="off" />);
|
|
90
189
|
|
|
@@ -190,6 +289,34 @@ describe('LogView', () => {
|
|
|
190
289
|
expect(screen.getByText('awaiting result')).toBeDefined();
|
|
191
290
|
});
|
|
192
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
|
+
|
|
193
320
|
test('renders unknown session entries without crashing', () => {
|
|
194
321
|
render(
|
|
195
322
|
<LogView
|
|
@@ -234,7 +361,7 @@ describe('LogView', () => {
|
|
|
234
361
|
);
|
|
235
362
|
});
|
|
236
363
|
|
|
237
|
-
test('anchors terminal failures
|
|
364
|
+
test('anchors terminal failures once while search changes', async () => {
|
|
238
365
|
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
|
|
239
366
|
callback(0);
|
|
240
367
|
return 1;
|
|
@@ -255,25 +382,26 @@ describe('LogView', () => {
|
|
|
255
382
|
.spyOn(HTMLElement.prototype, 'scrollIntoView')
|
|
256
383
|
.mockImplementation(() => undefined);
|
|
257
384
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
}),
|
|
272
|
-
]}
|
|
273
|
-
/>,
|
|
274
|
-
);
|
|
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" />);
|
|
275
398
|
|
|
276
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();
|
|
277
405
|
});
|
|
278
406
|
});
|
|
279
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,7 @@ export interface LogViewProps {
|
|
|
25
33
|
emptyState?: 'complete' | 'pending';
|
|
26
34
|
defaultGroupsOpen?: boolean;
|
|
27
35
|
anchorToFailure?: boolean;
|
|
36
|
+
search?: string;
|
|
28
37
|
ariaLive?: 'off' | 'polite' | 'assertive';
|
|
29
38
|
className?: string | undefined;
|
|
30
39
|
onScroll?: UIEventHandler<HTMLDivElement> | undefined;
|
|
@@ -43,15 +52,29 @@ export function LogView({
|
|
|
43
52
|
emptyState = 'complete',
|
|
44
53
|
defaultGroupsOpen = false,
|
|
45
54
|
anchorToFailure = false,
|
|
55
|
+
search = '',
|
|
46
56
|
ariaLive = 'polite',
|
|
47
57
|
className,
|
|
48
58
|
onScroll,
|
|
49
59
|
}: LogViewProps) {
|
|
50
60
|
const rowsRef = useRef<HTMLDivElement>(null);
|
|
51
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
|
+
);
|
|
52
70
|
const resolvedToolCalls = useMemo(() => collectResolvedToolCalls(tree.nodes), [tree.nodes]);
|
|
53
|
-
const noOutputState = getNoOutputState(tree, emptyState);
|
|
71
|
+
const noOutputState = normalizedSearch ? null : getNoOutputState(tree, emptyState);
|
|
54
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;
|
|
55
78
|
|
|
56
79
|
useEffect(() => {
|
|
57
80
|
if (!anchorToFailure) return;
|
|
@@ -74,19 +97,36 @@ export function LogView({
|
|
|
74
97
|
}, [anchorToFailure, anchorRecordCount]);
|
|
75
98
|
|
|
76
99
|
return (
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
+
</>
|
|
90
130
|
);
|
|
91
131
|
}
|
|
92
132
|
|
|
@@ -157,13 +197,13 @@ function NoOutputRow({state}: {state: NonNullable<LogViewProps['emptyState']>})
|
|
|
157
197
|
|
|
158
198
|
return (
|
|
159
199
|
<LogRow lineNumber={null}>
|
|
160
|
-
<LogContent className="text-foreground-
|
|
200
|
+
<LogContent className="text-foreground-contrast-secondary">
|
|
161
201
|
<span className="inline-flex min-w-0 items-center gap-inline">
|
|
162
202
|
<Icon name="info" className="size-14 flex-none" aria-hidden="true" />
|
|
163
203
|
<span className="min-w-0">
|
|
164
204
|
<span className="font-medium">{copy.title}</span>
|
|
165
205
|
{' · '}
|
|
166
|
-
<span className="text-foreground-
|
|
206
|
+
<span className="text-foreground-contrast-secondary">{copy.detail}</span>
|
|
167
207
|
</span>
|
|
168
208
|
</span>
|
|
169
209
|
</LogContent>
|
|
@@ -171,11 +211,25 @@ function NoOutputRow({state}: {state: NonNullable<LogViewProps['emptyState']>})
|
|
|
171
211
|
);
|
|
172
212
|
}
|
|
173
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
|
+
|
|
174
227
|
function renderNodes(
|
|
175
228
|
nodes: readonly LogNode[],
|
|
176
229
|
depth: number,
|
|
177
230
|
tree: LogTree,
|
|
178
231
|
defaultGroupsOpen: boolean,
|
|
232
|
+
forceOpen: boolean,
|
|
179
233
|
resolvedToolCalls: ResolvedToolCalls,
|
|
180
234
|
): ReactNode[] {
|
|
181
235
|
// `node.seq` is the stable, unique render key (see `LogNodeBase`): a concatenated
|
|
@@ -200,8 +254,16 @@ function renderNodes(
|
|
|
200
254
|
depth={depth}
|
|
201
255
|
terminated={tree.terminated}
|
|
202
256
|
defaultOpen={defaultGroupsOpen}
|
|
257
|
+
forceOpen={forceOpen}
|
|
203
258
|
>
|
|
204
|
-
{renderNodes(
|
|
259
|
+
{renderNodes(
|
|
260
|
+
node.children,
|
|
261
|
+
depth + 1,
|
|
262
|
+
tree,
|
|
263
|
+
defaultGroupsOpen,
|
|
264
|
+
forceOpen,
|
|
265
|
+
resolvedToolCalls,
|
|
266
|
+
)}
|
|
205
267
|
</LogGroup>
|
|
206
268
|
);
|
|
207
269
|
case 'marker':
|
|
@@ -214,6 +276,7 @@ function renderNodes(
|
|
|
214
276
|
resolvedToolCallIds={resolvedToolCalls.ids}
|
|
215
277
|
toolCallNames={resolvedToolCalls.names}
|
|
216
278
|
indent={depth}
|
|
279
|
+
forceOpen={forceOpen}
|
|
217
280
|
/>
|
|
218
281
|
);
|
|
219
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>
|