@opengeni/react 0.12.0 → 0.15.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 (94) hide show
  1. package/README.md +20 -14
  2. package/dist/chunk-TOJR776I.js +2280 -0
  3. package/dist/chunk-TOJR776I.js.map +1 -0
  4. package/dist/index.d.ts +1040 -363
  5. package/dist/index.js +10938 -6281
  6. package/dist/index.js.map +1 -1
  7. package/dist/{machines-BeZ3bD3t.d.ts → machines-BpdwuQcD.d.ts} +134 -14
  8. package/dist/machines.d.ts +1 -1
  9. package/dist/machines.js +23 -1
  10. package/package.json +20 -12
  11. package/src/approvals.ts +16 -4
  12. package/src/client.ts +41 -5
  13. package/src/commands/index.ts +1 -7
  14. package/src/commands/registry.ts +46 -12
  15. package/src/components/chat-composer.tsx +417 -69
  16. package/src/components/code-editor.tsx +28 -36
  17. package/src/components/command-palette.tsx +26 -5
  18. package/src/components/desktop-viewer.tsx +29 -16
  19. package/src/components/diff-view.tsx +27 -189
  20. package/src/components/enrollment-consent.tsx +28 -10
  21. package/src/components/enrollment-device-flow.tsx +8 -5
  22. package/src/components/file-browser.tsx +190 -56
  23. package/src/components/fleet-tile.tsx +13 -4
  24. package/src/components/machine-card.tsx +93 -15
  25. package/src/components/machine-dock-bar.tsx +20 -8
  26. package/src/components/machine-health-pill.tsx +68 -0
  27. package/src/components/machine-metrics.tsx +28 -19
  28. package/src/components/machine-status-pill.tsx +26 -5
  29. package/src/components/machines/health.ts +146 -0
  30. package/src/components/machines/machine-detail.tsx +220 -0
  31. package/src/components/machines/metric-history-chart.tsx +298 -0
  32. package/src/components/machines/metric-sparkline.tsx +76 -0
  33. package/src/components/machines/series.ts +113 -0
  34. package/src/components/machines-dashboard.tsx +21 -3
  35. package/src/components/markdown.tsx +39 -9
  36. package/src/components/message-timeline.tsx +633 -109
  37. package/src/components/pierre-diff.tsx +83 -15
  38. package/src/components/pierre-file.tsx +10 -9
  39. package/src/components/queue-surface.tsx +578 -0
  40. package/src/components/sandbox-files.tsx +193 -225
  41. package/src/components/sandbox-terminal.tsx +256 -88
  42. package/src/components/sandbox-workspace.tsx +957 -0
  43. package/src/components/session-status.tsx +22 -2
  44. package/src/components/workbench-changes.tsx +585 -0
  45. package/src/components/workspace-dock.tsx +213 -59
  46. package/src/hooks/internal.ts +5 -2
  47. package/src/hooks/use-available-models.ts +7 -2
  48. package/src/hooks/use-billing-usage.ts +4 -1
  49. package/src/hooks/use-codex-accounts.ts +74 -44
  50. package/src/hooks/use-composer.ts +414 -63
  51. package/src/hooks/use-environments.ts +92 -47
  52. package/src/hooks/use-file-attachments.ts +101 -55
  53. package/src/hooks/use-goal.ts +52 -16
  54. package/src/hooks/use-machine-chip.ts +134 -0
  55. package/src/hooks/use-machines.ts +34 -16
  56. package/src/hooks/use-packs.ts +24 -19
  57. package/src/hooks/use-relay-frame-stream.ts +5 -2
  58. package/src/hooks/use-rigs.ts +335 -0
  59. package/src/hooks/use-sandbox-files.ts +213 -39
  60. package/src/hooks/use-sandbox-git.ts +188 -11
  61. package/src/hooks/use-sandbox-terminal.ts +18 -14
  62. package/src/hooks/use-scheduled-tasks.ts +10 -2
  63. package/src/hooks/use-session-capabilities.ts +77 -20
  64. package/src/hooks/use-session-control.ts +50 -23
  65. package/src/hooks/use-session-events.ts +50 -21
  66. package/src/hooks/use-session-lineage.ts +128 -0
  67. package/src/hooks/use-session.ts +53 -30
  68. package/src/hooks/use-slash-commands.ts +6 -4
  69. package/src/hooks/use-turn-queue.ts +202 -147
  70. package/src/hooks/use-windowed-sections.ts +204 -0
  71. package/src/hooks/use-workspace-capture.ts +233 -0
  72. package/src/hooks/use-workspace-edit.ts +231 -0
  73. package/src/hooks/use-workspace-sessions.ts +48 -5
  74. package/src/hooks/use-workspaces.ts +18 -15
  75. package/src/index.ts +133 -23
  76. package/src/lib/format.ts +3 -3
  77. package/src/lib/xterm-renderer.ts +65 -0
  78. package/src/lib/xterm-theme.ts +224 -18
  79. package/src/machines.ts +29 -3
  80. package/src/provider.tsx +195 -6
  81. package/src/timeline/activity-rail.tsx +213 -54
  82. package/src/timeline/disclosure-context.tsx +12 -2
  83. package/src/timeline/index.ts +21 -2
  84. package/src/timeline/parsers.ts +87 -16
  85. package/src/timeline/projection.ts +412 -87
  86. package/src/timeline/shared.tsx +111 -24
  87. package/src/timeline/tool-diff.tsx +24 -20
  88. package/src/timeline/tool-renderers.tsx +98 -21
  89. package/src/timeline/turn-summary.tsx +76 -23
  90. package/src/timeline/types.ts +96 -12
  91. package/styles/index.css +22 -0
  92. package/styles/tokens.css +2 -2
  93. package/dist/chunk-5TIXRCSI.js +0 -1291
  94. package/dist/chunk-5TIXRCSI.js.map +0 -1
@@ -2,16 +2,32 @@ import type { SessionEvent, SessionStatus } from "@opengeni/sdk";
2
2
  import {
3
3
  ArrowDownIcon,
4
4
  ArrowRightIcon,
5
+ BotIcon,
6
+ CheckCircle2Icon,
5
7
  CheckIcon,
8
+ ChevronRightIcon,
9
+ PauseCircleIcon,
6
10
  PauseIcon,
7
11
  PencilLineIcon,
8
12
  PlayIcon,
13
+ RefreshCwIcon,
9
14
  TargetIcon,
15
+ Trash2Icon,
10
16
  TriangleAlertIcon,
17
+ XCircleIcon,
11
18
  } from "lucide-react";
12
19
  import type { ComponentType } from "react";
13
20
  import { AnimatePresence, motion } from "motion/react";
14
- import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from "react";
21
+ import { Collapsible } from "radix-ui";
22
+ import {
23
+ useCallback,
24
+ useEffect,
25
+ useLayoutEffect,
26
+ useMemo,
27
+ useRef,
28
+ useState,
29
+ type ReactNode,
30
+ } from "react";
15
31
  import { cn } from "../lib/cn";
16
32
  import { formatRelativeTime, truncate } from "../lib/format";
17
33
  import { Markdown } from "./markdown";
@@ -23,12 +39,14 @@ import {
23
39
  LightboxProvider,
24
40
  type ActivityItem,
25
41
  type AgentMessageItem,
42
+ type AuthNeededItem,
26
43
  type GoalItem,
27
44
  type NoticeItem,
28
45
  type TimelineGroup,
29
46
  type TimelineItem,
30
47
  type ToolRegistry,
31
48
  type UserMessageItem,
49
+ type WorkerCompletionItem,
32
50
  TurnSummary,
33
51
  } from "../timeline";
34
52
  import { SESSION_STATUS_META, StatusDot } from "./session-status";
@@ -42,9 +60,36 @@ export type MessageTimelineProps = {
42
60
  /** Current session status; drives the live "working" indicator. */
43
61
  status?: SessionStatus | null | undefined;
44
62
  /** Plug a markdown renderer for message bodies (e.g. streamdown). */
45
- renderMessageText?: ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode) | undefined;
63
+ renderMessageText?:
64
+ | ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode)
65
+ | undefined;
46
66
  /** Drill into a spawned worker session. */
47
67
  onOpenSession?: ((sessionId: string) => void) | undefined;
68
+ /**
69
+ * Deep-link a memory row (a `memory.saved` / `memory.corrected` step) to its
70
+ * record in the host's memory pane. Opt-in, exactly like `onReconnect`: the
71
+ * library draws no "View in memory" affordance without a handler — the memory
72
+ * row is then non-interactive rich content. This is the switch that makes the
73
+ * deep-link a first-party OpenGeni capability without other SDK consumers
74
+ * opting into it. The app supplies it (it owns routing to the memory pane).
75
+ */
76
+ onMemoryClick?: ((memoryId: string) => void) | undefined;
77
+ /**
78
+ * Start the reconnect flow when a tool needs its connection reauthorized. The
79
+ * app supplies this (it owns the SDK client + workspace): it typically kicks
80
+ * off `startConnectionOAuth` and redirects, or routes to credential entry.
81
+ * Rejecting surfaces a calm inline error on the card; the library never draws
82
+ * a Reconnect button without a handler to run it.
83
+ */
84
+ onReconnect?: ((item: AuthNeededItem) => void | Promise<void>) | undefined;
85
+ /**
86
+ * Resolve a provider domain (from a reconnect card) to a logo URL the host
87
+ * serves itself — the app maps it through its catalog + `catalogAssetUrl`.
88
+ * Return null/undefined to fall back to a calm monogram. The library never
89
+ * fetches an off-origin favicon (CSP + privacy); an unresolved logo is a
90
+ * monogram, not an external image.
91
+ */
92
+ resolveProviderLogo?: ((providerDomain: string) => string | null | undefined) | undefined;
48
93
  /**
49
94
  * The tool-renderer registry that resolves how each tool call is drawn.
50
95
  * Defaults to {@link defaultToolRegistry}; pass a registry from
@@ -75,6 +120,9 @@ export function MessageTimeline({
75
120
  status,
76
121
  renderMessageText,
77
122
  onOpenSession,
123
+ onMemoryClick,
124
+ onReconnect,
125
+ resolveProviderLogo,
78
126
  toolRegistry = defaultToolRegistry,
79
127
  autoFollow = true,
80
128
  hasOlder = false,
@@ -120,11 +168,16 @@ export function MessageTimeline({
120
168
  // viewport expands or collapses (e.g. a turn folds when it settles).
121
169
  const anchorRef = useRef<{ el: Element; top: number } | null>(null);
122
170
  const lastItem = resolvedItems[resolvedItems.length - 1];
123
- const streaming = lastItem !== undefined && (lastItem.kind === "agent-message" || lastItem.kind === "reasoning") && lastItem.streaming;
171
+ const streaming =
172
+ lastItem !== undefined &&
173
+ (lastItem.kind === "agent-message" || lastItem.kind === "reasoning") &&
174
+ lastItem.streaming;
124
175
  const working = status === "running" && !streaming;
125
176
  // Bulk paints (the initial tail window, a prepended older window — detected
126
177
  // by the first group key changing) must not run per-row entrance animations.
127
- const firstKeyChangedForBulk = previousBulkFirstKeyRef.current !== undefined && previousBulkFirstKeyRef.current !== firstGroupKey;
178
+ const firstKeyChangedForBulk =
179
+ previousBulkFirstKeyRef.current !== undefined &&
180
+ previousBulkFirstKeyRef.current !== firstGroupKey;
128
181
  const bulkRender = groups.length > 0 && (bulkActive || firstKeyChangedForBulk);
129
182
 
130
183
  // Snapshot the topmost visible element and where it sits in the viewport, so a
@@ -192,14 +245,24 @@ export function MessageTimeline({
192
245
  useEffect(() => {
193
246
  const root = scrollRef.current;
194
247
  const target = topSentinelRef.current;
195
- if (!root || !target || !hasOlder || loadingOlder || !onLoadOlder || typeof IntersectionObserver === "undefined") {
248
+ if (
249
+ !root ||
250
+ !target ||
251
+ !hasOlder ||
252
+ loadingOlder ||
253
+ !onLoadOlder ||
254
+ typeof IntersectionObserver === "undefined"
255
+ ) {
196
256
  return;
197
257
  }
198
- const observer = new IntersectionObserver((entries) => {
199
- if (entries.some((entry) => entry.isIntersecting)) {
200
- onLoadOlder();
201
- }
202
- }, { root, rootMargin: "1600px 0px 0px 0px" });
258
+ const observer = new IntersectionObserver(
259
+ (entries) => {
260
+ if (entries.some((entry) => entry.isIntersecting)) {
261
+ onLoadOlder();
262
+ }
263
+ },
264
+ { root, rootMargin: "1600px 0px 0px 0px" },
265
+ );
203
266
  observer.observe(target);
204
267
  return () => observer.disconnect();
205
268
  }, [hasOlder, loadingOlder, onLoadOlder, firstGroupKey]);
@@ -259,73 +322,86 @@ export function MessageTimeline({
259
322
 
260
323
  return (
261
324
  <LightboxProvider>
262
- <EntranceAnimationProvider value={!bulkRender}>
263
- <div className={cn("og-root relative flex min-h-0 flex-col", className)}>
264
- {/* overflow-anchor off: the browser's native scroll anchoring would fight
325
+ <EntranceAnimationProvider value={!bulkRender}>
326
+ <div className={cn("og-root relative flex min-h-0 flex-col", className)}>
327
+ {/* overflow-anchor off: the browser's native scroll anchoring would fight
265
328
  the ResizeObserver corrections above — one authority only. */}
266
- <div
267
- ref={scrollRef}
268
- onScroll={onScroll}
269
- style={groups.length > 0 && !revealed ? { visibility: "hidden" } : undefined}
270
- className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-6 [overflow-anchor:none] sm:px-6"
271
- >
272
- <div className="mx-auto flex w-full max-w-3xl flex-col gap-5">
273
- {groups.length === 0 && !working
274
- ? (emptyState ?? <p className="py-10 text-center text-sm text-og-fg-subtle">No activity yet.</p>)
275
- : null}
276
- {hasOlder ? <div ref={topSentinelRef} data-og-top-sentinel="" data-og-timeline-chrome="" aria-hidden="true" className="h-px w-full shrink-0" /> : null}
277
- {loadingOlder ? (
278
- <div data-og-timeline-chrome="" className="flex items-center gap-2 text-sm">
279
- <span className="og-shimmer-text font-medium">Loading earlier activity…</span>
280
- </div>
281
- ) : null}
282
- {groups.map((group, index) => (
283
- <TimelineGroupView
284
- key={timelineGroupKey(group)}
285
- group={group}
286
- renderMessageText={renderMessageText}
287
- onOpenSession={onOpenSession}
288
- toolRegistry={toolRegistry}
289
- foldLiveCluster={isAgentProgress(groups[index + 1])}
290
- />
291
- ))}
292
- {working ? (
293
- <div className="flex items-center gap-2 text-sm">
294
- <span className="og-shimmer-text font-medium">Working…</span>
329
+ <div
330
+ ref={scrollRef}
331
+ onScroll={onScroll}
332
+ style={groups.length > 0 && !revealed ? { visibility: "hidden" } : undefined}
333
+ className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-6 [overflow-anchor:none] sm:px-6"
334
+ >
335
+ <div className="mx-auto flex w-full max-w-3xl flex-col gap-5">
336
+ {groups.length === 0 && !working
337
+ ? (emptyState ?? (
338
+ <p className="py-10 text-center text-sm text-og-fg-subtle">No activity yet.</p>
339
+ ))
340
+ : null}
341
+ {hasOlder ? (
342
+ <div
343
+ ref={topSentinelRef}
344
+ data-og-top-sentinel=""
345
+ data-og-timeline-chrome=""
346
+ aria-hidden="true"
347
+ className="h-px w-full shrink-0"
348
+ />
349
+ ) : null}
350
+ {loadingOlder ? (
351
+ <div data-og-timeline-chrome="" className="flex items-center gap-2 text-sm">
352
+ <span className="og-shimmer-text font-medium">Loading earlier activity…</span>
353
+ </div>
354
+ ) : null}
355
+ {groups.map((group, index) => (
356
+ <TimelineGroupView
357
+ key={timelineGroupKey(group)}
358
+ group={group}
359
+ renderMessageText={renderMessageText}
360
+ onOpenSession={onOpenSession}
361
+ onMemoryClick={onMemoryClick}
362
+ onReconnect={onReconnect}
363
+ resolveProviderLogo={resolveProviderLogo}
364
+ toolRegistry={toolRegistry}
365
+ foldLiveCluster={isAgentProgress(groups[index + 1])}
366
+ />
367
+ ))}
368
+ {working ? (
369
+ <div className="flex items-center gap-2 text-sm">
370
+ <span className="og-shimmer-text font-medium">Working…</span>
371
+ </div>
372
+ ) : null}
295
373
  </div>
296
- ) : null}
374
+ </div>
375
+ <AnimatePresence>
376
+ {!pinned && autoFollow ? (
377
+ <motion.button
378
+ type="button"
379
+ initial={{ opacity: 0, y: 8 }}
380
+ animate={{ opacity: 1, y: 0 }}
381
+ exit={{ opacity: 0, y: 8 }}
382
+ transition={{ duration: 0.15, ease: "easeOut" }}
383
+ onClick={() => {
384
+ const node = scrollRef.current;
385
+ if (node) {
386
+ node.scrollTo({ top: node.scrollHeight, behavior: "smooth" });
387
+ }
388
+ pinnedRef.current = true;
389
+ setPinned(true);
390
+ }}
391
+ className={cn(
392
+ "absolute bottom-4 left-1/2 -translate-x-1/2",
393
+ "inline-flex items-center gap-1.5 rounded-full border border-og-border bg-og-surface-3/90 px-3 py-1.5",
394
+ "text-xs font-medium text-og-fg shadow-og-md backdrop-blur",
395
+ "hover:border-og-border-strong",
396
+ )}
397
+ >
398
+ <ArrowDownIcon className="size-3.5" />
399
+ Jump to latest
400
+ </motion.button>
401
+ ) : null}
402
+ </AnimatePresence>
297
403
  </div>
298
- </div>
299
- <AnimatePresence>
300
- {!pinned && autoFollow ? (
301
- <motion.button
302
- type="button"
303
- initial={{ opacity: 0, y: 8 }}
304
- animate={{ opacity: 1, y: 0 }}
305
- exit={{ opacity: 0, y: 8 }}
306
- transition={{ duration: 0.15, ease: "easeOut" }}
307
- onClick={() => {
308
- const node = scrollRef.current;
309
- if (node) {
310
- node.scrollTo({ top: node.scrollHeight, behavior: "smooth" });
311
- }
312
- pinnedRef.current = true;
313
- setPinned(true);
314
- }}
315
- className={cn(
316
- "absolute bottom-4 left-1/2 -translate-x-1/2",
317
- "inline-flex items-center gap-1.5 rounded-full border border-og-border bg-og-surface-3/90 px-3 py-1.5",
318
- "text-xs font-medium text-og-fg shadow-og-md backdrop-blur",
319
- "hover:border-og-border-strong",
320
- )}
321
- >
322
- <ArrowDownIcon className="size-3.5" />
323
- Jump to latest
324
- </motion.button>
325
- ) : null}
326
- </AnimatePresence>
327
- </div>
328
- </EntranceAnimationProvider>
404
+ </EntranceAnimationProvider>
329
405
  </LightboxProvider>
330
406
  );
331
407
  }
@@ -349,13 +425,21 @@ function TimelineGroupView({
349
425
  group,
350
426
  renderMessageText,
351
427
  onOpenSession,
428
+ onMemoryClick,
429
+ onReconnect,
430
+ resolveProviderLogo,
352
431
  toolRegistry,
353
432
  insideTurn = false,
354
433
  foldLiveCluster = false,
355
434
  }: {
356
435
  group: TimelineGroup;
357
- renderMessageText?: ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode) | undefined;
436
+ renderMessageText?:
437
+ | ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode)
438
+ | undefined;
358
439
  onOpenSession?: ((sessionId: string) => void) | undefined;
440
+ onMemoryClick?: ((memoryId: string) => void) | undefined;
441
+ onReconnect?: ((item: AuthNeededItem) => void | Promise<void>) | undefined;
442
+ resolveProviderLogo?: ((providerDomain: string) => string | null | undefined) | undefined;
359
443
  toolRegistry: ToolRegistry;
360
444
  /** A completed cluster of a still-RUNNING turn (not the live tail) folds
361
445
  behind a neutral chip — the one place activity without an outcome still
@@ -374,41 +458,78 @@ function TimelineGroupView({
374
458
  outcome={group.outcome}
375
459
  failureText={insideTurn ? undefined : group.failureText}
376
460
  defaultOpen={!insideTurn && group.outcome === "failed" ? true : undefined}
461
+ bare={insideTurn}
377
462
  >
378
- <ActivityRail items={group.items} onOpenSession={onOpenSession} toolRegistry={toolRegistry} />
463
+ <ActivityRail
464
+ items={group.items}
465
+ onOpenSession={onOpenSession}
466
+ onMemoryClick={onMemoryClick}
467
+ toolRegistry={toolRegistry}
468
+ bare={insideTurn}
469
+ />
379
470
  </TurnSummary>
380
471
  ) : (
381
- <ActivityRail items={group.items} onOpenSession={onOpenSession} toolRegistry={toolRegistry} />
472
+ // A nested live cluster hangs on the turn's rail (bare); a top-level one
473
+ // owns its own rail.
474
+ <ActivityRail
475
+ items={group.items}
476
+ onOpenSession={onOpenSession}
477
+ onMemoryClick={onMemoryClick}
478
+ toolRegistry={toolRegistry}
479
+ bare={insideTurn}
480
+ />
382
481
  );
383
482
  case "turn": {
384
483
  const activityItems = flattenActivityItems(group.groups);
484
+ const body = group.groups.map((child) => (
485
+ <TimelineGroupView
486
+ key={timelineGroupKey(child)}
487
+ group={child}
488
+ renderMessageText={renderMessageText}
489
+ onOpenSession={onOpenSession}
490
+ onMemoryClick={onMemoryClick}
491
+ onReconnect={onReconnect}
492
+ resolveProviderLogo={resolveProviderLogo}
493
+ toolRegistry={toolRegistry}
494
+ insideTurn
495
+ />
496
+ ));
385
497
  return (
386
498
  <TurnSummary
387
499
  items={activityItems}
388
500
  outcome={group.outcome}
389
- failureText={group.failureText}
501
+ // One loud error at the top; nested sub-turn chips stay calm — the
502
+ // parent already renders the failure reason, so clear it here exactly
503
+ // as the nested activity-cluster case does.
504
+ failureText={insideTurn ? undefined : group.failureText}
390
505
  durationMs={durationBetween(group.startedAt, group.endedAt)}
391
- defaultOpen={group.outcome === "failed" ? true : undefined}
506
+ defaultOpen={!insideTurn && group.outcome === "failed" ? true : undefined}
507
+ bare={insideTurn}
392
508
  >
393
- {/* The body wears the timeline's rail language (matching ActivityRail)
394
- so nested chips read as contained by the turn, not as siblings. */}
395
- <div className="flex flex-col gap-4 border-l-2 border-og-border pl-3 sm:pl-4">
396
- {group.groups.map((child) => (
397
- <TimelineGroupView
398
- key={timelineGroupKey(child)}
399
- group={child}
400
- renderMessageText={renderMessageText}
401
- onOpenSession={onOpenSession}
402
- toolRegistry={toolRegistry}
403
- insideTurn
404
- />
405
- ))}
406
- </div>
509
+ {insideTurn ? (
510
+ // A nested turn is already on an ancestor rail its body just stacks
511
+ // flush (the bare-node body already indents it), so no second rule.
512
+ <div className="flex flex-col gap-4">{body}</div>
513
+ ) : (
514
+ // The top-level turn draws THE rail: one thin continuous rule that
515
+ // every nested node and step hangs off of.
516
+ <div className="flex flex-col gap-4 border-l-2 border-og-border pl-3 sm:pl-4">
517
+ {body}
518
+ </div>
519
+ )}
407
520
  </TurnSummary>
408
521
  );
409
522
  }
410
523
  case "item":
411
- return <TimelineRow item={group.item} renderMessageText={renderMessageText} />;
524
+ return (
525
+ <TimelineRow
526
+ item={group.item}
527
+ renderMessageText={renderMessageText}
528
+ onReconnect={onReconnect}
529
+ resolveProviderLogo={resolveProviderLogo}
530
+ onOpenSession={onOpenSession}
531
+ />
532
+ );
412
533
  }
413
534
  }
414
535
 
@@ -432,7 +553,11 @@ function isAgentProgress(next: TimelineGroup | undefined): boolean {
432
553
  if (!next) {
433
554
  return false;
434
555
  }
435
- return next.kind === "activity" || next.kind === "turn" || (next.kind === "item" && next.item.kind === "agent-message");
556
+ return (
557
+ next.kind === "activity" ||
558
+ next.kind === "turn" ||
559
+ (next.kind === "item" && next.item.kind === "agent-message")
560
+ );
436
561
  }
437
562
 
438
563
  /** No item still running or streaming — the only state safe to fold live.
@@ -443,6 +568,10 @@ function clusterIsSettled(group: Extract<TimelineGroup, { kind: "activity" }>):
443
568
  if (item.kind === "reasoning") {
444
569
  return !item.streaming;
445
570
  }
571
+ // A memory write is a discrete, already-settled event — it has no running state.
572
+ if (item.kind === "memory") {
573
+ return true;
574
+ }
446
575
  return item.status !== "running";
447
576
  });
448
577
  }
@@ -478,21 +607,39 @@ function durationBetween(startedAt: string, endedAt: string): number | undefined
478
607
  export function TimelineRow({
479
608
  item,
480
609
  renderMessageText,
610
+ onReconnect,
611
+ resolveProviderLogo,
612
+ onOpenSession,
481
613
  }: {
482
614
  item: TimelineItem;
483
- renderMessageText?: ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode) | undefined;
615
+ renderMessageText?:
616
+ | ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode)
617
+ | undefined;
618
+ onReconnect?: ((item: AuthNeededItem) => void | Promise<void>) | undefined;
619
+ resolveProviderLogo?: ((providerDomain: string) => string | null | undefined) | undefined;
620
+ onOpenSession?: ((sessionId: string) => void) | undefined;
484
621
  }) {
485
622
  switch (item.kind) {
486
623
  case "user-message":
487
624
  return <UserMessageRow item={item} renderMessageText={renderMessageText} />;
488
625
  case "agent-message":
489
626
  return <AgentMessageRow item={item} renderMessageText={renderMessageText} />;
627
+ case "worker-completion":
628
+ return <WorkerCompletionRow item={item} onOpenSession={onOpenSession} />;
490
629
  case "session-status":
491
630
  return <SessionStatusRow item={item} />;
492
631
  case "goal":
493
632
  return <GoalRow item={item} />;
494
633
  case "notice":
495
634
  return <NoticeRow item={item} />;
635
+ case "auth-needed":
636
+ return (
637
+ <AuthNeededRow
638
+ item={item}
639
+ onReconnect={onReconnect}
640
+ resolveProviderLogo={resolveProviderLogo}
641
+ />
642
+ );
496
643
  default:
497
644
  return null;
498
645
  }
@@ -503,15 +650,20 @@ function UserMessageRow({
503
650
  renderMessageText,
504
651
  }: {
505
652
  item: UserMessageItem;
506
- renderMessageText?: ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode) | undefined;
653
+ renderMessageText?:
654
+ | ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode)
655
+ | undefined;
507
656
  }) {
508
657
  const enter = useEntranceAnimation();
509
658
  return (
510
659
  <div className={cn(enter && "animate-og-enter", "flex justify-end")}>
511
660
  <div className="flex max-w-[85%] min-w-0 flex-col items-end gap-1">
512
- {item.pending ? <span className="px-1 text-og-xs text-og-fg-subtle">queued</span> : null}
513
661
  <div className="w-fit max-w-full min-w-0 rounded-og-lg rounded-br-og-xs border border-og-border bg-og-surface-2 px-4 py-2.5 text-og-md leading-6 text-og-fg">
514
- {renderMessageText ? renderMessageText(item.text, item) : <Markdown>{item.text}</Markdown>}
662
+ {renderMessageText ? (
663
+ renderMessageText(item.text, item)
664
+ ) : (
665
+ <Markdown>{item.text}</Markdown>
666
+ )}
515
667
  </div>
516
668
  </div>
517
669
  </div>
@@ -523,11 +675,16 @@ function AgentMessageRow({
523
675
  renderMessageText,
524
676
  }: {
525
677
  item: AgentMessageItem;
526
- renderMessageText?: ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode) | undefined;
678
+ renderMessageText?:
679
+ | ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode)
680
+ | undefined;
527
681
  }) {
528
682
  const enter = useEntranceAnimation();
529
683
  const caret = item.streaming ? (
530
- <span className="ml-0.5 inline-block h-[1.1em] w-[2px] translate-y-[3px] animate-og-blink rounded-full bg-og-accent" aria-hidden />
684
+ <span
685
+ className="ml-0.5 inline-block h-[1.1em] w-[2px] translate-y-[3px] animate-og-blink rounded-full bg-og-accent"
686
+ aria-hidden
687
+ />
531
688
  ) : null;
532
689
  return (
533
690
  <div className={cn(enter && "animate-og-enter", "min-w-0 text-og-md leading-7 text-og-fg")}>
@@ -549,11 +706,173 @@ function AgentMessageRow({
549
706
  );
550
707
  }
551
708
 
709
+ /**
710
+ * A worker session reporting back to its manager. The child's completion arrives
711
+ * as a `user.message` carrying a `childCompletion` payload (the raw message text
712
+ * used to render as an "ugly" user bubble); it projects to a `worker-completion`
713
+ * item and draws here as a quietly-confident card — an inbound result, not
714
+ * something the human said. One glyph + one line carry the outcome; the worker's
715
+ * full report, evidence, and any paused reason live behind a collapsed
716
+ * disclosure, and a "View session" affordance deep-links into the child.
717
+ *
718
+ * Color follows the timeline's restraint: green only for a completed goal, the
719
+ * waiting hue only for a paused one, red only for a failed child — everything
720
+ * else is a neutral inbound card.
721
+ */
722
+ type WorkerCompletionMeta = {
723
+ label: string;
724
+ icon: ComponentType<{ className?: string }>;
725
+ iconClass: string;
726
+ /** The 2px left-accent hue — color spent only on the exceptional outcomes. */
727
+ accentClass: string;
728
+ };
729
+
730
+ function workerCompletionMeta(item: WorkerCompletionItem): WorkerCompletionMeta {
731
+ if (item.childStatus === "failed") {
732
+ return {
733
+ label: "Worker failed",
734
+ icon: XCircleIcon,
735
+ iconClass: "text-og-status-failed",
736
+ accentClass: "border-og-status-failed/60",
737
+ };
738
+ }
739
+ if (item.goalStatus === "paused") {
740
+ return {
741
+ label: "Worker paused",
742
+ icon: PauseCircleIcon,
743
+ iconClass: "text-og-status-waiting",
744
+ accentClass: "border-og-status-waiting/50",
745
+ };
746
+ }
747
+ if (item.goalStatus === "completed") {
748
+ return {
749
+ label: "Worker completed",
750
+ icon: CheckCircle2Icon,
751
+ iconClass: "text-og-status-idle",
752
+ accentClass: "border-og-status-idle/45",
753
+ };
754
+ }
755
+ return {
756
+ label: "Worker reported back",
757
+ icon: BotIcon,
758
+ iconClass: "text-og-accent",
759
+ accentClass: "border-og-border-strong",
760
+ };
761
+ }
762
+
763
+ function WorkerCompletionRow({
764
+ item,
765
+ onOpenSession,
766
+ }: {
767
+ item: WorkerCompletionItem;
768
+ onOpenSession?: ((sessionId: string) => void) | undefined;
769
+ }) {
770
+ const enter = useEntranceAnimation();
771
+ const [open, setOpen] = useState(false);
772
+ const meta = workerCompletionMeta(item);
773
+ const Icon = meta.icon;
774
+ // The worker's own report is the substance behind the fold; evidence and any
775
+ // paused reason sit alongside it as quieter, labelled context.
776
+ // "Paused because" only when the outcome actually IS a pause — completion
777
+ // payloads can carry a leftover pausedReason/rationale from earlier in the
778
+ // worker's life, and a "Worker completed" card must not show a pause section.
779
+ const showPausedReason =
780
+ item.childStatus !== "failed" &&
781
+ item.goalStatus === "paused" &&
782
+ Boolean(item.pausedReason?.trim());
783
+ const details: { label: string; value: string; muted?: boolean }[] = [
784
+ ...(item.text.trim() ? [{ label: "Report", value: item.text.trim() }] : []),
785
+ ...(item.evidence?.trim()
786
+ ? [{ label: "Evidence", value: item.evidence.trim(), muted: true }]
787
+ : []),
788
+ ...(showPausedReason
789
+ ? [{ label: "Paused because", value: item.pausedReason!.trim(), muted: true }]
790
+ : []),
791
+ ];
792
+ const hasDetails = details.length > 0;
793
+ return (
794
+ <div className={cn(enter && "animate-og-enter", "min-w-0")}>
795
+ {/* An inbound result, not a bubble: a 2px left accent carries the outcome —
796
+ no full frame, no surface fill. The report unfolds flush beneath. */}
797
+ <div className={cn("flex flex-col gap-2 border-l-2 pl-3", meta.accentClass)}>
798
+ <div className="flex items-start gap-2.5">
799
+ <span className={cn("mt-0.5 shrink-0", meta.iconClass)}>
800
+ <Icon className="size-4" />
801
+ </span>
802
+ <div className="min-w-0 flex-1">
803
+ <p className="text-og-base leading-5 text-og-fg">
804
+ <span className="font-medium">{meta.label}</span>
805
+ {item.goalText ? (
806
+ <span className="text-og-fg-muted"> · {truncate(item.goalText, 90)}</span>
807
+ ) : null}
808
+ </p>
809
+ </div>
810
+ {item.childSessionId && onOpenSession ? (
811
+ <button
812
+ type="button"
813
+ onClick={() => onOpenSession(item.childSessionId)}
814
+ className={cn(
815
+ "-my-0.5 -mr-1 inline-flex shrink-0 items-center gap-1 rounded-og-sm px-2 py-1 text-og-sm font-medium text-og-fg-muted pointer-coarse:py-2",
816
+ "outline-none transition-colors duration-150 hover:bg-og-surface-2 hover:text-og-fg",
817
+ "focus-visible:ring-2 focus-visible:ring-og-accent",
818
+ )}
819
+ >
820
+ View session
821
+ <ArrowRightIcon className="size-3.5" />
822
+ </button>
823
+ ) : null}
824
+ </div>
825
+ {hasDetails ? (
826
+ <Collapsible.Root open={open} onOpenChange={setOpen}>
827
+ <Collapsible.Trigger asChild>
828
+ <button
829
+ type="button"
830
+ className={cn(
831
+ "group/wc -mx-1 inline-flex w-fit items-center gap-1 rounded-og-sm px-1 py-0.5 text-og-xs font-medium text-og-fg-subtle",
832
+ "outline-none transition-colors duration-150 hover:text-og-fg-muted focus-visible:ring-2 focus-visible:ring-og-accent",
833
+ )}
834
+ >
835
+ <ChevronRightIcon className="size-3 transition-transform duration-150 ease-og-in-out group-data-[state=open]/wc:rotate-90" />
836
+ {open ? "Hide details" : "Show details"}
837
+ </button>
838
+ </Collapsible.Trigger>
839
+ <Collapsible.Content className="overflow-hidden data-[state=closed]:animate-og-collapse data-[state=open]:animate-og-expand">
840
+ <div className="ml-1 mt-1.5 flex flex-col gap-2.5">
841
+ {details.map((detail) => (
842
+ <div key={detail.label} className="min-w-0">
843
+ <p className="mb-1 text-og-xs font-medium uppercase tracking-[0.08em] text-og-fg-subtle">
844
+ {detail.label}
845
+ </p>
846
+ <p
847
+ className={cn(
848
+ "whitespace-pre-wrap break-words text-og-sm leading-6",
849
+ detail.muted ? "text-og-fg-subtle" : "text-og-fg-muted",
850
+ )}
851
+ >
852
+ {detail.value}
853
+ </p>
854
+ </div>
855
+ ))}
856
+ </div>
857
+ </Collapsible.Content>
858
+ </Collapsible.Root>
859
+ ) : null}
860
+ </div>
861
+ </div>
862
+ );
863
+ }
864
+
552
865
  function SessionStatusRow({ item }: { item: { status: SessionStatus; occurredAt: string } }) {
553
866
  const enter = useEntranceAnimation();
554
867
  const meta = SESSION_STATUS_META[item.status];
555
868
  return (
556
- <div className={cn(enter && "animate-og-enter", "flex items-center gap-3 text-og-xs text-og-fg-subtle")} role="status">
869
+ <div
870
+ className={cn(
871
+ enter && "animate-og-enter",
872
+ "flex items-center gap-3 text-og-xs text-og-fg-subtle",
873
+ )}
874
+ role="status"
875
+ >
557
876
  <span className="h-px flex-1 bg-og-border" />
558
877
  <span className="inline-flex items-center gap-1.5">
559
878
  <StatusDot status={item.status} className="size-1" />
@@ -586,11 +905,24 @@ type GoalMeta = { label: string; pill: string; icon: ComponentType<{ className?:
586
905
  const NEUTRAL_PILL = "border-og-border bg-og-surface-1 text-og-fg-muted";
587
906
 
588
907
  const GOAL_META: Record<GoalItem["action"], GoalMeta> = {
589
- set: { label: "Goal set", pill: "border-og-accent/30 bg-og-accent/10 text-og-accent", icon: TargetIcon },
908
+ set: {
909
+ label: "Goal set",
910
+ pill: "border-og-accent/30 bg-og-accent/10 text-og-accent",
911
+ icon: TargetIcon,
912
+ },
590
913
  updated: { label: "Goal updated", pill: NEUTRAL_PILL, icon: PencilLineIcon },
591
- completed: { label: "Goal completed", pill: "border-og-status-idle/30 bg-og-status-idle/10 text-og-status-idle", icon: CheckIcon },
592
- paused: { label: "Goal paused", pill: "border-og-status-waiting/35 bg-og-status-waiting/10 text-og-status-waiting", icon: PauseIcon },
914
+ completed: {
915
+ label: "Goal completed",
916
+ pill: "border-og-status-idle/30 bg-og-status-idle/10 text-og-status-idle",
917
+ icon: CheckIcon,
918
+ },
919
+ paused: {
920
+ label: "Goal paused",
921
+ pill: "border-og-status-waiting/35 bg-og-status-waiting/10 text-og-status-waiting",
922
+ icon: PauseIcon,
923
+ },
593
924
  resumed: { label: "Goal resumed", pill: NEUTRAL_PILL, icon: PlayIcon },
925
+ cleared: { label: "Goal cleared", pill: NEUTRAL_PILL, icon: Trash2Icon },
594
926
  continuation: { label: "Continuing toward the goal", pill: NEUTRAL_PILL, icon: ArrowRightIcon },
595
927
  };
596
928
 
@@ -604,7 +936,12 @@ function GoalRow({ item }: { item: GoalItem }) {
604
936
  const { label, pill, icon: Icon } = GOAL_META[item.action];
605
937
  return (
606
938
  <div className={cn(enter && "animate-og-enter", "flex justify-center")}>
607
- <span className={cn("inline-flex max-w-full items-center gap-1.5 rounded-full border px-3 py-1 text-og-sm", pill)}>
939
+ <span
940
+ className={cn(
941
+ "inline-flex max-w-full items-center gap-1.5 rounded-full border px-3 py-1 text-og-sm",
942
+ pill,
943
+ )}
944
+ >
608
945
  <Icon className="size-3.5 shrink-0" />
609
946
  <span className="truncate">
610
947
  {label}
@@ -624,9 +961,28 @@ function NoticeRow({ item }: { item: NoticeItem }) {
624
961
  ? "border-og-status-waiting/35 bg-og-status-waiting/10 text-og-status-waiting"
625
962
  : "border-og-border bg-og-surface-1 text-og-fg-muted";
626
963
  return (
627
- <div className={cn(enter && "animate-og-enter", "flex items-start gap-2.5 rounded-og-md border px-3.5 py-2.5 text-sm", tone)} role="status">
628
- <TriangleAlertIcon className={cn("mt-0.5 size-4 shrink-0", item.tone === "cancelled" && "opacity-60")} />
629
- <span className="min-w-0 flex-1 whitespace-pre-wrap break-words">{item.text}</span>
964
+ <div
965
+ className={cn(
966
+ enter && "animate-og-enter",
967
+ "flex items-start gap-2.5 rounded-og-md border px-3.5 py-2.5 text-sm",
968
+ tone,
969
+ )}
970
+ role="status"
971
+ >
972
+ <TriangleAlertIcon
973
+ className={cn("mt-0.5 size-4 shrink-0", item.tone === "cancelled" && "opacity-60")}
974
+ />
975
+ <div className="min-w-0 flex-1">
976
+ <span className="whitespace-pre-wrap break-words">{item.text}</span>
977
+ {item.details ? (
978
+ <details className="mt-2 text-xs">
979
+ <summary className="cursor-pointer font-medium">{item.details.label}</summary>
980
+ <pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-all rounded-og-sm bg-black/5 p-2 font-mono dark:bg-white/5">
981
+ {JSON.stringify(item.details.value, null, 2)}
982
+ </pre>
983
+ </details>
984
+ ) : null}
985
+ </div>
630
986
  {item.action ? (
631
987
  <a
632
988
  className="shrink-0 rounded-og-sm border border-current/25 px-2 py-1 text-xs font-medium hover:bg-current/10"
@@ -640,3 +996,171 @@ function NoticeRow({ item }: { item: NoticeItem }) {
640
996
  </div>
641
997
  );
642
998
  }
999
+
1000
+ /**
1001
+ * The inline reconnect card: a lapsed connection surfaces as a calm, tappable
1002
+ * affordance — provider logo, one human line, one primary Reconnect button —
1003
+ * instead of a raw "linear.app needs to be reconnected." string. The `reason`
1004
+ * only shapes the helper copy; no domain or enum code is shown as a label.
1005
+ * `onReconnect` (from the app, which owns the SDK client) runs the flow; without
1006
+ * it, a pre-minted authorization link is offered, or the card stays informative.
1007
+ */
1008
+ function AuthNeededRow({
1009
+ item,
1010
+ onReconnect,
1011
+ resolveProviderLogo,
1012
+ }: {
1013
+ item: AuthNeededItem;
1014
+ onReconnect?: ((item: AuthNeededItem) => void | Promise<void>) | undefined;
1015
+ resolveProviderLogo?: ((providerDomain: string) => string | null | undefined) | undefined;
1016
+ }) {
1017
+ const enter = useEntranceAnimation();
1018
+ const [busy, setBusy] = useState(false);
1019
+ const [failed, setFailed] = useState(false);
1020
+ const provider = providerLabel(item.providerDomain);
1021
+
1022
+ const start = async () => {
1023
+ if (!onReconnect || busy) {
1024
+ return;
1025
+ }
1026
+ setBusy(true);
1027
+ setFailed(false);
1028
+ try {
1029
+ // On success the app redirects to consent (or routes to credential entry),
1030
+ // so this row unmounts; a resolve without navigation just relaxes the button.
1031
+ await onReconnect(item);
1032
+ setBusy(false);
1033
+ } catch {
1034
+ setFailed(true);
1035
+ setBusy(false);
1036
+ }
1037
+ };
1038
+
1039
+ return (
1040
+ <div className={cn(enter && "animate-og-enter", "flex flex-col gap-2")} role="status">
1041
+ <div className="flex flex-col gap-3 rounded-og-lg border border-og-border bg-og-surface-1 px-3.5 py-3 sm:flex-row sm:items-center">
1042
+ <div className="flex min-w-0 flex-1 items-center gap-3">
1043
+ <AuthProviderLogo
1044
+ src={resolveProviderLogo?.(item.providerDomain) ?? null}
1045
+ label={provider}
1046
+ />
1047
+ <div className="min-w-0">
1048
+ <p className="truncate text-og-md font-medium text-og-fg">Reconnect {provider}</p>
1049
+ <p className="truncate text-og-sm text-og-fg-subtle">{authReasonLine(item.reason)}</p>
1050
+ </div>
1051
+ </div>
1052
+ {onReconnect ? (
1053
+ <button
1054
+ type="button"
1055
+ onClick={() => void start()}
1056
+ disabled={busy}
1057
+ className={cn(
1058
+ "inline-flex w-full shrink-0 items-center justify-center gap-1.5 rounded-og-md bg-og-accent px-3 py-1.5 text-sm font-medium text-og-accent-fg sm:w-auto",
1059
+ "transition-colors hover:bg-og-accent-strong disabled:opacity-70 pointer-coarse:min-h-9",
1060
+ )}
1061
+ >
1062
+ <RefreshCwIcon className={cn("size-3.5", busy && "animate-og-spin")} aria-hidden />
1063
+ {busy ? "Reconnecting…" : "Reconnect"}
1064
+ </button>
1065
+ ) : item.authorizationUrl ? (
1066
+ <a
1067
+ href={item.authorizationUrl}
1068
+ rel="noreferrer"
1069
+ target="_blank"
1070
+ className={cn(
1071
+ "inline-flex w-full shrink-0 items-center justify-center gap-1.5 rounded-og-md bg-og-accent px-3 py-1.5 text-sm font-medium text-og-accent-fg sm:w-auto",
1072
+ "transition-colors hover:bg-og-accent-strong pointer-coarse:min-h-9",
1073
+ )}
1074
+ >
1075
+ <RefreshCwIcon className="size-3.5" aria-hidden />
1076
+ Reconnect
1077
+ </a>
1078
+ ) : null}
1079
+ </div>
1080
+ {failed ? (
1081
+ <p className="px-1 text-og-xs text-og-status-failed">
1082
+ Couldn't start reconnecting {provider}. Try again.
1083
+ </p>
1084
+ ) : null}
1085
+ </div>
1086
+ );
1087
+ }
1088
+
1089
+ /**
1090
+ * The provider's logo in a rounded tile, from a URL the HOST serves itself
1091
+ * (resolved via `resolveProviderLogo` → the app's catalog assets). A missing or
1092
+ * failed image falls back to a calm letter monogram — same as the rest of the
1093
+ * app — so the card never shows a broken-image glyph and never reaches off-origin
1094
+ * for a favicon (CSP + privacy).
1095
+ */
1096
+ function AuthProviderLogo({ src, label }: { src: string | null; label: string }) {
1097
+ const [failed, setFailed] = useState(false);
1098
+ // A resolver that only returns the URL after a lazy catalog fetch means `src`
1099
+ // can arrive on a later render; reset the error latch so it gets its attempt.
1100
+ useEffect(() => setFailed(false), [src]);
1101
+ const showImage = src && !failed;
1102
+ return (
1103
+ <span
1104
+ className="relative flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-og-md border border-og-border bg-og-surface-2 text-sm font-semibold text-og-fg-muted"
1105
+ aria-hidden
1106
+ >
1107
+ {showImage ? (
1108
+ <img
1109
+ src={src}
1110
+ alt=""
1111
+ loading="lazy"
1112
+ decoding="async"
1113
+ className="size-full object-contain p-1.5"
1114
+ onError={() => setFailed(true)}
1115
+ />
1116
+ ) : (
1117
+ <span>{monogram(label)}</span>
1118
+ )}
1119
+ </span>
1120
+ );
1121
+ }
1122
+
1123
+ /** First one or two initials for the monogram fallback — mirrors the app's
1124
+ `capabilityMonogram` so the reconnect tile reads like every other logo tile. */
1125
+ function monogram(label: string): string {
1126
+ const words = label.trim().split(/\s+/).filter(Boolean);
1127
+ if (words.length === 0) {
1128
+ return "?";
1129
+ }
1130
+ if (words.length === 1) {
1131
+ return words[0]!.slice(0, 2).toUpperCase();
1132
+ }
1133
+ return (words[0]![0]! + words[1]![0]!).toUpperCase();
1134
+ }
1135
+
1136
+ /** "linear.app" -> "Linear": the first domain label, capitalized. A calm human
1137
+ name for the provider — never the raw domain shown as a label. */
1138
+ function providerLabel(domain: string): string {
1139
+ const host =
1140
+ domain
1141
+ .trim()
1142
+ .replace(/^https?:\/\//, "")
1143
+ .replace(/^www\./, "")
1144
+ .split("/")[0] ?? "";
1145
+ const first = host.split(".")[0] ?? host;
1146
+ if (!first) {
1147
+ return "this service";
1148
+ }
1149
+ return first.charAt(0).toUpperCase() + first.slice(1);
1150
+ }
1151
+
1152
+ /** A calm, human helper line per reauth reason — the `reason` informs the copy
1153
+ but is never rendered as a raw enum/code. */
1154
+ function authReasonLine(reason: AuthNeededItem["reason"]): string {
1155
+ switch (reason) {
1156
+ case "insufficient_scope":
1157
+ return "It needs additional access to continue.";
1158
+ case "missing_connection":
1159
+ return "It isn't connected yet.";
1160
+ case "expired":
1161
+ case "refresh_failed":
1162
+ return "Its access expired.";
1163
+ default:
1164
+ return "Its connection needs attention.";
1165
+ }
1166
+ }