@opengeni/react 0.11.0 → 0.13.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/README.md +1 -1
- package/dist/{chunk-5C7RGAWA.js → chunk-NFYVQWIB.js} +170 -64
- package/dist/chunk-NFYVQWIB.js.map +1 -0
- package/dist/index.d.ts +791 -145
- package/dist/index.js +5447 -2213
- package/dist/index.js.map +1 -1
- package/dist/{machines-BeZ3bD3t.d.ts → machines-CnlMb7E-.d.ts} +5 -5
- package/dist/machines.d.ts +1 -1
- package/dist/machines.js +1 -1
- package/package.json +17 -12
- package/src/approvals.ts +16 -4
- package/src/client.ts +32 -5
- package/src/commands/index.ts +1 -7
- package/src/commands/registry.ts +46 -12
- package/src/components/chat-composer.tsx +135 -39
- package/src/components/code-editor.tsx +28 -36
- package/src/components/command-palette.tsx +26 -5
- package/src/components/desktop-viewer.tsx +29 -16
- package/src/components/diff-view.tsx +27 -189
- package/src/components/enrollment-consent.tsx +28 -10
- package/src/components/enrollment-device-flow.tsx +8 -5
- package/src/components/file-browser.tsx +190 -56
- package/src/components/fleet-tile.tsx +13 -4
- package/src/components/machine-card.tsx +16 -4
- package/src/components/machine-dock-bar.tsx +20 -8
- package/src/components/machine-metrics.tsx +31 -8
- package/src/components/machine-status-pill.tsx +26 -5
- package/src/components/machines-dashboard.tsx +8 -2
- package/src/components/markdown.tsx +39 -9
- package/src/components/message-timeline.tsx +633 -109
- package/src/components/pierre-diff.tsx +83 -15
- package/src/components/pierre-file.tsx +10 -9
- package/src/components/sandbox-files.tsx +110 -227
- package/src/components/sandbox-terminal.tsx +256 -88
- package/src/components/sandbox-workspace.tsx +823 -0
- package/src/components/session-status.tsx +28 -2
- package/src/components/workbench-changes.tsx +541 -0
- package/src/components/workspace-dock.tsx +85 -22
- package/src/hooks/internal.ts +5 -2
- package/src/hooks/use-available-models.ts +7 -2
- package/src/hooks/use-billing-usage.ts +4 -1
- package/src/hooks/use-codex-accounts.ts +74 -44
- package/src/hooks/use-composer.ts +57 -36
- package/src/hooks/use-environments.ts +92 -47
- package/src/hooks/use-file-attachments.ts +101 -55
- package/src/hooks/use-goal.ts +52 -16
- package/src/hooks/use-machine-chip.ts +134 -0
- package/src/hooks/use-machines.ts +34 -16
- package/src/hooks/use-packs.ts +24 -19
- package/src/hooks/use-relay-frame-stream.ts +5 -2
- package/src/hooks/use-rigs.ts +335 -0
- package/src/hooks/use-sandbox-files.ts +213 -39
- package/src/hooks/use-sandbox-git.ts +188 -11
- package/src/hooks/use-sandbox-terminal.ts +18 -14
- package/src/hooks/use-scheduled-tasks.ts +10 -2
- package/src/hooks/use-session-capabilities.ts +77 -20
- package/src/hooks/use-session-control.ts +49 -21
- package/src/hooks/use-session-events.ts +48 -20
- package/src/hooks/use-session-lineage.ts +119 -0
- package/src/hooks/use-session.ts +43 -28
- package/src/hooks/use-slash-commands.ts +6 -4
- package/src/hooks/use-turn-queue.ts +74 -147
- package/src/hooks/use-windowed-sections.ts +204 -0
- package/src/hooks/use-workspace-capture.ts +233 -0
- package/src/hooks/use-workspace-edit.ts +231 -0
- package/src/hooks/use-workspace-sessions.ts +48 -5
- package/src/hooks/use-workspaces.ts +18 -15
- package/src/index.ts +128 -23
- package/src/lib/format.ts +44 -6
- package/src/lib/xterm-renderer.ts +65 -0
- package/src/lib/xterm-theme.ts +224 -18
- package/src/machines.ts +13 -3
- package/src/provider.tsx +3 -1
- package/src/timeline/activity-rail.tsx +213 -54
- package/src/timeline/disclosure-context.tsx +12 -2
- package/src/timeline/index.ts +21 -2
- package/src/timeline/parsers.ts +44 -10
- package/src/timeline/projection.ts +435 -84
- package/src/timeline/shared.tsx +111 -24
- package/src/timeline/tool-diff.tsx +24 -20
- package/src/timeline/tool-renderers.tsx +98 -21
- package/src/timeline/turn-summary.tsx +76 -23
- package/src/timeline/types.ts +96 -12
- package/styles/tokens.css +2 -2
- package/dist/chunk-5C7RGAWA.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 {
|
|
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?:
|
|
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 =
|
|
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 =
|
|
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 (
|
|
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(
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
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
|
-
</
|
|
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?:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
|
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
|
|
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?:
|
|
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?:
|
|
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 ?
|
|
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?:
|
|
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
|
|
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
|
|
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: {
|
|
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: {
|
|
592
|
-
|
|
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
|
|
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
|
|
628
|
-
|
|
629
|
-
|
|
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
|
+
}
|