@shipfox/client-workflows 20.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 (118) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +51 -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 +232 -44
  5. package/dist/components/job-detail/job-detail-view.js.map +1 -1
  6. package/dist/components/job-detail/job-empty-states.js +2 -2
  7. package/dist/components/job-detail/job-empty-states.js.map +1 -1
  8. package/dist/components/job-detail/step-attempt-log-panel.d.ts +7 -1
  9. package/dist/components/job-detail/step-attempt-log-panel.d.ts.map +1 -1
  10. package/dist/components/job-detail/step-attempt-log-panel.js +37 -10
  11. package/dist/components/job-detail/step-attempt-log-panel.js.map +1 -1
  12. package/dist/components/job-graph/job-graph-content.js +2 -2
  13. package/dist/components/job-graph/job-graph-content.js.map +1 -1
  14. package/dist/components/step-list/step-list-model.d.ts +1 -0
  15. package/dist/components/step-list/step-list-model.d.ts.map +1 -1
  16. package/dist/components/step-list/step-list-model.js +1 -1
  17. package/dist/components/step-list/step-list-model.js.map +1 -1
  18. package/dist/components/step-list/step-list.d.ts +2 -1
  19. package/dist/components/step-list/step-list.d.ts.map +1 -1
  20. package/dist/components/step-list/step-list.js +25 -9
  21. package/dist/components/step-list/step-list.js.map +1 -1
  22. package/dist/components/workflow-run-list/job-status-strip.d.ts +9 -1
  23. package/dist/components/workflow-run-list/job-status-strip.d.ts.map +1 -1
  24. package/dist/components/workflow-run-list/job-status-strip.js +22 -11
  25. package/dist/components/workflow-run-list/job-status-strip.js.map +1 -1
  26. package/dist/components/workflow-run-list/workflow-run-filters.js +1 -1
  27. package/dist/components/workflow-run-list/workflow-run-filters.js.map +1 -1
  28. package/dist/components/workflow-run-list/workflow-run-list-content.d.ts.map +1 -1
  29. package/dist/components/workflow-run-list/workflow-run-list-content.js +13 -7
  30. package/dist/components/workflow-run-list/workflow-run-list-content.js.map +1 -1
  31. package/dist/components/workflow-run-list/workflow-run-list-states.d.ts.map +1 -1
  32. package/dist/components/workflow-run-list/workflow-run-list-states.js +43 -38
  33. package/dist/components/workflow-run-list/workflow-run-list-states.js.map +1 -1
  34. package/dist/components/workflow-run-list/workflow-run-list-view.d.ts.map +1 -1
  35. package/dist/components/workflow-run-list/workflow-run-list-view.js +34 -22
  36. package/dist/components/workflow-run-list/workflow-run-list-view.js.map +1 -1
  37. package/dist/components/workflow-run-list/workflow-run-row.d.ts +3 -3
  38. package/dist/components/workflow-run-list/workflow-run-row.d.ts.map +1 -1
  39. package/dist/components/workflow-run-list/workflow-run-row.js +25 -18
  40. package/dist/components/workflow-run-list/workflow-run-row.js.map +1 -1
  41. package/dist/components/workflow-run-summary/workflow-run-summary.d.ts.map +1 -1
  42. package/dist/components/workflow-run-summary/workflow-run-summary.js +4 -4
  43. package/dist/components/workflow-run-summary/workflow-run-summary.js.map +1 -1
  44. package/dist/components/workflow-run-view/run-workspace-nav.d.ts.map +1 -1
  45. package/dist/components/workflow-run-view/run-workspace-nav.js +20 -7
  46. package/dist/components/workflow-run-view/run-workspace-nav.js.map +1 -1
  47. package/dist/components/workflow-run-view/workflow-run-states.d.ts.map +1 -1
  48. package/dist/components/workflow-run-view/workflow-run-states.js +1 -1
  49. package/dist/components/workflow-run-view/workflow-run-states.js.map +1 -1
  50. package/dist/components/workflow-run-view/workflow-run-view.d.ts.map +1 -1
  51. package/dist/components/workflow-run-view/workflow-run-view.js +103 -78
  52. package/dist/components/workflow-run-view/workflow-run-view.js.map +1 -1
  53. package/dist/components/workflow-source-panel/workflow-source-content.js +1 -1
  54. package/dist/components/workflow-source-panel/workflow-source-content.js.map +1 -1
  55. package/dist/pages/project-workflows-page.d.ts.map +1 -1
  56. package/dist/pages/project-workflows-page.js +171 -158
  57. package/dist/pages/project-workflows-page.js.map +1 -1
  58. package/dist/pages/workflow-job-detail-page.d.ts.map +1 -1
  59. package/dist/pages/workflow-job-detail-page.js +3 -2
  60. package/dist/pages/workflow-job-detail-page.js.map +1 -1
  61. package/dist/pages/workflow-run-detail-page.js +2 -1
  62. package/dist/pages/workflow-run-detail-page.js.map +1 -1
  63. package/dist/pages/workflow-run-list-page.d.ts.map +1 -1
  64. package/dist/pages/workflow-run-list-page.js +9 -11
  65. package/dist/pages/workflow-run-list-page.js.map +1 -1
  66. package/dist/routes/job-detail.d.ts +1 -1
  67. package/dist/routes/job-detail.js +1 -1
  68. package/dist/routes/job-detail.js.map +1 -1
  69. package/dist/routes/run-detail.d.ts +1 -1
  70. package/dist/routes/run-detail.js +1 -1
  71. package/dist/routes/run-detail.js.map +1 -1
  72. package/dist/routes/runs.d.ts +1 -1
  73. package/dist/routes/runs.js +1 -1
  74. package/dist/routes/runs.js.map +1 -1
  75. package/dist/routes/workflows.d.ts +3 -0
  76. package/dist/routes/workflows.js +3 -0
  77. package/dist/routes/workflows.js.map +1 -1
  78. package/dist/tsconfig.test.tsbuildinfo +1 -1
  79. package/package.json +8 -8
  80. package/src/components/job-detail/job-detail-view.tsx +264 -49
  81. package/src/components/job-detail/job-empty-states.tsx +2 -2
  82. package/src/components/job-detail/step-attempt-log-panel.test.tsx +30 -12
  83. package/src/components/job-detail/step-attempt-log-panel.tsx +68 -10
  84. package/src/components/job-graph/job-graph-content.tsx +2 -2
  85. package/src/components/job-graph/job-graph.stories.tsx +1 -1
  86. package/src/components/job-graph/job-node.stories.tsx +1 -1
  87. package/src/components/step-list/step-list-model.ts +1 -1
  88. package/src/components/step-list/step-list.test.tsx +29 -1
  89. package/src/components/step-list/step-list.tsx +29 -11
  90. package/src/components/workflow-run-list/job-status-strip.tsx +25 -12
  91. package/src/components/workflow-run-list/workflow-run-filters.tsx +1 -1
  92. package/src/components/workflow-run-list/workflow-run-list-content.tsx +7 -2
  93. package/src/components/workflow-run-list/workflow-run-list-states.tsx +42 -41
  94. package/src/components/workflow-run-list/workflow-run-list-view.test.tsx +53 -4
  95. package/src/components/workflow-run-list/workflow-run-list-view.tsx +28 -22
  96. package/src/components/workflow-run-list/workflow-run-list.stories.tsx +9 -20
  97. package/src/components/workflow-run-list/workflow-run-row.tsx +26 -19
  98. package/src/components/workflow-run-summary/workflow-run-summary.stories.tsx +3 -3
  99. package/src/components/workflow-run-summary/workflow-run-summary.test.tsx +11 -2
  100. package/src/components/workflow-run-summary/workflow-run-summary.tsx +4 -7
  101. package/src/components/workflow-run-view/run-workspace-nav.test.tsx +40 -2
  102. package/src/components/workflow-run-view/run-workspace-nav.tsx +28 -9
  103. package/src/components/workflow-run-view/workflow-run-states.tsx +1 -4
  104. package/src/components/workflow-run-view/workflow-run-view.stories.tsx +142 -0
  105. package/src/components/workflow-run-view/workflow-run-view.test.tsx +42 -8
  106. package/src/components/workflow-run-view/workflow-run-view.tsx +112 -96
  107. package/src/components/workflow-source-panel/workflow-source-content.tsx +2 -2
  108. package/src/pages/project-workflows-page.tsx +135 -125
  109. package/src/pages/workflow-job-detail-page.test.tsx +32 -0
  110. package/src/pages/workflow-job-detail-page.tsx +5 -1
  111. package/src/pages/workflow-run-detail-page.tsx +1 -1
  112. package/src/pages/workflow-run-list-page.tsx +9 -11
  113. package/src/pages/workflow-run-pages.test.tsx +26 -0
  114. package/src/routes/job-detail.tsx +1 -1
  115. package/src/routes/run-detail.tsx +1 -1
  116. package/src/routes/runs.tsx +1 -1
  117. package/src/routes/workflows.tsx +1 -0
  118. 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": "22.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -29,14 +29,14 @@
29
29
  "date-fns": "^4.1.0",
30
30
  "@shipfox/annotations-dto": "12.3.0",
31
31
  "@shipfox/api-definitions-dto": "12.3.0",
32
- "@shipfox/api-workflows-dto": "12.7.0",
32
+ "@shipfox/api-workflows-dto": "13.0.0",
33
+ "@shipfox/client-logs": "22.0.0",
33
34
  "@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"
35
+ "@shipfox/client-projects": "22.0.0",
36
+ "@shipfox/client-shell": "22.0.0",
37
+ "@shipfox/client-triggers": "22.0.0",
38
+ "@shipfox/react-ui": "2.1.0",
39
+ "@shipfox/client-ui": "22.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,21 +444,124 @@ 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 (
367
560
  <EmptyState
368
- className="min-h-120 p-panel"
369
561
  icon="componentLine"
370
562
  title={emptyState.title}
371
563
  description={emptyState.description}
372
- variant="compact"
564
+ variant="panel"
373
565
  />
374
566
  );
375
567
  }
@@ -442,7 +634,7 @@ function NewerAttemptNotice({
442
634
  );
443
635
  }
444
636
 
445
- function runningStepSelection(jobExecution: JobExecution | undefined) {
637
+ function runningStepSelection(jobExecution: JobExecution | undefined, model: StepListModel) {
446
638
  if (!jobExecution) return undefined;
447
639
  const steps = [...jobExecution.steps].sort(
448
640
  (left, right) => left.position - right.position || left.id.localeCompare(right.id),
@@ -455,7 +647,11 @@ function runningStepSelection(jobExecution: JobExecution | undefined) {
455
647
  .reverse()
456
648
  .find((candidate) => candidate.status === 'running');
457
649
  if (attempt)
458
- return {stepId: step.id, attemptId: attempt.id, stepLabel: step.name || step.key || step.id};
650
+ return {
651
+ stepId: step.id,
652
+ attemptId: attempt.id,
653
+ stepLabel: model.entries.find((entry) => entry.id === attempt.id)?.step.label ?? 'Step',
654
+ };
459
655
  }
460
656
  return undefined;
461
657
  }
@@ -468,6 +664,25 @@ function findAttempt(jobExecution: JobExecution, attemptId: string) {
468
664
  return undefined;
469
665
  }
470
666
 
667
+ function findExpandedLogSelection(
668
+ jobExecution: JobExecution | undefined,
669
+ attemptIds: readonly string[],
670
+ model: StepListModel,
671
+ ) {
672
+ if (!jobExecution) return undefined;
673
+
674
+ for (let index = attemptIds.length - 1; index >= 0; index -= 1) {
675
+ const attemptId = attemptIds[index];
676
+ if (!attemptId) continue;
677
+ const match = findAttempt(jobExecution, attemptId);
678
+ const attempt = match?.step.attempts.find((candidate) => candidate.id === attemptId);
679
+ const entry = model.entries.find((candidate) => candidate.id === attemptId);
680
+ if (match && attempt && entry) return {step: match.step, attempt, stepLabel: entry.step.label};
681
+ }
682
+
683
+ return undefined;
684
+ }
685
+
471
686
  function annotationCountForStep(
472
687
  summary: RunAnnotationSummary | undefined,
473
688
  stepId: string,
@@ -256,11 +256,11 @@ function humanizeFailureReason(reason: string): string {
256
256
  export function CarriedOverStepPanel() {
257
257
  return (
258
258
  <EmptyState
259
- className="min-h-120 rounded-8 border border-border-neutral-base bg-background-components-base p-panel"
259
+ className="rounded-8 border border-border-neutral-base bg-background-components-base"
260
260
  icon="componentLine"
261
261
  title="Carried over from a previous attempt"
262
262
  description="Not executed in this run."
263
- variant="compact"
263
+ variant="panel"
264
264
  />
265
265
  );
266
266
  }
@@ -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);