@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
@@ -1,23 +1,62 @@
1
- import type { ClientModel, SessionStatus } from "@opengeni/sdk";
2
- import { ArrowUpIcon, FileIcon, ImageIcon, LoaderCircleIcon, PaperclipIcon, RotateCwIcon, SquareIcon, XIcon } from "lucide-react";
1
+ import type { ClientModel, EffectiveSessionControl } from "@opengeni/sdk";
2
+ import {
3
+ ArrowUpIcon,
4
+ ChevronDownIcon,
5
+ FileIcon,
6
+ ImageIcon,
7
+ LoaderCircleIcon,
8
+ PaperclipIcon,
9
+ PauseIcon,
10
+ PlayIcon,
11
+ RotateCwIcon,
12
+ XIcon,
13
+ } from "lucide-react";
3
14
  import { AnimatePresence, motion } from "motion/react";
4
- import { useCallback, useEffect, useId, useMemo, useRef, useState, type ChangeEvent, type ClipboardEvent, type DragEvent, type KeyboardEvent, type ReactNode, type RefObject } from "react";
15
+ import {
16
+ useCallback,
17
+ useEffect,
18
+ useId,
19
+ useMemo,
20
+ useRef,
21
+ useState,
22
+ type ChangeEvent,
23
+ type ClipboardEvent,
24
+ type DragEvent,
25
+ type KeyboardEvent,
26
+ type ReactNode,
27
+ type RefObject,
28
+ } from "react";
5
29
  import { argHint } from "../commands/registry";
6
30
  import type { Notice, SlashCommand } from "../commands/types";
7
31
  import type { ComposerState } from "../hooks/use-composer";
8
- import { shouldSubmitOnKey } from "../hooks/use-composer";
32
+ import { shouldSteerOnKey, shouldSubmitOnKey } from "../hooks/use-composer";
9
33
  import type { UseFileAttachmentsResult } from "../hooks/use-file-attachments";
10
34
  import { defaultCommands } from "../commands/registry";
11
- import { useSlashCommands, type ConfirmState, type SlashCommandContext } from "../hooks/use-slash-commands";
35
+ import {
36
+ useSlashCommands,
37
+ type ConfirmState,
38
+ type SlashCommandContext,
39
+ } from "../hooks/use-slash-commands";
12
40
  import { cn } from "../lib/cn";
13
- import { formatBytes } from "../lib/format";
41
+ import { formatBytes, formatRelativeTime } from "../lib/format";
14
42
  import { CommandPalette } from "./command-palette";
15
43
  import { ModelPicker } from "./model-picker";
16
44
 
17
45
  export type ChatComposerProps = {
18
46
  composer: ComposerState;
19
- /** Current session status; shows the stop control while a turn runs. */
20
- status?: SessionStatus | null | undefined;
47
+ /** Canonical workstream control, separate from lifecycle status. */
48
+ effectiveControl?: EffectiveSessionControl | null | undefined;
49
+ /** Waiting prompts already ahead of a normal Send. */
50
+ queuedAheadCount?: number | undefined;
51
+ /** Whether broader Workspace Resume is authorized for this viewer. */
52
+ canControlWorkspace?: boolean | undefined;
53
+ /** Optional host routes used to navigate from effective Pause blockers. */
54
+ controlLinks?:
55
+ | {
56
+ workspaceHref?: string | undefined;
57
+ sessionHref?: ((sessionId: string) => string) | undefined;
58
+ }
59
+ | undefined;
21
60
  placeholder?: string | undefined;
22
61
  disabled?: boolean | undefined;
23
62
  autoFocus?: boolean | undefined;
@@ -68,7 +107,7 @@ export type ChatComposerProps = {
68
107
  onClearView?: (() => void) | undefined;
69
108
  };
70
109
 
71
- const ACTIVE_STATUSES: ReadonlySet<SessionStatus> = new Set(["queued", "running"]);
110
+ export const OPEN_WORKSTREAM_CONTROL_EVENT = "opengeni:open-workstream-control";
72
111
 
73
112
  /**
74
113
  * The chat composer — the only human-to-agent input surface. Plain chat in,
@@ -83,7 +122,10 @@ const ACTIVE_STATUSES: ReadonlySet<SessionStatus> = new Set(["queued", "running"
83
122
  */
84
123
  export function ChatComposer({
85
124
  composer,
86
- status,
125
+ effectiveControl,
126
+ queuedAheadCount = 0,
127
+ canControlWorkspace = false,
128
+ controlLinks,
87
129
  placeholder,
88
130
  disabled,
89
131
  autoFocus,
@@ -102,7 +144,24 @@ export function ChatComposer({
102
144
  }: ChatComposerProps) {
103
145
  const textareaRef = useRef<HTMLTextAreaElement | null>(null);
104
146
  const fileInputRef = useRef<HTMLInputElement | null>(null);
105
- const active = status != null && ACTIVE_STATUSES.has(status);
147
+ const paused = effectiveControl?.state === "paused";
148
+ const [controlDetailsOpen, setControlDetailsOpen] = useState(false);
149
+ useEffect(() => {
150
+ const openControl = () => {
151
+ textareaRef.current?.scrollIntoView({ block: "end", behavior: "smooth" });
152
+ if (paused) {
153
+ setControlDetailsOpen(true);
154
+ return;
155
+ }
156
+ window.requestAnimationFrame(() => {
157
+ document
158
+ .querySelector<HTMLButtonElement>('button[aria-label="Pause this workstream"]')
159
+ ?.focus();
160
+ });
161
+ };
162
+ document.addEventListener(OPEN_WORKSTREAM_CONTROL_EVENT, openControl);
163
+ return () => document.removeEventListener(OPEN_WORKSTREAM_CONTROL_EVENT, openControl);
164
+ }, [paused]);
106
165
 
107
166
  // Block sends while attachments are still uploading so a message never
108
167
  // departs without the files the user attached to it. This gates BOTH the
@@ -255,7 +314,11 @@ export function ChatComposer({
255
314
  if (commandDraftBlocked) {
256
315
  // Dismissed palette + a "/command" draft: don't send it as chat. Nudge
257
316
  // the operator to re-open the palette (any edit re-opens it) or clear it.
258
- setNotice({ tone: "error", message: "That's a slash command — press Enter in the command list to run it, or edit the line to send a message." });
317
+ setNotice({
318
+ tone: "error",
319
+ message:
320
+ "That's a slash command — press Enter in the command list to run it, or edit the line to send a message.",
321
+ });
259
322
  return;
260
323
  }
261
324
  if (blockedByUpload) {
@@ -263,7 +326,7 @@ export function ChatComposer({
263
326
  // depart without them. The send button is disabled in the same state.
264
327
  return;
265
328
  }
266
- void composer.send();
329
+ void (shouldSteerOnKey(event) ? composer.steer() : composer.send());
267
330
  }
268
331
  };
269
332
 
@@ -300,12 +363,24 @@ export function ChatComposer({
300
363
  [commands, commandContext],
301
364
  );
302
365
 
303
- const activeNotice = notice ?? (composer.error ? { tone: "error" as const, message: composer.error.message || "Sending failed — your draft is still here. Try again." } : null);
366
+ const activeNotice =
367
+ notice ??
368
+ (composer.error
369
+ ? {
370
+ tone: "error" as const,
371
+ message: /control changed|paused while/i.test(composer.error.message)
372
+ ? "This workstream was paused while you were sending. Nothing was sent, and your draft is still here."
373
+ : composer.error.message || "Sending failed — your draft is still here. Try again.",
374
+ }
375
+ : null);
304
376
 
305
377
  return (
306
378
  // Respect the iOS home-indicator inset so the sticky composer never sits
307
379
  // under it (0 on non-notch devices and desktop, so it's inert there).
308
- <div className={cn("og-root", className)} style={{ paddingBottom: "env(safe-area-inset-bottom)" }}>
380
+ <div
381
+ className={cn("og-root", className)}
382
+ style={{ paddingBottom: "env(safe-area-inset-bottom)" }}
383
+ >
309
384
  <div className="relative">
310
385
  {paletteEnabled ? (
311
386
  <CommandPalette
@@ -355,6 +430,26 @@ export function ChatComposer({
355
430
  </span>
356
431
  </div>
357
432
  ) : null}
433
+ {paused && effectiveControl ? (
434
+ <WorkstreamPausedStrip
435
+ control={effectiveControl}
436
+ queuedAheadCount={queuedAheadCount}
437
+ open={controlDetailsOpen}
438
+ busy={composer.resuming}
439
+ sending={composer.sending}
440
+ canControlWorkspace={canControlWorkspace}
441
+ controlLinks={controlLinks}
442
+ onOpenChange={setControlDetailsOpen}
443
+ onResume={() => void composer.resume()}
444
+ onResumeOption={(option) => void composer.resumeScope(option)}
445
+ />
446
+ ) : null}
447
+ {composer.restoredResources.length > 0 ? (
448
+ <RestoredResourceChips
449
+ resources={composer.restoredResources}
450
+ onRemove={composer.removeRestoredResource}
451
+ />
452
+ ) : null}
358
453
  {attachments && attachments.attachments.length > 0 ? (
359
454
  <AttachmentChips
360
455
  attachments={attachments.attachments}
@@ -370,14 +465,26 @@ export function ChatComposer({
370
465
  onChange={(event) => composer.setValue(event.target.value)}
371
466
  onKeyDown={onKeyDown}
372
467
  onPaste={handlePaste}
373
- placeholder={placeholder ?? "Message the agent…"}
468
+ placeholder={
469
+ paused && disabled !== true
470
+ ? "Sending will resume this workstream…"
471
+ : (placeholder ?? "Message the agent…")
472
+ }
374
473
  disabled={disabled}
375
474
  autoFocus={autoFocus}
376
475
  aria-label="Message the agent"
377
- role={paletteEnabled && palette.open ? "combobox" : undefined}
378
- aria-expanded={paletteEnabled ? palette.open : undefined}
476
+ aria-keyshortcuts="Enter Meta+Enter Control+Enter Shift+Enter"
477
+ // A multiline textarea cannot legally take the combobox role or
478
+ // aria-expanded. It remains a native textbox and advertises the
479
+ // popup as list autocomplete; controls/active-descendant exist
480
+ // only while the suggestion list itself exists.
481
+ aria-autocomplete={paletteEnabled ? "list" : undefined}
379
482
  aria-controls={paletteEnabled && palette.open ? listboxId : undefined}
380
- aria-activedescendant={paletteEnabled && palette.open ? `${listboxId}-option-${palette.highlight}` : undefined}
483
+ aria-activedescendant={
484
+ paletteEnabled && palette.open
485
+ ? `${listboxId}-option-${palette.highlight}`
486
+ : undefined
487
+ }
381
488
  className={cn(
382
489
  // Font size steps up to 16px below `md` so iOS never zooms the
383
490
  // viewport on focus; desktop keeps the 15px og-md rhythm.
@@ -426,7 +533,7 @@ export function ChatComposer({
426
533
  className={cn(
427
534
  "inline-flex size-8 items-center justify-center rounded-og-md",
428
535
  "text-og-fg-muted transition-colors duration-150 hover:bg-og-surface-2 hover:text-og-fg",
429
- "disabled:cursor-not-allowed disabled:opacity-50",
536
+ "disabled:cursor-not-allowed disabled:opacity-50 pointer-coarse:size-11",
430
537
  )}
431
538
  >
432
539
  <PaperclipIcon className="size-4" />
@@ -445,38 +552,31 @@ export function ChatComposer({
445
552
  </span>
446
553
  ) : (
447
554
  <span className="min-w-0 flex-1 px-1.5 text-og-xs text-og-fg-subtle max-sm:hidden">
448
- {hint ?? "Enter to send · Shift+Enter for a new line · / for commands"}
555
+ {hint ?? "Enter to queue · Cmd/Ctrl+Enter to steer · Shift+Enter for a new line"}
449
556
  </span>
450
557
  )}
451
558
  <span className="ml-auto flex shrink-0 items-center gap-1.5">
452
- <AnimatePresence initial={false}>
453
- {active ? (
454
- <motion.button
455
- key="stop"
456
- type="button"
457
- initial={{ opacity: 0, scale: 0.8 }}
458
- animate={{ opacity: 1, scale: 1 }}
459
- exit={{ opacity: 0, scale: 0.8 }}
460
- transition={{ duration: 0.15, ease: "easeOut" }}
461
- onClick={() => void composer.interrupt()}
462
- disabled={composer.interrupting}
463
- aria-label="Stop the current turn"
464
- title="Stop the current turn"
465
- className={cn(
466
- "inline-flex size-8 items-center justify-center rounded-og-md border border-og-border pointer-coarse:size-11",
467
- "bg-og-surface-2 text-og-fg-muted transition-colors duration-150",
468
- "hover:border-og-status-failed/50 hover:text-og-status-failed",
469
- "disabled:opacity-50",
470
- )}
471
- >
472
- {composer.interrupting ? (
473
- <LoaderCircleIcon className="size-3.5 animate-og-spin" />
474
- ) : (
475
- <SquareIcon className="size-3 fill-current" />
476
- )}
477
- </motion.button>
478
- ) : null}
479
- </AnimatePresence>
559
+ {effectiveControl && !paused ? (
560
+ <button
561
+ type="button"
562
+ onClick={() => void composer.pause()}
563
+ disabled={composer.pausing || composer.resuming}
564
+ aria-label="Pause this workstream"
565
+ title="Pause this workstream; queued prompts and approvals are preserved"
566
+ className={cn(
567
+ "inline-flex size-8 items-center justify-center rounded-og-md border border-og-border pointer-coarse:size-11",
568
+ "bg-og-surface-2 text-og-fg-muted transition-colors duration-150",
569
+ "hover:border-og-status-waiting/50 hover:text-og-status-waiting",
570
+ "disabled:opacity-50",
571
+ )}
572
+ >
573
+ {composer.pausing || composer.resuming ? (
574
+ <LoaderCircleIcon className="size-3.5 animate-og-spin" />
575
+ ) : (
576
+ <PauseIcon className="size-3.5 fill-current" />
577
+ )}
578
+ </button>
579
+ ) : null}
480
580
  <button
481
581
  type="button"
482
582
  onClick={() => {
@@ -486,7 +586,12 @@ export function ChatComposer({
486
586
  void composer.send();
487
587
  }}
488
588
  disabled={!canSend || disabled === true || commandDraftBlocked}
489
- aria-label="Send message"
589
+ aria-label={paused ? "Send and resume" : "Send message"}
590
+ title={
591
+ paused
592
+ ? "Send & resume (Enter); Steer & resume with Cmd/Ctrl+Enter"
593
+ : "Queue message (Enter); steer with Cmd/Ctrl+Enter"
594
+ }
490
595
  className={cn(
491
596
  "inline-flex size-8 items-center justify-center rounded-og-md pointer-coarse:size-11",
492
597
  "bg-og-accent text-og-accent-fg shadow-og-sm",
@@ -495,7 +600,11 @@ export function ChatComposer({
495
600
  "disabled:cursor-not-allowed disabled:bg-og-surface-3 disabled:text-og-fg-subtle disabled:shadow-none",
496
601
  )}
497
602
  >
498
- {composer.sending ? <LoaderCircleIcon className="size-4 animate-og-spin" /> : <ArrowUpIcon className="size-4" />}
603
+ {composer.sending ? (
604
+ <LoaderCircleIcon className="size-4 animate-og-spin" />
605
+ ) : (
606
+ <ArrowUpIcon className="size-4" />
607
+ )}
499
608
  </button>
500
609
  </span>
501
610
  </div>
@@ -503,9 +612,7 @@ export function ChatComposer({
503
612
  </div>
504
613
  </div>
505
614
  <AnimatePresence>
506
- {helpOpen ? (
507
- <HelpPanel commands={helpCommands} onClose={() => setHelpOpen(false)} />
508
- ) : null}
615
+ {helpOpen ? <HelpPanel commands={helpCommands} onClose={() => setHelpOpen(false)} /> : null}
509
616
  </AnimatePresence>
510
617
  <AnimatePresence>
511
618
  {activeNotice ? (
@@ -521,7 +628,10 @@ export function ChatComposer({
521
628
  onAnimationComplete={() => {
522
629
  if (activeNotice.tone === "ok") {
523
630
  // Auto-dismiss success notices after a beat.
524
- window.setTimeout(() => setNotice((current) => (current === activeNotice ? null : current)), 2400);
631
+ window.setTimeout(
632
+ () => setNotice((current) => (current === activeNotice ? null : current)),
633
+ 2400,
634
+ );
525
635
  }
526
636
  }}
527
637
  >
@@ -529,6 +639,218 @@ export function ChatComposer({
529
639
  </motion.p>
530
640
  ) : null}
531
641
  </AnimatePresence>
642
+ {composer.draftConflict ? (
643
+ <div
644
+ className="mt-1.5 flex flex-wrap items-center gap-2 px-1 text-og-xs text-og-status-failed"
645
+ role="alert"
646
+ >
647
+ <span className="min-w-0 flex-1">
648
+ This draft changed in another tab. Your local draft is still here.
649
+ </span>
650
+ <button
651
+ type="button"
652
+ className="underline underline-offset-2"
653
+ onClick={() => void composer.resolveDraftConflict("use_remote")}
654
+ >
655
+ Use other draft
656
+ </button>
657
+ <button
658
+ type="button"
659
+ className="font-medium underline underline-offset-2"
660
+ onClick={() => void composer.resolveDraftConflict("keep_mine")}
661
+ >
662
+ Keep mine
663
+ </button>
664
+ </div>
665
+ ) : composer.draftSaving ? (
666
+ <p className="px-1 pt-1 text-og-xs text-og-fg-subtle" role="status">
667
+ Saving draft…
668
+ </p>
669
+ ) : null}
670
+ </div>
671
+ );
672
+ }
673
+
674
+ function WorkstreamPausedStrip({
675
+ control,
676
+ queuedAheadCount,
677
+ open,
678
+ busy,
679
+ sending,
680
+ canControlWorkspace,
681
+ controlLinks,
682
+ onOpenChange,
683
+ onResume,
684
+ onResumeOption,
685
+ }: {
686
+ control: EffectiveSessionControl;
687
+ queuedAheadCount: number;
688
+ open: boolean;
689
+ busy: boolean;
690
+ sending: boolean;
691
+ canControlWorkspace: boolean;
692
+ controlLinks: ChatComposerProps["controlLinks"];
693
+ onOpenChange: (open: boolean) => void;
694
+ onResume: () => void;
695
+ onResumeOption: (option: EffectiveSessionControl["resumeOptions"][number]) => void;
696
+ }) {
697
+ const blocker = control.primaryBlocker;
698
+ const cause =
699
+ blocker?.kind === "workspace"
700
+ ? "Workspace paused"
701
+ : control.directState === "paused"
702
+ ? "Paused here"
703
+ : `Paused by ${blocker?.displayName ?? "parent"}`;
704
+ const primary = control.resumeOptions.find((option) => option.scope === "selected");
705
+ const broaderOptions = control.resumeOptions.filter(
706
+ (option) =>
707
+ option !== primary &&
708
+ option.scope !== "selected" &&
709
+ (option.scope !== "workspace" || canControlWorkspace),
710
+ );
711
+ return (
712
+ <div className="border-b border-og-status-waiting/25 bg-og-status-waiting/[0.07]">
713
+ <div className="flex min-w-0 items-center gap-2 px-3 py-2">
714
+ <PauseIcon className="size-3.5 shrink-0 text-og-status-waiting" />
715
+ <button
716
+ type="button"
717
+ className="min-w-0 flex-1 text-left"
718
+ onClick={() => onOpenChange(!open)}
719
+ aria-expanded={open}
720
+ >
721
+ <span className="block truncate text-og-xs font-medium text-og-fg">{cause}</span>
722
+ <span className="block truncate text-[11px] text-og-fg-muted">
723
+ {queuedAheadCount > 0
724
+ ? `Send & resume queues behind ${queuedAheadCount} waiting prompt${queuedAheadCount === 1 ? "" : "s"}.`
725
+ : sending
726
+ ? "Resuming and sending…"
727
+ : "Your next message resumes this workstream automatically."}
728
+ </span>
729
+ </button>
730
+ <button
731
+ type="button"
732
+ aria-label="Resume this workstream"
733
+ className="inline-flex min-h-8 shrink-0 items-center gap-1.5 rounded-og-md border border-og-status-waiting/35 bg-og-surface-1 px-2.5 text-og-xs font-medium text-og-fg hover:bg-og-surface-2 pointer-coarse:min-h-11"
734
+ disabled={busy}
735
+ onClick={onResume}
736
+ >
737
+ {busy ? (
738
+ <LoaderCircleIcon className="size-3.5 animate-og-spin" />
739
+ ) : (
740
+ <PlayIcon className="size-3.5 fill-current" />
741
+ )}
742
+ <span className="hidden min-[400px]:inline">Resume this workstream</span>
743
+ <span className="min-[400px]:hidden">Resume</span>
744
+ </button>
745
+ <button
746
+ type="button"
747
+ className="inline-flex size-8 shrink-0 items-center justify-center rounded-og-md text-og-fg-muted hover:bg-og-surface-2 pointer-coarse:size-11"
748
+ onClick={() => onOpenChange(!open)}
749
+ aria-label={open ? "Hide pause details" : "Show pause details"}
750
+ >
751
+ <ChevronDownIcon className={cn("size-3.5 transition-transform", open && "rotate-180")} />
752
+ </button>
753
+ </div>
754
+ {open ? (
755
+ <div className="border-t border-og-status-waiting/20 px-3 pb-3 pt-2">
756
+ <ul className="grid gap-2" aria-label="Reasons this workstream is paused">
757
+ {control.blockers.map((entry, index) => (
758
+ <li
759
+ key={`${entry.kind}-${entry.sessionId ?? "workspace"}-${entry.revision}`}
760
+ className="text-og-xs"
761
+ >
762
+ <span className="font-medium text-og-fg">
763
+ {index === 0 ? "Paused by " : "Also paused by "}
764
+ </span>
765
+ {blockerHref(entry, controlLinks) ? (
766
+ <a
767
+ href={blockerHref(entry, controlLinks)}
768
+ className="font-medium text-og-fg underline decoration-og-border-strong underline-offset-2 hover:text-og-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-og-ring/40"
769
+ >
770
+ {entry.displayName}
771
+ </a>
772
+ ) : (
773
+ <span className="font-medium text-og-fg">{entry.displayName}</span>
774
+ )}
775
+ {entry.reason ? <span className="text-og-fg-muted"> · {entry.reason}</span> : null}
776
+ {entry.actor ? <span className="text-og-fg-subtle"> · {entry.actor}</span> : null}
777
+ {entry.changedAt ? (
778
+ <span className="text-og-fg-subtle">
779
+ {" "}
780
+ · {formatRelativeTime(entry.changedAt)}
781
+ </span>
782
+ ) : null}
783
+ </li>
784
+ ))}
785
+ </ul>
786
+ {broaderOptions.length > 0 ? (
787
+ <div className="mt-2 flex flex-wrap gap-1.5">
788
+ {broaderOptions.map((option) => (
789
+ <button
790
+ key={`${option.scope}-${option.targetId ?? "selected"}`}
791
+ type="button"
792
+ disabled={busy}
793
+ title={option.impactCopy}
794
+ onClick={() => onResumeOption(option)}
795
+ className="rounded-og-md border border-og-border bg-og-surface-1 px-2 py-1 text-og-xs text-og-fg-muted hover:bg-og-surface-2 hover:text-og-fg pointer-coarse:min-h-10"
796
+ >
797
+ <span className="block font-medium">
798
+ {option.scope === "workspace" ? "Resume workspace" : "Resume from this session"}
799
+ </span>
800
+ <span className="block text-[10px] text-og-fg-subtle">
801
+ {option.selectedStateAfter === "active"
802
+ ? "This session will be able to run"
803
+ : `Still paused by ${option.remainingPrimaryBlocker?.displayName ?? "a narrower pause"}`}
804
+ </span>
805
+ </button>
806
+ ))}
807
+ </div>
808
+ ) : null}
809
+ </div>
810
+ ) : null}
811
+ </div>
812
+ );
813
+ }
814
+
815
+ function blockerHref(
816
+ blocker: EffectiveSessionControl["blockers"][number],
817
+ links: ChatComposerProps["controlLinks"],
818
+ ): string | undefined {
819
+ if (blocker.kind === "workspace") return links?.workspaceHref;
820
+ return blocker.sessionId ? links?.sessionHref?.(blocker.sessionId) : undefined;
821
+ }
822
+
823
+ function RestoredResourceChips({
824
+ resources,
825
+ onRemove,
826
+ }: {
827
+ resources: ComposerState["restoredResources"];
828
+ onRemove: (index: number) => void;
829
+ }) {
830
+ return (
831
+ <div
832
+ className="flex flex-wrap gap-1.5 border-b border-og-border px-3 py-2"
833
+ aria-label="Restored prompt resources"
834
+ >
835
+ {resources.map((resource, index) => (
836
+ <span
837
+ key={`${resource.kind}-${resource.kind === "file" ? resource.fileId : resource.uri}`}
838
+ className="inline-flex min-w-0 max-w-full items-center gap-1.5 rounded-og-md border border-og-border bg-og-surface-2 px-2 py-1 text-og-xs text-og-fg-muted"
839
+ >
840
+ <FileIcon className="size-3 shrink-0" />
841
+ <span className="truncate">
842
+ {resource.kind === "file" ? `File ${resource.fileId.slice(0, 8)}` : resource.uri}
843
+ </span>
844
+ <button
845
+ type="button"
846
+ className="shrink-0 hover:text-og-fg"
847
+ onClick={() => onRemove(index)}
848
+ aria-label={`Remove restored resource ${index + 1}`}
849
+ >
850
+ <XIcon className="size-3" />
851
+ </button>
852
+ </span>
853
+ ))}
532
854
  </div>
533
855
  );
534
856
  }
@@ -608,7 +930,11 @@ function ConfirmBar({
608
930
  * in-progress upload does), and removing it clears the way. Styled with the
609
931
  * package's og-* tokens so it themes in any consumer.
610
932
  */
611
- function AttachmentChips({ attachments, onRemove, onRetry }: {
933
+ function AttachmentChips({
934
+ attachments,
935
+ onRemove,
936
+ onRetry,
937
+ }: {
612
938
  attachments: UseFileAttachmentsResult["attachments"];
613
939
  onRemove: (id: string) => void;
614
940
  onRetry?: ((id: string) => void) | undefined;
@@ -617,21 +943,28 @@ function AttachmentChips({ attachments, onRemove, onRetry }: {
617
943
  <div className="flex flex-wrap gap-2 border-b border-og-border px-3 py-2">
618
944
  {attachments.map((attachment) => {
619
945
  const failed = attachment.status === "failed";
620
- const statusText = attachment.status === "uploading"
621
- ? "Uploading"
622
- : failed
623
- ? attachment.error || "Upload failed"
624
- : formatBytes(attachment.sizeBytes);
946
+ const statusText =
947
+ attachment.status === "uploading"
948
+ ? "Uploading"
949
+ : failed
950
+ ? attachment.error || "Upload failed"
951
+ : formatBytes(attachment.sizeBytes);
625
952
  return (
626
953
  <div
627
954
  key={attachment.id}
628
955
  className={cn(
629
956
  "flex min-w-0 max-w-[240px] items-center gap-2 rounded-og-md border px-2 py-1.5 text-og-sm",
630
- failed ? "border-og-status-failed/40 bg-og-status-failed/10" : "border-og-border bg-og-surface-2",
957
+ failed
958
+ ? "border-og-status-failed/40 bg-og-status-failed/10"
959
+ : "border-og-border bg-og-surface-2",
631
960
  )}
632
961
  >
633
962
  {attachment.previewUrl ? (
634
- <img src={attachment.previewUrl} alt="" className="size-8 shrink-0 rounded object-cover" />
963
+ <img
964
+ src={attachment.previewUrl}
965
+ alt=""
966
+ className="size-8 shrink-0 rounded object-cover"
967
+ />
635
968
  ) : attachment.contentType.startsWith("image/") ? (
636
969
  <ImageIcon className="size-4 shrink-0 text-og-fg-muted" />
637
970
  ) : (
@@ -640,18 +973,23 @@ function AttachmentChips({ attachments, onRemove, onRetry }: {
640
973
  <div className="min-w-0 flex-1">
641
974
  <div className="truncate font-medium text-og-fg">{attachment.name}</div>
642
975
  <div
643
- className={cn("truncate text-og-xs", failed ? "text-og-status-failed" : "text-og-fg-subtle")}
976
+ className={cn(
977
+ "truncate text-og-xs",
978
+ failed ? "text-og-status-failed" : "text-og-fg-subtle",
979
+ )}
644
980
  title={failed ? statusText : undefined}
645
981
  >
646
982
  {statusText}
647
983
  </div>
648
984
  </div>
649
- {attachment.status === "uploading" ? <LoaderCircleIcon className="size-3.5 shrink-0 animate-og-spin" /> : null}
985
+ {attachment.status === "uploading" ? (
986
+ <LoaderCircleIcon className="size-3.5 shrink-0 animate-og-spin" />
987
+ ) : null}
650
988
  {failed && onRetry ? (
651
989
  <button
652
990
  type="button"
653
991
  onClick={() => onRetry(attachment.id)}
654
- className="shrink-0 rounded-og-xs p-1 text-og-fg-muted hover:bg-og-surface-1 hover:text-og-fg pointer-coarse:size-8"
992
+ className="shrink-0 rounded-og-xs p-1 text-og-fg-muted hover:bg-og-surface-1 hover:text-og-fg pointer-coarse:size-10"
655
993
  aria-label={`Retry ${attachment.name}`}
656
994
  title="Retry upload"
657
995
  >
@@ -661,7 +999,7 @@ function AttachmentChips({ attachments, onRemove, onRetry }: {
661
999
  <button
662
1000
  type="button"
663
1001
  onClick={() => onRemove(attachment.id)}
664
- className="shrink-0 rounded-og-xs p-1 text-og-fg-muted hover:bg-og-surface-1 hover:text-og-fg pointer-coarse:size-8"
1002
+ className="shrink-0 rounded-og-xs p-1 text-og-fg-muted hover:bg-og-surface-1 hover:text-og-fg pointer-coarse:size-10"
665
1003
  aria-label={`Remove ${attachment.name}`}
666
1004
  >
667
1005
  <XIcon className="size-3.5" />
@@ -674,7 +1012,13 @@ function AttachmentChips({ attachments, onRemove, onRetry }: {
674
1012
  }
675
1013
 
676
1014
  /** The in-composer /help panel, rendered entirely from the registry. */
677
- function HelpPanel({ commands, onClose }: { commands: readonly SlashCommand[]; onClose: () => void }) {
1015
+ function HelpPanel({
1016
+ commands,
1017
+ onClose,
1018
+ }: {
1019
+ commands: readonly SlashCommand[];
1020
+ onClose: () => void;
1021
+ }) {
678
1022
  return (
679
1023
  <motion.div
680
1024
  initial={{ opacity: 0, height: 0 }}
@@ -684,7 +1028,11 @@ function HelpPanel({ commands, onClose }: { commands: readonly SlashCommand[]; o
684
1028
  >
685
1029
  <div className="flex items-center justify-between border-b border-og-border px-3 py-1.5">
686
1030
  <span className="text-og-sm font-medium text-og-fg">Commands</span>
687
- <button type="button" onClick={onClose} className="text-og-xs text-og-fg-subtle hover:text-og-fg">
1031
+ <button
1032
+ type="button"
1033
+ onClick={onClose}
1034
+ className="text-og-xs text-og-fg-subtle hover:text-og-fg"
1035
+ >
688
1036
  Close
689
1037
  </button>
690
1038
  </div>