@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.
Files changed (42) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-type.log +1 -0
  3. package/CHANGELOG.md +29 -0
  4. package/dist/components/agent-session-rows.d.ts +2 -1
  5. package/dist/components/agent-session-rows.d.ts.map +1 -1
  6. package/dist/components/agent-session-rows.js +33 -18
  7. package/dist/components/agent-session-rows.js.map +1 -1
  8. package/dist/components/log-group.d.ts +3 -2
  9. package/dist/components/log-group.d.ts.map +1 -1
  10. package/dist/components/log-group.js +12 -4
  11. package/dist/components/log-group.js.map +1 -1
  12. package/dist/components/log-view.d.ts +3 -1
  13. package/dist/components/log-view.d.ts.map +1 -1
  14. package/dist/components/log-view.js +76 -22
  15. package/dist/components/log-view.js.map +1 -1
  16. package/dist/components/output-log-row.d.ts.map +1 -1
  17. package/dist/components/output-log-row.js +1 -1
  18. package/dist/components/output-log-row.js.map +1 -1
  19. package/dist/components/system-markers.d.ts.map +1 -1
  20. package/dist/components/system-markers.js +9 -4
  21. package/dist/components/system-markers.js.map +1 -1
  22. package/dist/core/log-search.d.ts +7 -0
  23. package/dist/core/log-search.d.ts.map +1 -0
  24. package/dist/core/log-search.js +124 -0
  25. package/dist/core/log-search.js.map +1 -0
  26. package/dist/hooks/api/step-logs.d.ts +169 -1
  27. package/dist/hooks/api/step-logs.d.ts.map +1 -1
  28. package/dist/hooks/api/step-logs.js +23 -3
  29. package/dist/hooks/api/step-logs.js.map +1 -1
  30. package/dist/tsconfig.test.tsbuildinfo +1 -1
  31. package/package.json +2 -2
  32. package/src/components/agent-session-rows.tsx +35 -23
  33. package/src/components/log-group.tsx +18 -5
  34. package/src/components/log-view.test.tsx +152 -18
  35. package/src/components/log-view.tsx +83 -17
  36. package/src/components/output-log-row.tsx +5 -1
  37. package/src/components/system-markers.tsx +14 -4
  38. package/src/core/log-search.test.ts +96 -0
  39. package/src/core/log-search.ts +121 -0
  40. package/src/hooks/api/step-logs-query.test.tsx +42 -0
  41. package/src/hooks/api/step-logs.ts +19 -3
  42. 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": "16.0.0",
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": "1.1.0"
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-neutral-base">
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-neutral-subtle"
100
+ className="text-foreground-contrast-secondary"
90
101
  >
91
102
  thinking
92
103
  </LogDisclosureTrigger>
93
- <LogDisclosureContent className="text-foreground-neutral-subtle">
94
- <LogContent className="text-foreground-neutral-subtle">
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-red-600 dark:text-red-400' : 'text-foreground-neutral-muted',
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-neutral-muted">
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-neutral-subtle">{row.detail}</span>
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-orange-600 dark:text-orange-400"
231
+ className="text-foreground-contrast-primary"
224
232
  >
225
233
  <span className="inline-flex min-w-0 items-center gap-inline">
226
- <Icon name="errorWarningLine" className="size-14 flex-none" aria-hidden="true" />
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-red-600 dark:text-red-400' : 'text-foreground-neutral-muted',
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-neutral-muted',
268
- terminalFailure && 'text-foreground-highlight-error',
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-neutral-muted', className)}
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-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"
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 {ReactNode} from 'react';
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({node, depth, terminated, children, defaultOpen = false}: LogGroupProps) {
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} defaultOpen={defaultOpen}>
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-neutral-muted">incomplete</span>;
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-neutral-muted">
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 when requested', async () => {
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
- render(
253
- <LogView
254
- anchorToFailure
255
- records={[
256
- output('setup\n'),
257
- agentSession({
258
- kind: 'message',
259
- timestamp: ts,
260
- role: 'assistant',
261
- label: 'assistant',
262
- meta: [],
263
- text: 'I cannot continue.',
264
- terminalFailure: true,
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 {type ReactNode, type UIEventHandler, useEffect, useMemo, useRef} from 'react';
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
- <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, resolvedToolCalls)}
86
- </LogRows>
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-neutral-muted">
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-neutral-subtle">{copy.detail}</span>
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(node.children, depth + 1, tree, defaultGroupsOpen, resolvedToolCalls)}
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 variant="code" ansi className={cn(isStderr && 'text-foreground-neutral-subtle')}>
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>