@shipfox/client-logs 17.0.0 → 22.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 +28 -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 +34 -19
  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 +2 -1
  13. package/dist/components/log-view.d.ts.map +1 -1
  14. package/dist/components/log-view.js +76 -23
  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 +3 -3
  32. package/src/components/agent-session-rows.tsx +36 -24
  33. package/src/components/log-group.tsx +18 -5
  34. package/src/components/log-view.test.tsx +146 -18
  35. package/src/components/log-view.tsx +81 -18
  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": "17.0.0",
4
+ "version": "22.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/react-ui": "1.2.0",
23
- "@shipfox/client-api": "6.0.1"
22
+ "@shipfox/client-api": "6.0.1",
23
+ "@shipfox/react-ui": "2.1.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-focus-inset 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" />
@@ -335,7 +347,7 @@ function PreviewText({text}: {text: string}) {
335
347
  <button
336
348
  type="button"
337
349
  aria-expanded={expanded}
338
- className="ms-inline inline-flex min-h-24 items-center rounded-4 px-tight font-display text-xs text-foreground-highlight-interactive focus-visible:shadow-[inset_0_0_0_2px_var(--color-primary-500)]"
350
+ className="ms-inline inline-flex min-h-24 items-center rounded-4 px-tight font-display text-xs text-foreground-highlight-interactive focus-visible:shadow-focus-inset"
339
351
  onClick={() => setExpanded((value) => !value)}
340
352
  >
341
353
  {expanded ? 'show less' : 'show more'}
@@ -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,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 when requested', async () => {
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
- render(
259
- <LogView
260
- anchorToFailure
261
- records={[
262
- output('setup\n'),
263
- agentSession({
264
- kind: 'message',
265
- timestamp: ts,
266
- role: 'assistant',
267
- label: 'assistant',
268
- meta: [],
269
- text: 'I cannot continue.',
270
- terminalFailure: true,
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 {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,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
- <LogRows
78
- ref={rowsRef}
79
- timestamps={timestamps}
80
- wrap={wrap}
81
- showLineNumbers={showLineNumbers}
82
- aria-live={ariaLive}
83
- className={className}
84
- onScroll={onScroll}
85
- {...(tree.originTs != null ? {timestampOrigin: new Date(tree.originTs)} : {})}
86
- >
87
- {noOutputState ? <NoOutputRow state={noOutputState} /> : null}
88
- {renderNodes(tree.nodes, 0, tree, defaultGroupsOpen, resolvedToolCalls)}
89
- </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
+ </>
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-neutral-muted">
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-neutral-subtle">{copy.detail}</span>
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(node.children, depth + 1, tree, defaultGroupsOpen, resolvedToolCalls)}
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 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>