@runwisp/ui 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@runwisp/ui",
3
3
  "description": "Svelte 5 UI component library for RunWisp dashboards and admin interfaces.",
4
- "version": "0.9.0",
4
+ "version": "0.10.0",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",
@@ -50,8 +50,8 @@
50
50
  "@chromatic-com/storybook": "^4.1.3",
51
51
  "@eslint/compat": "^1.4.0",
52
52
  "@eslint/js": "^9.39.1",
53
- "@runwisp/eslint-config": "0.9.0",
54
- "@runwisp/typescript-config": "0.9.0",
53
+ "@runwisp/eslint-config": "0.10.0",
54
+ "@runwisp/typescript-config": "0.10.0",
55
55
  "@storybook/addon-a11y": "^10.1.9",
56
56
  "@storybook/addon-docs": "^10.1.9",
57
57
  "@storybook/addon-svelte-csf": "^5.0.10",
@@ -99,7 +99,7 @@
99
99
  "@floating-ui/dom": "^1.7.4",
100
100
  "@fontsource/tasa-orbiter": "^5.2.2",
101
101
  "@lucide/svelte": "^0.561.0",
102
- "@runwisp/common": "0.9.0",
102
+ "@runwisp/common": "0.10.0",
103
103
  "@tanstack/svelte-virtual": "^3.13.25",
104
104
  "ansi-to-html": "^0.7.2",
105
105
  "cron-parser": "^5.4.0",
@@ -35,14 +35,23 @@
35
35
  const cache = new LogCache();
36
36
  let fetcher: LogFetcher | null = $state(null);
37
37
  let containerEl: HTMLDivElement | null = $state(null);
38
+ let rulerEl: HTMLSpanElement | null = $state(null);
38
39
  let scrollTop = $state(0);
39
40
  let containerHeight = $state(0);
41
+ let containerWidth = $state(0);
42
+ // Monospace character width in px, measured from a hidden ruler so the
43
+ // horizontal scroll surface is sized without per-line DOM reflow.
44
+ let charWidth = $state(8);
40
45
  let isAutoScroll = $state(true);
41
46
  let userScrolledUp = $state(false);
42
47
  let isStreaming = $derived(!cache.finished && cache.totalLines > 0);
43
48
 
44
49
  const OVERSCAN = 50;
45
50
  const BLANK_LINES_AT_END = 3;
51
+ const RULER_SAMPLE = "0".repeat(50);
52
+ // Trailing slack past the last column so the widest line never sits flush
53
+ // against the scroll edge.
54
+ const SURFACE_PADDING = 16;
46
55
 
47
56
  let truncationBannerHeight = $derived(cache.firstAvailableLine > 0 ? lineHeight : 0);
48
57
 
@@ -85,6 +94,13 @@
85
94
 
86
95
  let gutterWidth = $derived(Math.max(4, String(cache.totalLines || 1).length) * 10 + 16);
87
96
 
97
+ // Width of the virtual scroll surface: wide enough for the longest line
98
+ // seen so far, never narrower than the viewport (so short logs show no
99
+ // horizontal scrollbar).
100
+ let surfaceWidth = $derived(
101
+ Math.max(containerWidth, gutterWidth + cache.maxLineColumns * charWidth + SURFACE_PADDING),
102
+ );
103
+
88
104
  let renderedLines = $derived.by(() => {
89
105
  const lines: Array<{ num: number; text: string | undefined }> = [];
90
106
  const start = Math.max(cache.firstAvailableLine, visibleStart);
@@ -186,6 +202,17 @@
186
202
  function onResize() {
187
203
  if (containerEl) {
188
204
  containerHeight = containerEl.clientHeight;
205
+ containerWidth = containerEl.clientWidth;
206
+ }
207
+ measureCharWidth();
208
+ }
209
+
210
+ // Measure one monospace column from the hidden ruler. Font, zoom, and
211
+ // DPI changes all surface through the ResizeObserver, so this stays
212
+ // accurate without polling.
213
+ function measureCharWidth() {
214
+ if (rulerEl && rulerEl.offsetWidth > 0) {
215
+ charWidth = rulerEl.offsetWidth / RULER_SAMPLE.length;
189
216
  }
190
217
  }
191
218
 
@@ -236,6 +263,8 @@
236
263
  $effect(() => {
237
264
  if (!containerEl) return;
238
265
  containerHeight = containerEl.clientHeight;
266
+ containerWidth = containerEl.clientWidth;
267
+ measureCharWidth();
239
268
 
240
269
  const resizeObserver = new ResizeObserver(() => {
241
270
  onResize();
@@ -251,18 +280,30 @@
251
280
  <div
252
281
  class="log-console relative flex h-full w-full flex-col overflow-hidden bg-mist-950 font-mono text-sm {className}"
253
282
  >
283
+ <!-- Hidden ruler: one monospace column is measured from this off-screen
284
+ sample to size the horizontal scroll surface without per-line reflow. -->
285
+ <span
286
+ bind:this={rulerEl}
287
+ aria-hidden="true"
288
+ class="pointer-events-none absolute -top-[9999px] left-0 whitespace-pre select-none"
289
+ >{RULER_SAMPLE}</span
290
+ >
291
+
254
292
  <div bind:this={containerEl} class="flex-1 overflow-auto" onscroll={onScroll}>
255
- <div class="relative" style="height: {totalHeight}px; min-height: 100%;">
293
+ <div
294
+ class="relative"
295
+ style="height: {totalHeight}px; min-height: 100%; width: {surfaceWidth}px; min-width: 100%;"
296
+ >
256
297
  {#if cache.firstAvailableLine > 0}
257
298
  <div
258
299
  class="absolute right-0 left-0 flex items-center bg-warning-700/20 text-warning-400"
259
300
  style="top: 0px; height: {lineHeight}px;"
260
301
  >
261
302
  <div
262
- class="flex-shrink-0 pr-3 text-right select-none"
303
+ class="sticky left-0 z-10 flex-shrink-0 pr-3 text-right select-none"
263
304
  style="width: {gutterWidth}px;"
264
305
  ></div>
265
- <div class="flex-1 truncate pr-4 text-xs">
306
+ <div class="sticky flex-shrink-0 pr-4 text-xs" style="left: {gutterWidth}px;">
266
307
  Log truncated: {cache.firstAvailableLine.toLocaleString()} earlier line{cache.firstAvailableLine ===
267
308
  1
268
309
  ? ""
@@ -280,12 +321,12 @@
280
321
  style="top: {lineTop(line.num)}px; height: {lineHeight}px;"
281
322
  >
282
323
  <div
283
- class="flex-shrink-0 pr-3 text-right text-mist-500 select-none"
324
+ class="sticky left-0 z-10 flex-shrink-0 bg-mist-950 pr-3 text-right text-mist-500 select-none"
284
325
  style="width: {gutterWidth}px;"
285
326
  >
286
327
  {line.num + 1}
287
328
  </div>
288
- <div class="flex-1 truncate pr-4 text-mist-200">
329
+ <div class="flex-1 pr-4 text-mist-200">
289
330
  {#if line.text !== undefined}
290
331
  <!-- eslint-disable-next-line svelte/no-at-html-tags -->
291
332
  <span class="whitespace-pre">{@html ansiLineToHtml(line.text)}</span>
@@ -302,10 +343,13 @@
302
343
  style="top: {lineTop(cache.totalLines)}px; height: {lineHeight}px;"
303
344
  >
304
345
  <div
305
- class="flex-shrink-0 pr-3 text-right select-none"
346
+ class="sticky left-0 z-10 flex-shrink-0 bg-mist-950 pr-3 text-right select-none"
306
347
  style="width: {gutterWidth}px;"
307
348
  ></div>
308
- <div class="flex items-center gap-1 text-mist-400">
349
+ <div
350
+ class="sticky flex items-center gap-1 text-mist-400"
351
+ style="left: {gutterWidth}px;"
352
+ >
309
353
  <span class="dot" style="animation-delay: 0ms;"></span>
310
354
  <span class="dot" style="animation-delay: 150ms;"></span>
311
355
  <span class="dot" style="animation-delay: 300ms;"></span>
@@ -322,7 +366,7 @@
322
366
  )}px; height: {lineHeight}px;"
323
367
  >
324
368
  <div
325
- class="flex-shrink-0 pr-3 text-right text-mist-700 opacity-0 select-none"
369
+ class="sticky left-0 z-10 flex-shrink-0 bg-mist-950 pr-3 text-right text-mist-700 opacity-0 select-none"
326
370
  style="width: {gutterWidth}px;"
327
371
  >
328
372
  ~
@@ -408,6 +452,7 @@
408
452
  <style>
409
453
  .log-console {
410
454
  --log-line-height: 20px;
455
+ tab-size: 8;
411
456
  }
412
457
 
413
458
  .log-line--flash {
@@ -14,7 +14,9 @@
14
14
  onClose?: () => void;
15
15
  header?: Snippet;
16
16
  footer?: Snippet;
17
- children?: Snippet;
17
+ // Explicitly nullable so callers can pass `children={cond ? snip : undefined}`
18
+ // to suppress the padded body band entirely when there's nothing to show.
19
+ children?: Snippet | undefined;
18
20
  class?: string;
19
21
  }
20
22
 
@@ -10,18 +10,26 @@
10
10
  Download,
11
11
  Trash2,
12
12
  MousePointerClick,
13
+ RotateCw,
14
+ SlidersHorizontal,
13
15
  } from "@lucide/svelte";
14
16
  import Badge from "../Badge.svelte";
15
17
  import Button from "../Button.svelte";
16
18
  import EmptyState from "../EmptyState.svelte";
17
19
  import LogConsole from "../LogConsole.svelte";
20
+ import Popover from "../Popover.svelte";
18
21
  import Tooltip from "../Tooltip.svelte";
19
22
  import type { Run } from "./types.js";
20
23
  import type { LogEvent, LogSlice } from "../../log-console/types.js";
21
- import { formatDateTime } from "../../utils/format.js";
24
+ import { formatDateTime, formatFullDateTime } from "../../utils/format.js";
22
25
  import { formatShortId } from "../../utils/id.js";
23
26
  import { getRunStatusConfig, runDisplayStatus } from "./status-config.js";
24
- import { runDuration, runStartDelay } from "./run-helpers.js";
27
+ import {
28
+ runDuration,
29
+ runStartDelay,
30
+ formatTriggeredByLabel,
31
+ runRetryLabel,
32
+ } from "./run-helpers.js";
25
33
 
26
34
  let {
27
35
  run,
@@ -95,6 +103,9 @@
95
103
  {@const DetailIcon = config.icon}
96
104
  {@const duration = runDuration(run)}
97
105
  {@const startDelay = runStartDelay(run)}
106
+ {@const startedAt = run.start_at ?? run.created_at}
107
+ {@const retry = runRetryLabel(run)}
108
+ {@const paramEntries = run.params ? Object.entries(run.params) : []}
98
109
  <!-- Detailed Header -->
99
110
  <div class="@container shrink-0 border-b border-outline-faint bg-surface-raised p-6">
100
111
  <div class="flex flex-col justify-between gap-6 @4xl:flex-row @4xl:items-start">
@@ -114,7 +125,9 @@
114
125
  class="text-on-surface-muted">#{run.instance_index}</span
115
126
  >{/if}
116
127
  {:else}
117
- Run #{formatShortId(run.id)}{#if run.instance_index > 0}
128
+ <span title={formatFullDateTime(startedAt)}
129
+ >Run · {formatDateTime(startedAt)}</span
130
+ >{#if run.instance_index > 0}
118
131
  <span class="text-on-surface-muted"
119
132
  >· instance #{run.instance_index}</span
120
133
  >
@@ -162,7 +175,7 @@
162
175
  <Server size={14} />
163
176
  <span
164
177
  >Triggered by <span class="font-medium text-on-surface"
165
- >{run.triggered_by}</span
178
+ >{formatTriggeredByLabel(run.triggered_by)}</span
166
179
  ></span
167
180
  >
168
181
  </div>
@@ -171,7 +184,7 @@
171
184
  <Server size={14} />
172
185
  <span
173
186
  >Triggered by <span class="font-medium text-on-surface"
174
- >{run.triggered_by}</span
187
+ >{formatTriggeredByLabel(run.triggered_by)}</span
175
188
  ></span
176
189
  >
177
190
  </div>
@@ -185,6 +198,57 @@
185
198
  {run.id}
186
199
  </div>
187
200
  {/if}
201
+ {#if retry}
202
+ <div
203
+ class="hidden h-1 w-1 rounded-full bg-outline-faint sm:block"
204
+ ></div>
205
+ <div class="flex items-center gap-1.5">
206
+ <RotateCw size={14} />
207
+ <span
208
+ >Retry #{run.retry_attempt}{#if run.retry_of_run_id}
209
+ <span class="text-on-surface-faint"
210
+ >of run {formatShortId(run.retry_of_run_id)}</span
211
+ >{/if}</span
212
+ >
213
+ </div>
214
+ {/if}
215
+ {#if paramEntries.length > 0}
216
+ <div
217
+ class="hidden h-1 w-1 rounded-full bg-outline-faint sm:block"
218
+ ></div>
219
+ <Popover placement="bottom-start">
220
+ {#snippet trigger()}
221
+ <span
222
+ class="inline-flex cursor-pointer items-center gap-1.5 rounded-full border border-outline-faint bg-surface-sunken px-2.5 py-0.5 text-xs font-medium text-on-surface-muted transition-colors hover:border-outline hover:text-on-surface"
223
+ title="View run parameters"
224
+ >
225
+ <SlidersHorizontal size={12} />
226
+ {paramEntries.length}
227
+ {paramEntries.length === 1 ? "parameter" : "parameters"}
228
+ </span>
229
+ {/snippet}
230
+ <div class="max-w-md min-w-48">
231
+ <span
232
+ class="text-xs font-semibold tracking-wide text-on-surface-faint uppercase"
233
+ >Parameters</span
234
+ >
235
+ <dl
236
+ class="mt-2 grid max-h-72 grid-cols-[max-content_1fr] gap-x-4 gap-y-1.5 overflow-y-auto"
237
+ >
238
+ {#each paramEntries as [key, value] (key)}
239
+ <dt class="font-mono text-xs text-on-surface-muted">
240
+ {key}
241
+ </dt>
242
+ <dd
243
+ class="font-mono text-xs font-medium break-all text-on-surface"
244
+ >
245
+ {value}
246
+ </dd>
247
+ {/each}
248
+ </dl>
249
+ </div>
250
+ </Popover>
251
+ {/if}
188
252
  </div>
189
253
  </div>
190
254
  </div>
@@ -28,13 +28,11 @@
28
28
  import Button from "../Button.svelte";
29
29
  import Badge from "../Badge.svelte";
30
30
  import EmptyState from "../EmptyState.svelte";
31
- import Tooltip from "../Tooltip.svelte";
32
31
  import type { Run } from "./types.js";
33
32
  import type { RunSelector } from "@runwisp/common";
34
33
  import { getRunStatusConfig, runDisplayStatus } from "./status-config.js";
35
- import { runDuration } from "./run-helpers.js";
36
- import { formatRelativeTime } from "../../utils/format.js";
37
- import { formatShortId } from "../../utils/id.js";
34
+ import { runDuration, formatTriggeredByLabel, runRetryLabel } from "./run-helpers.js";
35
+ import { formatRelativeTime, formatDateTime, formatFullDateTime } from "../../utils/format.js";
38
36
 
39
37
  type BulkHandler = (selector: RunSelector, affected: Run[]) => void;
40
38
 
@@ -365,6 +363,8 @@
365
363
  {@const config = getRunStatusConfig(runDisplayStatus(run))}
366
364
  {@const Icon = config.icon}
367
365
  {@const duration = runDuration(run)}
366
+ {@const retry = runRetryLabel(run)}
367
+ {@const startedAt = run.start_at ?? run.created_at}
368
368
  <div
369
369
  class="flex items-stretch gap-1"
370
370
  style:position="absolute"
@@ -377,7 +377,7 @@
377
377
  {#if bulkActions}
378
378
  <label
379
379
  class="flex shrink-0 cursor-pointer items-center px-1.5"
380
- aria-label={`Select run ${formatShortId(run.id)}`}
380
+ aria-label={`Select run from ${formatDateTime(run.start_at ?? run.created_at)}`}
381
381
  >
382
382
  <input
383
383
  type="checkbox"
@@ -421,16 +421,27 @@
421
421
  class="shrink-0 text-2xs {isActive
422
422
  ? 'font-medium text-primary'
423
423
  : 'text-on-surface-faint'}"
424
+ title={formatFullDateTime(startedAt)}
424
425
  >
425
- {formatRelativeTime(run.start_at || run.created_at)}
426
+ {formatRelativeTime(startedAt)}
426
427
  </span>
427
428
  </div>
428
429
 
429
430
  <div class="flex items-center justify-between pl-6 text-xs">
430
431
  <div
431
- class="flex items-center gap-1 font-mono text-2xs text-on-surface-muted"
432
+ class="flex min-w-0 items-center gap-1.5 text-2xs text-on-surface-muted"
432
433
  >
433
- #{formatShortId(run.id)}
434
+ <span class="truncate">{formatDateTime(startedAt)}</span
435
+ >
436
+ <span class="text-on-surface-faint"
437
+ >· {formatTriggeredByLabel(run.triggered_by)}</span
438
+ >
439
+ {#if retry}
440
+ <span
441
+ class="shrink-0 rounded bg-surface-sunken px-1 font-mono text-2xs text-on-surface-faint"
442
+ >{retry}</span
443
+ >
444
+ {/if}
434
445
  </div>
435
446
  {#if duration}
436
447
  <div
@@ -444,10 +455,10 @@
444
455
  {/if}
445
456
  </div>
446
457
  {:else}
447
- <!-- Task variant: shortId as primary, status secondary -->
458
+ <!-- Task variant: when it ran as primary, outcome secondary -->
448
459
  <div class="mb-1.5 flex items-center justify-between">
449
- <div class="flex items-center gap-2">
450
- <div class={config.color}>
460
+ <div class="flex min-w-0 items-center gap-2">
461
+ <div class="{config.color} shrink-0">
451
462
  <Icon
452
463
  size={16}
453
464
  class={run.status === "running"
@@ -456,31 +467,35 @@
456
467
  />
457
468
  </div>
458
469
  <span
459
- class="font-mono text-xs font-medium text-on-surface"
470
+ class="truncate text-sm font-semibold text-on-surface"
460
471
  >
461
- #{formatShortId(run.id)}
472
+ {formatDateTime(startedAt)}
462
473
  </span>
463
474
  </div>
464
475
  <span
465
- class="text-2xs {isActive
476
+ class="shrink-0 text-2xs {isActive
466
477
  ? 'font-medium text-primary'
467
478
  : 'text-on-surface-faint'}"
479
+ title={formatFullDateTime(startedAt)}
468
480
  >
469
- {formatRelativeTime(run.start_at || run.created_at)}
481
+ {formatRelativeTime(startedAt)}
470
482
  </span>
471
483
  </div>
472
484
 
473
- <div class="flex items-center justify-between text-xs">
474
- <div class="flex items-center gap-2 text-on-surface-muted">
475
- <Tooltip
476
- content={config.description}
477
- position="right"
478
- wide
485
+ <div class="flex items-center justify-between pl-6 text-xs">
486
+ <div
487
+ class="flex min-w-0 items-center gap-2 text-on-surface-muted"
488
+ >
489
+ <span class="capitalize">{runDisplayStatus(run)}</span>
490
+ <span class="text-on-surface-faint"
491
+ >· {formatTriggeredByLabel(run.triggered_by)}</span
479
492
  >
480
- <span class="capitalize"
481
- >{runDisplayStatus(run)}</span
493
+ {#if retry}
494
+ <span
495
+ class="shrink-0 rounded bg-surface-sunken px-1 font-mono text-2xs text-on-surface-faint"
496
+ >{retry}</span
482
497
  >
483
- </Tooltip>
498
+ {/if}
484
499
  {#if run.instance_index > 0}
485
500
  <span
486
501
  class="font-mono text-2xs text-on-surface-faint"
@@ -2,7 +2,12 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
- import { runDuration, runStartDelay } from "./run-helpers.js";
5
+ import {
6
+ runDuration,
7
+ runStartDelay,
8
+ formatTriggeredByLabel,
9
+ runRetryLabel,
10
+ } from "./run-helpers.js";
6
11
 
7
12
  describe("runDuration", () => {
8
13
  it("returns undefined when start_at is not set", () => {
@@ -59,3 +64,27 @@ describe("runStartDelay", () => {
59
64
  ).toBe("7m 12s");
60
65
  });
61
66
  });
67
+
68
+ describe("formatTriggeredByLabel", () => {
69
+ it("humanizes each trigger source", () => {
70
+ expect(formatTriggeredByLabel("api")).toBe("API");
71
+ expect(formatTriggeredByLabel("cron")).toBe("Cron");
72
+ expect(formatTriggeredByLabel("service")).toBe("Service");
73
+ expect(formatTriggeredByLabel("startup")).toBe("Startup");
74
+ expect(formatTriggeredByLabel("cloud")).toBe("Cloud");
75
+ });
76
+ });
77
+
78
+ describe("runRetryLabel", () => {
79
+ it("returns undefined for a first attempt that is not a retry", () => {
80
+ expect(runRetryLabel({ retry_attempt: 0 })).toBeUndefined();
81
+ });
82
+
83
+ it("labels a run with a positive attempt number", () => {
84
+ expect(runRetryLabel({ retry_attempt: 2 })).toBe("retry #2");
85
+ });
86
+
87
+ it("labels a run that points back at the run it re-attempts", () => {
88
+ expect(runRetryLabel({ retry_attempt: 1, retry_of_run_id: "01JABC" })).toBe("retry #1");
89
+ });
90
+ });
@@ -26,6 +26,29 @@ export function runStartDelay(run: Pick<Run, "created_at" | "start_at">): string
26
26
  return formatDuration(delay);
27
27
  }
28
28
 
29
+ /** Human label for why a run fired (the `triggered_by` source). */
30
+ export function formatTriggeredByLabel(triggeredBy: Run["triggered_by"]): string {
31
+ if (triggeredBy === "api") return "API";
32
+ if (triggeredBy === "cron") return "Cron";
33
+ if (triggeredBy === "service") return "Service";
34
+ if (triggeredBy === "startup") return "Startup";
35
+ return "Cloud";
36
+ }
37
+
38
+ /**
39
+ * "retry #N" label when a run is a retry of an earlier one, else undefined.
40
+ * A run is a retry if it carries a positive attempt number or points back at
41
+ * the run it re-attempts.
42
+ */
43
+ export function runRetryLabel(
44
+ run: Pick<Run, "retry_attempt" | "retry_of_run_id">,
45
+ ): string | undefined {
46
+ if (run.retry_attempt > 0 || run.retry_of_run_id) {
47
+ return `retry #${String(run.retry_attempt)}`;
48
+ }
49
+ return undefined;
50
+ }
51
+
29
52
  /**
30
53
  * Monotonic position of a run's status in its lifecycle. Used to reject stale
31
54
  * updates (e.g. a `pending` HTTP response arriving after an SSE already
package/src/lib/index.ts CHANGED
@@ -90,7 +90,13 @@ export {
90
90
  RUN_STATUS_CONFIG,
91
91
  type RunStatusConfig,
92
92
  } from "./components/dashboard/status-config.js";
93
- export { runDuration, runStartDelay, runPhaseOrder } from "./components/dashboard/run-helpers.js";
93
+ export {
94
+ runDuration,
95
+ runStartDelay,
96
+ runPhaseOrder,
97
+ formatTriggeredByLabel,
98
+ runRetryLabel,
99
+ } from "./components/dashboard/run-helpers.js";
94
100
  export { humanizeCron, type HumanizedCron } from "./components/dashboard/cron-format.js";
95
101
  export type { DaemonState, DaemonStats } from "./components/dashboard/types.js";
96
102
 
@@ -3,6 +3,7 @@
3
3
 
4
4
  import type { LogSlice, LogEvent } from "./types.js";
5
5
  import { SvelteMap } from "svelte/reactivity";
6
+ import { visibleColumns } from "./ansi.js";
6
7
 
7
8
  /**
8
9
  * LogCache provides high-performance storage and pruning for log data.
@@ -17,6 +18,9 @@ export class LogCache {
17
18
  totalBytes = $state(0);
18
19
  finished = $state(false);
19
20
  firstAvailableLine = $state(0);
21
+ // Widest visible line seen so far, in monospace columns. Monotonic so the
22
+ // horizontal scroll surface never shrinks as windowed lines are pruned.
23
+ maxLineColumns = $state(0);
20
24
 
21
25
  get size() {
22
26
  return this.lines.size;
@@ -38,6 +42,7 @@ export class LogCache {
38
42
  const prev = this.lines.get(line);
39
43
  if (prev !== v) {
40
44
  this.lines.set(line, v);
45
+ this.maxLineColumns = Math.max(this.maxLineColumns, visibleColumns(v));
41
46
  touched = true;
42
47
  }
43
48
  }
@@ -77,6 +82,7 @@ export class LogCache {
77
82
  this.totalBytes = 0;
78
83
  this.finished = false;
79
84
  this.firstAvailableLine = 0;
85
+ this.maxLineColumns = 0;
80
86
  }
81
87
 
82
88
  isRangeComplete(start: number, end: number) {
@@ -2,7 +2,7 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
 
4
4
  import { describe, expect, it } from "vitest";
5
- import { ansiLineToHtml } from "./ansi.js";
5
+ import { ansiLineToHtml, visibleColumns } from "./ansi.js";
6
6
 
7
7
  describe("ansiLineToHtml", () => {
8
8
  it("passes plain text through unchanged", () => {
@@ -48,3 +48,20 @@ describe("ansiLineToHtml", () => {
48
48
  expect(ansiLineToHtml("plain")).toBe("plain");
49
49
  });
50
50
  });
51
+
52
+ describe("visibleColumns", () => {
53
+ it("counts plain characters", () => {
54
+ expect(visibleColumns("hello")).toBe(5);
55
+ expect(visibleColumns("")).toBe(0);
56
+ });
57
+
58
+ it("ignores ANSI escape sequences", () => {
59
+ expect(visibleColumns("\x1b[31merror\x1b[0m")).toBe(5);
60
+ expect(visibleColumns("\x1b[1;36minfo\x1b[0m done")).toBe(9);
61
+ });
62
+
63
+ it("charges tabs the full tab width", () => {
64
+ expect(visibleColumns("\t")).toBe(8);
65
+ expect(visibleColumns("ab\tcd")).toBe(12);
66
+ });
67
+ });
@@ -37,3 +37,20 @@ export function ansiLineToHtml(text: string): string {
37
37
  colors: ANSI_PALETTE,
38
38
  }).toHtml(text);
39
39
  }
40
+
41
+ // Matches CSI escape sequences (colour codes, cursor moves, etc.), which
42
+ // render to zero visible width. The ESC control char is intentional here.
43
+ // eslint-disable-next-line no-control-regex
44
+ const ANSI_ESCAPE = /\x1b\[[0-9;?]*[A-Za-z]/g;
45
+ const TAB_WIDTH = 8;
46
+
47
+ // Count the visible columns a log line occupies once ANSI escape sequences
48
+ // are stripped. Tabs are charged the full tab width — an over-estimate that
49
+ // only adds trailing slack to the horizontal scroll surface, never clips.
50
+ export function visibleColumns(text: string): number {
51
+ let columns = 0;
52
+ for (const ch of text.replace(ANSI_ESCAPE, "")) {
53
+ columns += ch === "\t" ? TAB_WIDTH : 1;
54
+ }
55
+ return columns;
56
+ }
@@ -50,6 +50,15 @@
50
50
  `${ESC}4;36munderlined cyan link${ESC}0m and ${ESC}4;33munderlined yellow${ESC}0m`,
51
51
  ];
52
52
 
53
+ const LONG_LINES = [
54
+ `${ESC}36m[info]${ESC}0m starting request trace`,
55
+ `${ESC}90m2026-06-05T03:00:00Z${ESC}0m GET /api/v1/tasks?include=runs,logs,schedule&limit=100&offset=0 → ${ESC}32m200${ESC}0m ${ESC}90m{"trace":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","upstream":"https://internal.example.com/very/long/path/segment/that/keeps/going/and/going","latency_ms":1287,"bytes":204812}${ESC}0m`,
56
+ `${ESC}31m[error]${ESC}0m panic: runtime error: index out of range [12] with length 8 — goroutine 42 [running]: main.process(0xc0000b4000, 0x1f4) /home/runner/work/app/app/internal/pipeline/process.go:317 +0x4a5`,
57
+ " short line in between",
58
+ `tab-separated\tcolumns\there\tshould\tstay\taligned\tand\textend\tfar\tpast\tthe\tviewport\tedge\tto\tprove\thorizontal\tscroll`,
59
+ `${ESC}32m[done]${ESC}0m ${"=".repeat(400)}|END`,
60
+ ];
61
+
53
62
  const STREAM_TICKS = [
54
63
  `${ESC}36m[info]${ESC}0m processed batch ${ESC}1m#42${ESC}0m ${ESC}90m(1,024 records)${ESC}0m`,
55
64
  `${ESC}32m✓${ESC}0m health check passed`,
@@ -73,3 +82,7 @@
73
82
  <Story name="Truncated" asChild>
74
83
  <LogConsoleDemo lines={SAMPLE_LOG.slice(6)} firstAvailableLine={1500} />
75
84
  </Story>
85
+
86
+ <Story name="Long Lines" asChild>
87
+ <LogConsoleDemo lines={LONG_LINES} />
88
+ </Story>