@shipfox/client-workflows 20.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 (105) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +29 -0
  3. package/dist/components/job-detail/job-detail-view.d.ts.map +1 -1
  4. package/dist/components/job-detail/job-detail-view.js +231 -42
  5. package/dist/components/job-detail/job-detail-view.js.map +1 -1
  6. package/dist/components/job-detail/step-attempt-log-panel.d.ts +7 -1
  7. package/dist/components/job-detail/step-attempt-log-panel.d.ts.map +1 -1
  8. package/dist/components/job-detail/step-attempt-log-panel.js +37 -10
  9. package/dist/components/job-detail/step-attempt-log-panel.js.map +1 -1
  10. package/dist/components/step-list/step-list-model.d.ts +1 -0
  11. package/dist/components/step-list/step-list-model.d.ts.map +1 -1
  12. package/dist/components/step-list/step-list-model.js +1 -1
  13. package/dist/components/step-list/step-list-model.js.map +1 -1
  14. package/dist/components/step-list/step-list.d.ts +2 -1
  15. package/dist/components/step-list/step-list.d.ts.map +1 -1
  16. package/dist/components/step-list/step-list.js +24 -7
  17. package/dist/components/step-list/step-list.js.map +1 -1
  18. package/dist/components/workflow-run-list/job-status-strip.d.ts +9 -1
  19. package/dist/components/workflow-run-list/job-status-strip.d.ts.map +1 -1
  20. package/dist/components/workflow-run-list/job-status-strip.js +22 -11
  21. package/dist/components/workflow-run-list/job-status-strip.js.map +1 -1
  22. package/dist/components/workflow-run-list/workflow-run-filters.js +1 -1
  23. package/dist/components/workflow-run-list/workflow-run-filters.js.map +1 -1
  24. package/dist/components/workflow-run-list/workflow-run-list-states.js +2 -2
  25. package/dist/components/workflow-run-list/workflow-run-list-states.js.map +1 -1
  26. package/dist/components/workflow-run-list/workflow-run-list-view.d.ts.map +1 -1
  27. package/dist/components/workflow-run-list/workflow-run-list-view.js +34 -22
  28. package/dist/components/workflow-run-list/workflow-run-list-view.js.map +1 -1
  29. package/dist/components/workflow-run-list/workflow-run-row.d.ts +3 -3
  30. package/dist/components/workflow-run-list/workflow-run-row.d.ts.map +1 -1
  31. package/dist/components/workflow-run-list/workflow-run-row.js +13 -10
  32. package/dist/components/workflow-run-list/workflow-run-row.js.map +1 -1
  33. package/dist/components/workflow-run-summary/workflow-run-summary.d.ts.map +1 -1
  34. package/dist/components/workflow-run-summary/workflow-run-summary.js +1 -1
  35. package/dist/components/workflow-run-summary/workflow-run-summary.js.map +1 -1
  36. package/dist/components/workflow-run-view/run-workspace-nav.js +1 -1
  37. package/dist/components/workflow-run-view/run-workspace-nav.js.map +1 -1
  38. package/dist/components/workflow-run-view/workflow-run-states.d.ts.map +1 -1
  39. package/dist/components/workflow-run-view/workflow-run-states.js +1 -1
  40. package/dist/components/workflow-run-view/workflow-run-states.js.map +1 -1
  41. package/dist/components/workflow-run-view/workflow-run-view.d.ts.map +1 -1
  42. package/dist/components/workflow-run-view/workflow-run-view.js +43 -36
  43. package/dist/components/workflow-run-view/workflow-run-view.js.map +1 -1
  44. package/dist/components/workflow-source-panel/workflow-source-content.js +1 -1
  45. package/dist/components/workflow-source-panel/workflow-source-content.js.map +1 -1
  46. package/dist/pages/project-workflows-page.d.ts.map +1 -1
  47. package/dist/pages/project-workflows-page.js +3 -2
  48. package/dist/pages/project-workflows-page.js.map +1 -1
  49. package/dist/pages/workflow-job-detail-page.d.ts.map +1 -1
  50. package/dist/pages/workflow-job-detail-page.js +3 -2
  51. package/dist/pages/workflow-job-detail-page.js.map +1 -1
  52. package/dist/pages/workflow-run-detail-page.js +2 -1
  53. package/dist/pages/workflow-run-detail-page.js.map +1 -1
  54. package/dist/pages/workflow-run-list-page.d.ts.map +1 -1
  55. package/dist/pages/workflow-run-list-page.js +9 -11
  56. package/dist/pages/workflow-run-list-page.js.map +1 -1
  57. package/dist/routes/job-detail.d.ts +1 -1
  58. package/dist/routes/job-detail.js +1 -1
  59. package/dist/routes/job-detail.js.map +1 -1
  60. package/dist/routes/run-detail.d.ts +1 -1
  61. package/dist/routes/run-detail.js +1 -1
  62. package/dist/routes/run-detail.js.map +1 -1
  63. package/dist/routes/runs.d.ts +1 -1
  64. package/dist/routes/runs.js +1 -1
  65. package/dist/routes/runs.js.map +1 -1
  66. package/dist/routes/workflows.d.ts +3 -0
  67. package/dist/routes/workflows.js +3 -0
  68. package/dist/routes/workflows.js.map +1 -1
  69. package/dist/tsconfig.test.tsbuildinfo +1 -1
  70. package/package.json +7 -7
  71. package/src/components/job-detail/job-detail-view.tsx +263 -47
  72. package/src/components/job-detail/step-attempt-log-panel.test.tsx +30 -12
  73. package/src/components/job-detail/step-attempt-log-panel.tsx +68 -10
  74. package/src/components/job-graph/job-graph.stories.tsx +1 -1
  75. package/src/components/job-graph/job-node.stories.tsx +1 -1
  76. package/src/components/step-list/step-list-model.ts +1 -1
  77. package/src/components/step-list/step-list.test.tsx +29 -1
  78. package/src/components/step-list/step-list.tsx +28 -9
  79. package/src/components/workflow-run-list/job-status-strip.tsx +25 -12
  80. package/src/components/workflow-run-list/workflow-run-filters.tsx +1 -1
  81. package/src/components/workflow-run-list/workflow-run-list-states.tsx +2 -2
  82. package/src/components/workflow-run-list/workflow-run-list-view.test.tsx +53 -4
  83. package/src/components/workflow-run-list/workflow-run-list-view.tsx +28 -22
  84. package/src/components/workflow-run-list/workflow-run-list.stories.tsx +9 -20
  85. package/src/components/workflow-run-list/workflow-run-row.tsx +17 -13
  86. package/src/components/workflow-run-summary/workflow-run-summary.stories.tsx +2 -2
  87. package/src/components/workflow-run-summary/workflow-run-summary.test.tsx +2 -1
  88. package/src/components/workflow-run-summary/workflow-run-summary.tsx +1 -4
  89. package/src/components/workflow-run-view/run-workspace-nav.tsx +1 -1
  90. package/src/components/workflow-run-view/workflow-run-states.tsx +1 -4
  91. package/src/components/workflow-run-view/workflow-run-view.stories.tsx +140 -0
  92. package/src/components/workflow-run-view/workflow-run-view.test.tsx +38 -7
  93. package/src/components/workflow-run-view/workflow-run-view.tsx +45 -37
  94. package/src/components/workflow-source-panel/workflow-source-content.tsx +2 -2
  95. package/src/pages/project-workflows-page.tsx +3 -2
  96. package/src/pages/workflow-job-detail-page.test.tsx +32 -0
  97. package/src/pages/workflow-job-detail-page.tsx +5 -1
  98. package/src/pages/workflow-run-detail-page.tsx +1 -1
  99. package/src/pages/workflow-run-list-page.tsx +9 -11
  100. package/src/pages/workflow-run-pages.test.tsx +26 -0
  101. package/src/routes/job-detail.tsx +1 -1
  102. package/src/routes/run-detail.tsx +1 -1
  103. package/src/routes/runs.tsx +1 -1
  104. package/src/routes/workflows.tsx +1 -0
  105. package/tsconfig.build.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/client-workflows",
3
3
  "license": "MIT",
4
- "version": "20.0.0",
4
+ "version": "21.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -31,12 +31,12 @@
31
31
  "@shipfox/api-definitions-dto": "12.3.0",
32
32
  "@shipfox/api-workflows-dto": "12.7.0",
33
33
  "@shipfox/client-api": "6.0.1",
34
- "@shipfox/client-logs": "17.0.0",
35
- "@shipfox/client-projects": "17.0.0",
36
- "@shipfox/client-shell": "17.0.0",
37
- "@shipfox/client-triggers": "17.0.0",
38
- "@shipfox/client-ui": "17.0.0",
39
- "@shipfox/react-ui": "1.2.0"
34
+ "@shipfox/client-logs": "21.0.0",
35
+ "@shipfox/client-projects": "21.0.0",
36
+ "@shipfox/client-shell": "21.0.0",
37
+ "@shipfox/client-triggers": "21.0.0",
38
+ "@shipfox/client-ui": "21.0.0",
39
+ "@shipfox/react-ui": "2.0.0"
40
40
  },
41
41
  "peerDependencies": {
42
42
  "@tanstack/react-query": "^5.101.0",
@@ -3,13 +3,24 @@
3
3
 
4
4
  import {ApiError} from '@shipfox/client-api';
5
5
  import {QueryLoadError} from '@shipfox/client-ui';
6
+ import {Badge} from '@shipfox/react-ui/badge';
7
+ import {IconButton} from '@shipfox/react-ui/button';
8
+ import {
9
+ DropdownMenu,
10
+ DropdownMenuCheckboxItem,
11
+ DropdownMenuContent,
12
+ DropdownMenuLabel,
13
+ DropdownMenuTrigger,
14
+ } from '@shipfox/react-ui/dropdown-menu';
6
15
  import {EmptyState} from '@shipfox/react-ui/empty-state';
7
16
  import {Icon} from '@shipfox/react-ui/icon';
17
+ import {Panel, PanelActions, PanelBody, PanelHeader} from '@shipfox/react-ui/panel';
18
+ import {SearchInline} from '@shipfox/react-ui/search';
8
19
  import {Skeleton} from '@shipfox/react-ui/skeleton';
9
20
  import {TimeTickerProvider} from '@shipfox/react-ui/time-ticker';
10
21
  import {Text} from '@shipfox/react-ui/typography';
11
22
  import {Link} from '@tanstack/react-router';
12
- import {type RefObject, useEffect, useRef, useState} from 'react';
23
+ import {type RefObject, useCallback, useEffect, useRef, useState} from 'react';
13
24
  import type {RunAnnotationSummary} from '#core/run-annotation.js';
14
25
  import {summarizeJobAnnotations} from '#core/run-annotation.js';
15
26
  import {isWorkflowRunTerminal, type Job, type JobExecution} from '#core/workflow-run.js';
@@ -22,6 +33,11 @@ import {
22
33
  workflowRunSearchParams,
23
34
  } from '#routes/inputs.js';
24
35
  import {type StepExpandedContext, StepList} from '../step-list/index.js';
36
+ import {
37
+ buildStepListModel,
38
+ getStepStatusVisual,
39
+ type StepListModel,
40
+ } from '../step-list/step-list-model.js';
25
41
  import {
26
42
  WorkflowRunNotFound,
27
43
  WorkflowRunStaleError,
@@ -71,6 +87,12 @@ export function JobDetailView({
71
87
  const rootRef = useRef<HTMLDivElement>(null);
72
88
  const pageScrollRef = useRef<HTMLDivElement>(null);
73
89
  const landingSelectionRef = useRef<FrozenLandingSelection | undefined>(undefined);
90
+ const [logSearch, setLogSearch] = useState('');
91
+ const [wrapLogs, setWrapLogs] = useState(false);
92
+ const [showLineNumbers, setShowLineNumbers] = useState(true);
93
+ const [expandedLogAttemptIds, setExpandedLogAttemptIds] = useState<readonly string[]>([]);
94
+ const [logRefreshTokens, setLogRefreshTokens] = useState<Record<string, number>>({});
95
+ const [logFetchingByAttemptId, setLogFetchingByAttemptId] = useState<Record<string, boolean>>({});
74
96
  const hasLoadedData = query.data !== undefined;
75
97
  // Reuse the run workspace's bounded annotation read for the job header chip. The separate
76
98
  // summary query below stays counts-only and is scoped to the inspector's selected execution.
@@ -112,6 +134,13 @@ export function JobDetailView({
112
134
  heading?.focus({preventScroll: true});
113
135
  }, [hasLoadedData, jobId]);
114
136
 
137
+ const handleLogFetchingChange = useCallback((attemptId: string, isFetching: boolean) => {
138
+ setLogFetchingByAttemptId((current) => {
139
+ if (current[attemptId] === isFetching) return current;
140
+ return {...current, [attemptId]: isFetching};
141
+ });
142
+ }, []);
143
+
115
144
  if (query.isPending) return <JobDetailSkeleton />;
116
145
 
117
146
  if (query.isError && query.data === undefined) {
@@ -139,6 +168,7 @@ export function JobDetailView({
139
168
  const resolvedSelection = resolveWorkflowJobSelection({job, selection: search});
140
169
  const selectedJobExecution = resolvedSelection.jobExecution;
141
170
  const hasExplicitStep = Boolean(search.stepId && resolvedSelection.step);
171
+ const stepListModel = buildStepListModel({job, jobExecution: selectedJobExecution});
142
172
  const currentLandingSelection = workflowJobLandingSelection(selectedJobExecution);
143
173
  if (selectedJobExecution) {
144
174
  const frozenLanding = landingSelectionRef.current;
@@ -165,11 +195,22 @@ export function JobDetailView({
165
195
  }
166
196
  const landingSelection = landingSelectionRef.current?.selection;
167
197
  const selectedAttemptId = hasExplicitStep ? resolvedSelection.selectedAttemptId : undefined;
168
- const runningSelection = runningStepSelection(selectedJobExecution);
198
+ const runningSelection = runningStepSelection(selectedJobExecution, stepListModel);
169
199
  const selectedStepId = resolvedSelection.step?.id ?? landingSelection?.stepId;
170
200
  const selectedAttemptForNotice = hasExplicitStep
171
201
  ? resolvedSelection.selectedAttemptId
172
202
  : landingSelection?.attemptId;
203
+ const expandedLogSelection = findExpandedLogSelection(
204
+ selectedJobExecution,
205
+ expandedLogAttemptIds,
206
+ stepListModel,
207
+ );
208
+ const selectedLogStep = expandedLogSelection?.step;
209
+ const selectedLogAttempt = expandedLogSelection?.attempt;
210
+ const selectedLogStatus = selectedLogAttempt?.status ?? selectedLogStep?.status;
211
+ const logIsFetching = Boolean(
212
+ selectedLogAttempt && logFetchingByAttemptId[selectedLogAttempt.id],
213
+ );
173
214
  const showRetargetNotice =
174
215
  runningSelection !== undefined &&
175
216
  (selectedStepId !== runningSelection.stepId ||
@@ -224,6 +265,14 @@ export function JobDetailView({
224
265
  });
225
266
  }
226
267
 
268
+ function refreshLogs() {
269
+ if (!selectedLogAttempt) return;
270
+ setLogRefreshTokens((current) => ({
271
+ ...current,
272
+ [selectedLogAttempt.id]: (current[selectedLogAttempt.id] ?? 0) + 1,
273
+ }));
274
+ }
275
+
227
276
  return (
228
277
  <TimeTickerProvider intervalMs={1000} reducedMotionIntervalMs={10_000}>
229
278
  <div ref={rootRef} className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
@@ -255,49 +304,77 @@ export function JobDetailView({
255
304
  ) : undefined
256
305
  }
257
306
  />
258
- <Text as="h2" className="sr-only">
259
- Logs
260
- </Text>
261
- {selectedJobExecution ? (
262
- <>
263
- <MaterializedOutputFailureNotice jobExecution={selectedJobExecution} />
264
- <StepList
265
- job={job}
266
- jobExecution={selectedJobExecution}
267
- selectedAttemptId={selectedAttemptId}
268
- defaultSelectedAttemptId={landingSelection?.attemptId}
269
- onSelectedAttemptChange={selectAttempt}
270
- inspectorOpenAttemptId={inspectorOpenAttemptId}
271
- onInspectorOpenChange={onInspectorOpenChange}
272
- autoSelectActiveAttempt
273
- emptyState={emptyStateForJob(job, selectedJobExecution)}
274
- showHeader={false}
275
- className="rounded-none border-0 bg-transparent"
276
- renderExpandedStep={(context) => (
277
- <ExpandedStep context={context} pageScrollRef={pageScrollRef} />
278
- )}
279
- renderInspector={(entry) => (
280
- <StepInspectorSheet
281
- entry={entry}
282
- open
283
- onOpenChange={(open) => onInspectorOpenChange(open ? entry.id : null)}
284
- workspaceSlug={workspaceSlug}
285
- projectSlug={projectSlug}
286
- workflowRunId={run.id}
287
- runAttempt={run.runAttempt.attempt}
288
- jobId={job.id}
289
- annotationCount={annotationCountForStep(
290
- annotationSummaryQuery.data,
291
- entry.step.id,
292
- entry.attempt,
307
+ <Panel data-job-log-panel className="min-w-0">
308
+ <JobLogPanelHeader
309
+ stepLabel={expandedLogSelection?.stepLabel}
310
+ attempt={selectedLogAttempt?.attempt}
311
+ status={selectedLogStatus}
312
+ search={logSearch}
313
+ onSearchChange={setLogSearch}
314
+ onRefresh={refreshLogs}
315
+ refreshing={logIsFetching}
316
+ showLineNumbers={showLineNumbers}
317
+ onShowLineNumbersChange={setShowLineNumbers}
318
+ wrap={wrapLogs}
319
+ onWrapChange={setWrapLogs}
320
+ disabled={!selectedLogAttempt}
321
+ />
322
+ <PanelBody className="min-w-0 p-0">
323
+ <Text as="h2" className="sr-only">
324
+ Logs
325
+ </Text>
326
+ {selectedJobExecution ? (
327
+ <>
328
+ <MaterializedOutputFailureNotice jobExecution={selectedJobExecution} />
329
+ <StepList
330
+ job={job}
331
+ jobExecution={selectedJobExecution}
332
+ selectedAttemptId={selectedAttemptId}
333
+ defaultSelectedAttemptId={landingSelection?.attemptId}
334
+ onSelectedAttemptChange={selectAttempt}
335
+ onExpandedAttemptIdsChange={setExpandedLogAttemptIds}
336
+ inspectorOpenAttemptId={inspectorOpenAttemptId}
337
+ onInspectorOpenChange={onInspectorOpenChange}
338
+ autoSelectActiveAttempt
339
+ emptyState={emptyStateForJob(job, selectedJobExecution)}
340
+ showHeader={false}
341
+ className="rounded-none border-0 bg-transparent shadow-none"
342
+ renderExpandedStep={(context) => (
343
+ <ExpandedStep
344
+ context={context}
345
+ pageScrollRef={pageScrollRef}
346
+ search={logSearch}
347
+ wrap={wrapLogs}
348
+ showLineNumbers={showLineNumbers}
349
+ attemptId={context.attemptId}
350
+ refreshToken={logRefreshTokens[context.attemptId] ?? 0}
351
+ onFetchingChange={handleLogFetchingChange}
352
+ />
353
+ )}
354
+ renderInspector={(entry) => (
355
+ <StepInspectorSheet
356
+ entry={entry}
357
+ open
358
+ onOpenChange={(open) => onInspectorOpenChange(open ? entry.id : null)}
359
+ workspaceSlug={workspaceSlug}
360
+ projectSlug={projectSlug}
361
+ workflowRunId={run.id}
362
+ runAttempt={run.runAttempt.attempt}
363
+ jobId={job.id}
364
+ annotationCount={annotationCountForStep(
365
+ annotationSummaryQuery.data,
366
+ entry.step.id,
367
+ entry.attempt,
368
+ )}
369
+ />
293
370
  )}
294
371
  />
295
- )}
296
- />
297
- </>
298
- ) : (
299
- <EmptyStateForMissingExecution job={job} />
300
- )}
372
+ </>
373
+ ) : (
374
+ <EmptyStateForMissingExecution job={job} />
375
+ )}
376
+ </PanelBody>
377
+ </Panel>
301
378
  </section>
302
379
  {showRetargetNotice && runningSelection ? (
303
380
  <div
@@ -336,9 +413,21 @@ export function JobDetailView({
336
413
  function ExpandedStep({
337
414
  context,
338
415
  pageScrollRef,
416
+ search,
417
+ wrap,
418
+ showLineNumbers,
419
+ attemptId,
420
+ refreshToken,
421
+ onFetchingChange,
339
422
  }: {
340
423
  context: StepExpandedContext;
341
424
  pageScrollRef: RefObject<HTMLDivElement | null>;
425
+ search: string;
426
+ wrap: boolean;
427
+ showLineNumbers: boolean;
428
+ attemptId: string;
429
+ refreshToken: number;
430
+ onFetchingChange: (attemptId: string, isFetching: boolean) => void;
342
431
  }) {
343
432
  if (context.carriedOver) return <CarriedOverStepPanel />;
344
433
 
@@ -347,7 +436,7 @@ function ExpandedStep({
347
436
  role="region"
348
437
  tabIndex={0}
349
438
  aria-label={`${context.stepLabel} output, attempt ${context.attempt}`}
350
- className="flex min-w-0 flex-col border-t border-border-neutral-base bg-background-neutral-base outline-none focus-visible:shadow-border-interactive-with-active dark:bg-background-contrast-subtle"
439
+ className="flex min-w-0 flex-col border-t border-border-neutral-base bg-background-contrast-base outline-none focus-visible:shadow-border-interactive-with-active"
351
440
  >
352
441
  <StepAttemptLogPanel
353
442
  stepId={context.stepId}
@@ -355,12 +444,116 @@ function ExpandedStep({
355
444
  attemptStatus={context.attemptStatus}
356
445
  attemptStartedAt={context.attemptStartedAt}
357
446
  pageScrollRef={pageScrollRef}
358
- surfaceClassName="rounded-none border-0 bg-transparent shadow-none"
447
+ search={search}
448
+ wrap={wrap}
449
+ showLineNumbers={showLineNumbers}
450
+ attemptId={attemptId}
451
+ refreshToken={refreshToken}
452
+ onFetchingChange={onFetchingChange}
359
453
  />
360
454
  </section>
361
455
  );
362
456
  }
363
457
 
458
+ function JobLogPanelHeader({
459
+ stepLabel,
460
+ attempt,
461
+ status,
462
+ search,
463
+ onSearchChange,
464
+ onRefresh,
465
+ refreshing,
466
+ showLineNumbers,
467
+ onShowLineNumbersChange,
468
+ wrap,
469
+ onWrapChange,
470
+ disabled,
471
+ }: {
472
+ stepLabel: string | undefined;
473
+ attempt: number | undefined;
474
+ status: string | undefined;
475
+ search: string;
476
+ onSearchChange: (value: string) => void;
477
+ onRefresh: () => void;
478
+ refreshing: boolean;
479
+ showLineNumbers: boolean;
480
+ onShowLineNumbersChange: (value: boolean) => void;
481
+ wrap: boolean;
482
+ onWrapChange: (value: boolean) => void;
483
+ disabled: boolean;
484
+ }) {
485
+ const statusVisual = status ? getStepStatusVisual(status) : undefined;
486
+
487
+ return (
488
+ <PanelHeader className="flex flex-wrap items-center gap-group">
489
+ <div className="min-w-0 flex-1">
490
+ <Text size="sm" bold className="truncate text-foreground-neutral-base">
491
+ {stepLabel ?? 'Logs'}
492
+ </Text>
493
+ {statusVisual ? (
494
+ <div className="flex min-w-0 items-center gap-inline text-foreground-neutral-muted">
495
+ <Badge variant={statusVisual.badge} size="2xs" radius="rounded">
496
+ {statusVisual.label}
497
+ </Badge>
498
+ {attempt ? (
499
+ <span className="font-code text-xs leading-20 tabular-nums">attempt {attempt}</span>
500
+ ) : null}
501
+ </div>
502
+ ) : (
503
+ <Text size="xs" className="text-foreground-neutral-muted">
504
+ Select a step to view its logs.
505
+ </Text>
506
+ )}
507
+ </div>
508
+ <PanelActions className="min-w-0 flex-wrap justify-end">
509
+ <SearchInline
510
+ value={search}
511
+ onChange={(event) => onSearchChange(event.target.value)}
512
+ aria-label="Search logs"
513
+ placeholder="Search logs"
514
+ size="small"
515
+ className="w-180 max-w-full"
516
+ disabled={disabled}
517
+ />
518
+ <IconButton
519
+ type="button"
520
+ variant="transparent"
521
+ size="sm"
522
+ icon="refreshLine"
523
+ aria-label="Refresh logs"
524
+ onClick={onRefresh}
525
+ disabled={disabled || refreshing}
526
+ isLoading={refreshing}
527
+ />
528
+ <DropdownMenu>
529
+ <DropdownMenuTrigger asChild>
530
+ <IconButton
531
+ type="button"
532
+ variant="transparent"
533
+ size="sm"
534
+ icon="settings3Line"
535
+ aria-label="Log settings"
536
+ disabled={disabled}
537
+ />
538
+ </DropdownMenuTrigger>
539
+ <DropdownMenuContent align="end" size="sm">
540
+ <DropdownMenuLabel>Log display</DropdownMenuLabel>
541
+ <DropdownMenuCheckboxItem
542
+ checked={showLineNumbers}
543
+ onCheckedChange={onShowLineNumbersChange}
544
+ >
545
+ Line numbers
546
+ </DropdownMenuCheckboxItem>
547
+ <DropdownMenuCheckboxItem checked={wrap} onCheckedChange={onWrapChange}>
548
+ Wrap long lines
549
+ </DropdownMenuCheckboxItem>
550
+ </DropdownMenuContent>
551
+ </DropdownMenu>
552
+ </PanelActions>
553
+ </PanelHeader>
554
+ );
555
+ }
556
+
364
557
  function EmptyStateForMissingExecution({job}: {job: Job}) {
365
558
  const emptyState = emptyStateForMissingExecution(job);
366
559
  return (
@@ -442,7 +635,7 @@ function NewerAttemptNotice({
442
635
  );
443
636
  }
444
637
 
445
- function runningStepSelection(jobExecution: JobExecution | undefined) {
638
+ function runningStepSelection(jobExecution: JobExecution | undefined, model: StepListModel) {
446
639
  if (!jobExecution) return undefined;
447
640
  const steps = [...jobExecution.steps].sort(
448
641
  (left, right) => left.position - right.position || left.id.localeCompare(right.id),
@@ -455,7 +648,11 @@ function runningStepSelection(jobExecution: JobExecution | undefined) {
455
648
  .reverse()
456
649
  .find((candidate) => candidate.status === 'running');
457
650
  if (attempt)
458
- return {stepId: step.id, attemptId: attempt.id, stepLabel: step.name || step.key || step.id};
651
+ return {
652
+ stepId: step.id,
653
+ attemptId: attempt.id,
654
+ stepLabel: model.entries.find((entry) => entry.id === attempt.id)?.step.label ?? 'Step',
655
+ };
459
656
  }
460
657
  return undefined;
461
658
  }
@@ -468,6 +665,25 @@ function findAttempt(jobExecution: JobExecution, attemptId: string) {
468
665
  return undefined;
469
666
  }
470
667
 
668
+ function findExpandedLogSelection(
669
+ jobExecution: JobExecution | undefined,
670
+ attemptIds: readonly string[],
671
+ model: StepListModel,
672
+ ) {
673
+ if (!jobExecution) return undefined;
674
+
675
+ for (let index = attemptIds.length - 1; index >= 0; index -= 1) {
676
+ const attemptId = attemptIds[index];
677
+ if (!attemptId) continue;
678
+ const match = findAttempt(jobExecution, attemptId);
679
+ const attempt = match?.step.attempts.find((candidate) => candidate.id === attemptId);
680
+ const entry = model.entries.find((candidate) => candidate.id === attemptId);
681
+ if (match && attempt && entry) return {step: match.step, attempt, stepLabel: entry.step.label};
682
+ }
683
+
684
+ return undefined;
685
+ }
686
+
471
687
  function annotationCountForStep(
472
688
  summary: RunAnnotationSummary | undefined,
473
689
  stepId: string,
@@ -2,7 +2,7 @@ import {configureApiClient} from '@shipfox/client-api';
2
2
  import {type StepLogSnapshot, stepLogsQueryKeys} from '@shipfox/client-logs';
3
3
  import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
4
4
  import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
5
- import {createRef, type ReactNode} from 'react';
5
+ import {createRef} from 'react';
6
6
  import {inlineLogBody, outputLine} from '#test/fixtures/logs.js';
7
7
  import {StepAttemptLogPanel} from './step-attempt-log-panel.js';
8
8
 
@@ -49,25 +49,28 @@ function renderPanel(
49
49
  new QueryClient({
50
50
  defaultOptions: {queries: {retry: false}},
51
51
  });
52
- const wrapper = ({children}: {children: ReactNode}) => (
53
- <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
54
- );
55
52
  const pageScrollRef = createRef<HTMLDivElement>();
56
-
57
- return {
58
- queryClient,
59
- ...render(
53
+ const renderPanelUi = (panelProps: Partial<Parameters<typeof StepAttemptLogPanel>[0]>) => (
54
+ <QueryClientProvider client={queryClient}>
60
55
  <div ref={pageScrollRef}>
61
56
  <StepAttemptLogPanel
62
57
  stepId={STEP_ID}
63
58
  attempt={1}
64
59
  attemptStatus="running"
65
60
  pageScrollRef={pageScrollRef}
66
- {...props}
61
+ {...panelProps}
67
62
  />
68
- </div>,
69
- {wrapper},
70
- ),
63
+ </div>
64
+ </QueryClientProvider>
65
+ );
66
+ const rendered = render(renderPanelUi(props));
67
+
68
+ return {
69
+ queryClient,
70
+ ...rendered,
71
+ rerenderPanel: (nextProps: Partial<Parameters<typeof StepAttemptLogPanel>[0]> = {}) => {
72
+ rendered.rerender(renderPanelUi({...props, ...nextProps}));
73
+ },
71
74
  };
72
75
  }
73
76
 
@@ -240,6 +243,21 @@ describe('StepAttemptLogPanel', () => {
240
243
  expect(screen.getByRole('button', {name: 'Retry'})).toBeInTheDocument();
241
244
  });
242
245
 
246
+ test('refetches the expanded attempt when its refresh token changes', async () => {
247
+ const fetchImpl = vi
248
+ .fn()
249
+ .mockResolvedValueOnce(jsonResponse(inlineLogBody(outputLine('first\n'), 1)))
250
+ .mockResolvedValueOnce(jsonResponse(inlineLogBody(outputLine('second\n'), 2)));
251
+ configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
252
+ const {rerenderPanel} = renderPanel({attemptStatus: 'succeeded', refreshToken: 0});
253
+
254
+ expect(await screen.findByText('first')).toBeInTheDocument();
255
+ rerenderPanel({refreshToken: 1});
256
+
257
+ expect(await screen.findByText('second')).toBeInTheDocument();
258
+ expect(fetchImpl).toHaveBeenCalledTimes(2);
259
+ });
260
+
243
261
  test('does not follow new log output after the user scrolls away from the tail', async () => {
244
262
  vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
245
263
  callback(0);
@@ -13,7 +13,7 @@ import {JobExecutionTimeText} from './job-execution-time-text.js';
13
13
  const TAIL_FOLLOW_THRESHOLD_PX = 24;
14
14
  const INITIAL_LOG_ERROR_RETRY_COUNT = 5;
15
15
  const INITIAL_LOG_ERROR_RETRY_DELAY_MS = 1_500;
16
- const defaultLogSurfaceClasses = 'rounded-8';
16
+ const defaultLogSurfaceClasses = 'rounded-none border-0 bg-background-contrast-base shadow-none';
17
17
 
18
18
  export interface StepAttemptLogPanelProps {
19
19
  stepId: string;
@@ -24,6 +24,12 @@ export interface StepAttemptLogPanelProps {
24
24
  /** The page scroll column used by the uncapped job-detail log surface. */
25
25
  pageScrollRef: RefObject<HTMLElement | null>;
26
26
  surfaceClassName?: string | undefined;
27
+ search?: string | undefined;
28
+ wrap?: boolean | undefined;
29
+ showLineNumbers?: boolean | undefined;
30
+ attemptId?: string | undefined;
31
+ refreshToken?: number | undefined;
32
+ onFetchingChange?: ((attemptId: string, isFetching: boolean) => void) | undefined;
27
33
  initialErrorRetryCount?: number | undefined;
28
34
  initialErrorRetryDelayMs?: number | undefined;
29
35
  }
@@ -35,6 +41,12 @@ export function StepAttemptLogPanel({
35
41
  attemptStartedAt,
36
42
  pageScrollRef,
37
43
  surfaceClassName = defaultLogSurfaceClasses,
44
+ search = '',
45
+ wrap = false,
46
+ showLineNumbers = true,
47
+ attemptId,
48
+ refreshToken = 0,
49
+ onFetchingChange,
38
50
  initialErrorRetryCount = INITIAL_LOG_ERROR_RETRY_COUNT,
39
51
  initialErrorRetryDelayMs = INITIAL_LOG_ERROR_RETRY_DELAY_MS,
40
52
  }: StepAttemptLogPanelProps) {
@@ -55,6 +67,18 @@ export function StepAttemptLogPanel({
55
67
  retryMissingStream && query.data === undefined && isMissingStepLogStreamError(query.error);
56
68
  const initialError = query.isError && query.data === undefined && !missingActiveStream;
57
69
  const staleError = query.isError && query.data !== undefined;
70
+ const lastRefreshTokenRef = useRef(refreshToken);
71
+
72
+ useEffect(() => {
73
+ if (refreshToken === lastRefreshTokenRef.current) return;
74
+ lastRefreshTokenRef.current = refreshToken;
75
+ void query.refetchLogs();
76
+ }, [query.refetchLogs, refreshToken]);
77
+
78
+ useEffect(() => {
79
+ if (!attemptId) return;
80
+ onFetchingChange?.(attemptId, query.isFetching);
81
+ }, [attemptId, onFetchingChange, query.isFetching]);
58
82
 
59
83
  useEffect(() => {
60
84
  const scrollElement = pageScrollRef.current;
@@ -85,18 +109,32 @@ export function StepAttemptLogPanel({
85
109
  }, [anchorToFailure, pageScrollRef, recordCount]);
86
110
 
87
111
  if (query.isPending) {
88
- return <StepLogsLoadingSurface label="Loading logs" className={surfaceClassName} />;
112
+ return (
113
+ <StepLogsLoadingSurface
114
+ label="Loading logs"
115
+ className={surfaceClassName}
116
+ wrap={wrap}
117
+ showLineNumbers={showLineNumbers}
118
+ />
119
+ );
89
120
  }
90
121
 
91
122
  if (missingActiveStream) {
92
123
  if (attemptStatus === 'running' && attemptStartedAt) {
93
124
  return <StepLogsWaitingSurface startedAt={attemptStartedAt} className={surfaceClassName} />;
94
125
  }
95
- return <StepLogsLoadingSurface label="Waiting for logs" className={surfaceClassName} />;
126
+ return (
127
+ <StepLogsLoadingSurface
128
+ label="Waiting for logs"
129
+ className={surfaceClassName}
130
+ wrap={wrap}
131
+ showLineNumbers={showLineNumbers}
132
+ />
133
+ );
96
134
  }
97
135
 
98
136
  if (initialError) {
99
- return <StepLogsError retrying={query.isFetching} onRetry={() => void query.refetch()} />;
137
+ return <StepLogsError retrying={query.isFetching} onRetry={() => void query.refetchLogs()} />;
100
138
  }
101
139
 
102
140
  return (
@@ -115,7 +153,7 @@ export function StepAttemptLogPanel({
115
153
  size="2xs"
116
154
  variant="secondary"
117
155
  isLoading={query.isFetching}
118
- onClick={() => void query.refetch()}
156
+ onClick={() => void query.refetchLogs()}
119
157
  >
120
158
  Retry
121
159
  </Button>
@@ -124,19 +162,32 @@ export function StepAttemptLogPanel({
124
162
  ) : null}
125
163
  <LogView
126
164
  records={records}
165
+ search={search}
166
+ wrap={wrap}
167
+ showLineNumbers={showLineNumbers}
127
168
  emptyState={query.data?.complete ? 'complete' : 'pending'}
128
169
  anchorToFailure={anchorToFailure}
129
- ariaLive={attemptStatus === 'running' ? 'polite' : 'off'}
170
+ ariaLive={search.trim() ? 'off' : attemptStatus === 'running' ? 'polite' : 'off'}
130
171
  className={surfaceClassName}
131
172
  />
132
173
  </div>
133
174
  );
134
175
  }
135
176
 
136
- function StepLogsLoadingSurface({label, className}: {label: string; className: string}) {
177
+ function StepLogsLoadingSurface({
178
+ label,
179
+ className,
180
+ wrap,
181
+ showLineNumbers,
182
+ }: {
183
+ label: string;
184
+ className: string;
185
+ wrap: boolean;
186
+ showLineNumbers: boolean;
187
+ }) {
137
188
  return (
138
189
  <div role="status" aria-label={label}>
139
- <LogViewSkeleton className={className} />
190
+ <LogViewSkeleton className={className} wrap={wrap} showLineNumbers={showLineNumbers} />
140
191
  </div>
141
192
  );
142
193
  }
@@ -144,7 +195,7 @@ function StepLogsLoadingSurface({label, className}: {label: string; className: s
144
195
  function StepLogsWaitingSurface({startedAt, className}: {startedAt: string; className: string}) {
145
196
  return (
146
197
  <div role="status" aria-label="Waiting for logs" className={className}>
147
- <Text size="xs" className="px-tight py-row text-foreground-neutral-muted">
198
+ <Text size="xs" className="px-tight py-row text-foreground-contrast-secondary">
148
199
  Waiting for output ·{' '}
149
200
  <span className="font-code tabular-nums" aria-hidden="true">
150
201
  <JobExecutionTimeText time={{state: 'live', fromIso: startedAt}} />
@@ -188,7 +239,14 @@ function StepLogsError({retrying, onRetry}: {retrying: boolean; onRetry: () => v
188
239
  >
189
240
  <div className="flex min-w-0 flex-1 items-center justify-between gap-inline">
190
241
  <Text size="xs">Could not load logs.</Text>
191
- <Button type="button" size="2xs" variant="secondary" isLoading={retrying} onClick={onRetry}>
242
+ <Button
243
+ type="button"
244
+ size="2xs"
245
+ variant="secondary"
246
+ isLoading={retrying}
247
+ disabled={retrying}
248
+ onClick={onRetry}
249
+ >
192
250
  Retry
193
251
  </Button>
194
252
  </div>