@opengeni/react 0.6.1 → 0.6.3

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 (42) hide show
  1. package/dist/{chunk-DEW2ZNF2.js → chunk-5C7RGAWA.js} +70 -37
  2. package/dist/chunk-5C7RGAWA.js.map +1 -0
  3. package/dist/index.d.ts +29 -5
  4. package/dist/index.js +1061 -618
  5. package/dist/index.js.map +1 -1
  6. package/dist/{machines-BD6h9P_s.d.ts → machines-Bv9tG7qZ.d.ts} +1 -1
  7. package/dist/machines.d.ts +1 -1
  8. package/dist/machines.js +1 -1
  9. package/package.json +2 -2
  10. package/src/components/chat-composer.tsx +127 -60
  11. package/src/components/code-editor.tsx +15 -15
  12. package/src/components/desktop-viewer.tsx +40 -36
  13. package/src/components/diff-view.tsx +22 -22
  14. package/src/components/enrollment-consent.tsx +13 -13
  15. package/src/components/file-browser.tsx +74 -67
  16. package/src/components/fleet-tile.tsx +3 -3
  17. package/src/components/machine-card.tsx +6 -6
  18. package/src/components/machine-status-pill.tsx +3 -3
  19. package/src/components/machines-dashboard.tsx +28 -14
  20. package/src/components/markdown.tsx +6 -6
  21. package/src/components/message-timeline.tsx +75 -4
  22. package/src/components/pierre-diff.tsx +9 -10
  23. package/src/components/pierre-file.tsx +8 -8
  24. package/src/components/sandbox-files.tsx +37 -40
  25. package/src/components/sandbox-terminal.tsx +9 -11
  26. package/src/components/session-status.tsx +2 -2
  27. package/src/components/workspace-dock.tsx +153 -49
  28. package/src/hooks/use-file-attachments.ts +45 -15
  29. package/src/index.ts +2 -2
  30. package/src/lib/format.ts +32 -0
  31. package/src/lib/xterm-theme.ts +8 -11
  32. package/src/timeline/activity-rail.tsx +1 -1
  33. package/src/timeline/parsers.ts +104 -0
  34. package/src/timeline/projection.ts +135 -3
  35. package/src/timeline/screenshot-lightbox.tsx +1 -1
  36. package/src/timeline/shared.tsx +4 -1
  37. package/src/timeline/tool-diff.tsx +1 -1
  38. package/src/timeline/tool-renderers.tsx +46 -7
  39. package/src/timeline/turn-summary.tsx +21 -3
  40. package/src/timeline/types.ts +2 -0
  41. package/styles/tokens.css +8 -0
  42. package/dist/chunk-DEW2ZNF2.js.map +0 -1
@@ -51,11 +51,11 @@ export type FileBrowserProps = {
51
51
  };
52
52
 
53
53
  const STATUS_TINT: Record<NonNullable<FileTreeNode["status"]>, string> = {
54
- added: "text-[color:var(--og-color-status-idle,var(--color-success,#3fb950))]",
55
- modified: "text-[color:var(--og-color-status-running,var(--color-warning,#d29922))]",
56
- deleted: "text-[color:var(--og-color-danger,var(--color-danger,#f85149))] line-through",
57
- renamed: "text-[color:var(--og-color-accent,var(--color-info,#58a6ff))]",
58
- untracked: "text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]",
54
+ added: "text-og-status-idle",
55
+ modified: "text-og-status-running",
56
+ deleted: "text-og-status-failed line-through",
57
+ renamed: "text-og-accent",
58
+ untracked: "text-og-fg-subtle",
59
59
  };
60
60
 
61
61
  /** Parent dir of a workspace-relative POSIX path ("" for a root entry). */
@@ -397,15 +397,9 @@ export function FileBrowser({
397
397
  };
398
398
  }, [menu]);
399
399
 
400
- if (result.error && result.tree.length === 0) {
401
- return (
402
- <div className={cn("p-3 text-xs text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]", className)}>
403
- {fallback ?? `Files unavailable: ${result.error.message}`}
404
- </div>
405
- );
406
- }
407
-
400
+ const showErrorEmpty = result.error && result.tree.length === 0 && !draftCreate;
408
401
  const showEmpty = !result.loading && result.tree.length === 0 && !draftCreate;
402
+ const showToolbar = supportsMutation || Boolean(result.error) || result.loading;
409
403
 
410
404
  const renderRow = (node: FileTreeNode, depth: number): ReactNode => {
411
405
  const isOpen = expanded.has(node.path);
@@ -489,13 +483,11 @@ export function FileBrowser({
489
483
  setMenu({ node, x: e.clientX, y: e.clientY });
490
484
  }}
491
485
  className={cn(
492
- "group flex w-full items-center gap-1 truncate rounded px-1 py-0.5 text-left text-xs",
493
- "hover:bg-[color:var(--og-color-surface-2,var(--color-bg-subtle,#1c1c1c))]",
494
- isSelected && "bg-[color:var(--og-color-surface-2,var(--color-bg-subtle,#1c1c1c))]",
495
- isCursor &&
496
- "outline outline-1 -outline-offset-1 outline-[color:var(--og-color-accent,var(--color-info,#58a6ff))]",
497
- isDropTarget &&
498
- "ring-1 ring-inset ring-[color:var(--og-color-accent,var(--color-info,#58a6ff))] bg-[color:var(--og-color-accent-soft,rgba(88,166,255,0.12))]",
486
+ "group flex w-full items-center gap-1 truncate rounded-og-sm px-1 py-0.5 text-left text-og-sm pointer-coarse:min-h-10",
487
+ "hover:bg-og-surface-2",
488
+ isSelected && "bg-og-surface-2",
489
+ isCursor && "outline outline-1 -outline-offset-1 outline-og-accent",
490
+ isDropTarget && "bg-og-accent-soft ring-1 ring-inset ring-og-accent",
499
491
  node.status ? STATUS_TINT[node.status] : undefined,
500
492
  )}
501
493
  style={{ paddingLeft: `${depth * 12 + 4}px` }}
@@ -503,7 +495,7 @@ export function FileBrowser({
503
495
  {isDir ? (
504
496
  isLoading ? (
505
497
  <Loader2Icon
506
- className="size-3 shrink-0 animate-spin text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]"
498
+ className="size-3 shrink-0 animate-spin text-og-fg-subtle"
507
499
  aria-label="Loading"
508
500
  />
509
501
  ) : (
@@ -526,7 +518,7 @@ export function FileBrowser({
526
518
  const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
527
519
  setMenu({ node, x: rect.right, y: rect.bottom });
528
520
  }}
529
- className="ml-auto hidden shrink-0 px-1 text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))] hover:text-[color:var(--og-color-fg,var(--color-fg,#e6e6e6))] group-hover:inline"
521
+ className="ml-auto hidden shrink-0 px-1 text-og-fg-subtle hover:text-og-fg group-hover:inline"
530
522
  >
531
523
 
532
524
  </span>
@@ -555,7 +547,7 @@ export function FileBrowser({
555
547
  {[0, 1, 2].map((i) => (
556
548
  <div
557
549
  key={i}
558
- className="h-2.5 animate-pulse rounded bg-[color:var(--og-color-surface-2,var(--color-bg-subtle,#1c1c1c))]"
550
+ className="h-2.5 animate-pulse rounded-og-sm bg-og-surface-2"
559
551
  style={{ width: `${70 - i * 12}%` }}
560
552
  />
561
553
  ))}
@@ -570,34 +562,38 @@ export function FileBrowser({
570
562
 
571
563
  return (
572
564
  <div className={cn("flex min-w-0 flex-col", className)}>
573
- {supportsMutation && (
574
- <div className="flex shrink-0 items-center gap-0.5 border-b border-[color:var(--og-color-border,var(--color-border,#2a2a2a))] px-1 py-1">
575
- <ToolbarButton label="New file" onClick={() => startCreate("file")} disabled={busy}>
576
- <FilePlusIcon className="size-3.5" />
577
- </ToolbarButton>
578
- <ToolbarButton label="New folder" onClick={() => startCreate("dir")} disabled={busy}>
579
- <FolderPlusIcon className="size-3.5" />
580
- </ToolbarButton>
581
- <ToolbarButton
582
- label="Rename"
583
- onClick={() => {
584
- const node = cursor ? findNode(result.tree, cursor) : undefined;
585
- if (node) startRename(node);
586
- }}
587
- disabled={busy || !cursor}
588
- >
589
- <PencilIcon className="size-3.5" />
590
- </ToolbarButton>
591
- <ToolbarButton
592
- label="Delete"
593
- onClick={() => {
594
- const node = cursor ? findNode(result.tree, cursor) : undefined;
595
- if (node) void runDelete(node);
596
- }}
597
- disabled={busy || !cursor}
598
- >
599
- <Trash2Icon className="size-3.5" />
600
- </ToolbarButton>
565
+ {showToolbar && (
566
+ <div className="flex shrink-0 flex-wrap items-center gap-0.5 border-b border-og-border px-1 py-1">
567
+ {supportsMutation ? (
568
+ <>
569
+ <ToolbarButton label="New file" onClick={() => startCreate("file")} disabled={busy}>
570
+ <FilePlusIcon className="size-3.5" />
571
+ </ToolbarButton>
572
+ <ToolbarButton label="New folder" onClick={() => startCreate("dir")} disabled={busy}>
573
+ <FolderPlusIcon className="size-3.5" />
574
+ </ToolbarButton>
575
+ <ToolbarButton
576
+ label="Rename"
577
+ onClick={() => {
578
+ const node = cursor ? findNode(result.tree, cursor) : undefined;
579
+ if (node) startRename(node);
580
+ }}
581
+ disabled={busy || !cursor}
582
+ >
583
+ <PencilIcon className="size-3.5" />
584
+ </ToolbarButton>
585
+ <ToolbarButton
586
+ label="Delete"
587
+ onClick={() => {
588
+ const node = cursor ? findNode(result.tree, cursor) : undefined;
589
+ if (node) void runDelete(node);
590
+ }}
591
+ disabled={busy || !cursor}
592
+ >
593
+ <Trash2Icon className="size-3.5" />
594
+ </ToolbarButton>
595
+ </>
596
+ ) : null}
601
597
  <span className="ml-auto" />
602
598
  <ToolbarButton label="Refresh" onClick={() => void result.refresh()} disabled={busy || result.loading}>
603
599
  <RefreshCwIcon className={cn("size-3.5", result.loading && "animate-spin")} />
@@ -633,7 +629,7 @@ export function FileBrowser({
633
629
  }
634
630
  className={cn(
635
631
  "min-w-0 flex-1 overflow-auto p-1 outline-none",
636
- dragOver === "" && "ring-1 ring-inset ring-[color:var(--og-color-accent,var(--color-info,#58a6ff))]",
632
+ dragOver === "" && "ring-1 ring-inset ring-og-accent",
637
633
  )}
638
634
  data-opengeni-file-tree
639
635
  >
@@ -647,9 +643,22 @@ export function FileBrowser({
647
643
  onCancel={() => setDraftCreate(null)}
648
644
  />
649
645
  )}
650
- {showEmpty ? (
651
- <div className="p-2 text-xs text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]">
652
- {emptyState ?? "No files."}
646
+ {showErrorEmpty ? (
647
+ <div className="flex flex-col items-start gap-2 p-2 text-og-sm text-og-fg-subtle">
648
+ <div>{fallback ?? `Could not load files: ${result.error?.message ?? "refresh the file list to try again"}`}</div>
649
+ <button
650
+ type="button"
651
+ onClick={() => void result.refresh()}
652
+ disabled={result.loading}
653
+ className="inline-flex items-center gap-1.5 rounded-og-sm border border-og-border px-2 py-1 text-og-xs font-medium text-og-fg-muted transition-colors hover:border-og-border-strong hover:text-og-fg disabled:cursor-not-allowed disabled:opacity-50 pointer-coarse:min-h-10"
654
+ >
655
+ <RefreshCwIcon className={cn("size-3.5", result.loading && "animate-spin")} aria-hidden />
656
+ Retry
657
+ </button>
658
+ </div>
659
+ ) : showEmpty ? (
660
+ <div className="p-2 text-og-sm text-og-fg-subtle">
661
+ {emptyState ?? "This directory is empty"}
653
662
  </div>
654
663
  ) : (
655
664
  result.tree.map((node) => renderRow(node, 0))
@@ -690,8 +699,8 @@ function ToolbarButton({
690
699
  onClick={onClick}
691
700
  disabled={disabled}
692
701
  className={cn(
693
- "inline-flex items-center justify-center rounded p-1 text-[color:var(--og-color-fg-muted,var(--color-fg-muted,#aaa))]",
694
- "hover:bg-[color:var(--og-color-surface-2,var(--color-bg-subtle,#1c1c1c))] hover:text-[color:var(--og-color-fg,var(--color-fg,#e6e6e6))]",
702
+ "inline-flex items-center justify-center rounded-og-sm p-1 text-og-fg-muted pointer-coarse:min-h-10 pointer-coarse:min-w-10",
703
+ "hover:bg-og-surface-2 hover:text-og-fg",
695
704
  "disabled:cursor-not-allowed disabled:opacity-40",
696
705
  )}
697
706
  >
@@ -759,8 +768,8 @@ function InlineInput({
759
768
  e.stopPropagation();
760
769
  }}
761
770
  className={cn(
762
- "min-w-0 flex-1 rounded border bg-[color:var(--og-color-bg,var(--color-bg,#0d0d0d))] px-1 py-0 text-xs",
763
- "border-[color:var(--og-color-accent,var(--color-info,#58a6ff))] text-[color:var(--og-color-fg,var(--color-fg,#e6e6e6))]",
771
+ "min-w-0 flex-1 rounded-og-sm border bg-og-bg px-1 py-0 text-og-sm",
772
+ "border-og-accent text-og-fg",
764
773
  "outline-none",
765
774
  )}
766
775
  />
@@ -801,11 +810,9 @@ function ContextMenu({
801
810
  onClick();
802
811
  }}
803
812
  className={cn(
804
- "flex w-full items-center gap-2 px-2.5 py-1 text-left text-xs",
805
- "hover:bg-[color:var(--og-color-surface-2,var(--color-bg-subtle,#1c1c1c))]",
806
- danger
807
- ? "text-[color:var(--og-color-danger,var(--color-danger,#f85149))]"
808
- : "text-[color:var(--og-color-fg,var(--color-fg,#e6e6e6))]",
813
+ "flex w-full items-center gap-2 px-2.5 py-1 text-left text-og-sm pointer-coarse:min-h-10",
814
+ "hover:bg-og-surface-2",
815
+ danger ? "text-og-status-failed" : "text-og-fg",
809
816
  )}
810
817
  >
811
818
  {icon}
@@ -820,15 +827,15 @@ function ContextMenu({
820
827
  style={{ left, top }}
821
828
  className={cn(
822
829
  "fixed z-50 min-w-[160px] overflow-hidden rounded-md border py-1 shadow-lg",
823
- "border-[color:var(--og-color-border,var(--color-border,#2a2a2a))]",
824
- "bg-[color:var(--og-color-surface-1,var(--color-surface,#161616))]",
830
+ "border-og-border",
831
+ "bg-og-surface-1",
825
832
  )}
826
833
  >
827
834
  {isDir && (
828
835
  <>
829
836
  {item("New file", <FilePlusIcon className="size-3.5" />, onNewFile)}
830
837
  {item("New folder", <FolderPlusIcon className="size-3.5" />, onNewFolder)}
831
- <div className="my-1 h-px bg-[color:var(--og-color-border,var(--color-border,#2a2a2a))]" />
838
+ <div className="my-1 h-px bg-og-border" />
832
839
  </>
833
840
  )}
834
841
  {item("Rename", <PencilIcon className="size-3.5" />, onRename)}
@@ -60,11 +60,11 @@ export function FleetTile({ session, title, subtitle, onOpen, className }: Fleet
60
60
  )}
61
61
  />
62
62
  <span className="flex w-full items-start justify-between gap-3">
63
- <span className="line-clamp-2 min-w-0 text-sm font-medium leading-snug text-og-fg">{title ?? sessionDisplayTitle(session)}</span>
63
+ <span className="line-clamp-2 min-w-0 text-og-base font-medium leading-snug text-og-fg">{title ?? sessionDisplayTitle(session)}</span>
64
64
  <SessionStatus status={session.status} size="sm" className="mt-px" />
65
65
  </span>
66
- {subtitle ? <span className="line-clamp-1 text-xs text-og-fg-muted">{subtitle}</span> : null}
67
- <span className="mt-auto flex w-full items-center gap-2 text-[11px] text-og-fg-subtle">
66
+ {subtitle ? <span className="line-clamp-1 text-og-sm text-og-fg-muted">{subtitle}</span> : null}
67
+ <span className="mt-auto flex w-full items-center gap-2 text-og-xs text-og-fg-subtle">
68
68
  <span className="font-og-mono">{session.id.slice(0, 8)}</span>
69
69
  <span aria-hidden>·</span>
70
70
  <span className="truncate">{session.model}</span>
@@ -60,17 +60,17 @@ export function MachineCard({ machine, onAttach, attaching, className }: Machine
60
60
  </span>
61
61
  <div className="min-w-0">
62
62
  <div className="flex items-center gap-2">
63
- <span className="truncate text-sm font-medium text-og-fg">{machine.name}</span>
63
+ <span className="truncate text-og-base font-medium text-og-fg">{machine.name}</span>
64
64
  {machine.active ? (
65
65
  <span
66
66
  data-active-marker
67
- className="shrink-0 rounded-full border border-og-accent/30 bg-og-accent-soft px-1.5 py-px text-[10px] font-medium text-og-accent"
67
+ className="shrink-0 rounded-full border border-og-accent/30 bg-og-accent-soft px-1.5 py-px text-og-xs font-medium text-og-accent"
68
68
  >
69
69
  Active
70
70
  </span>
71
71
  ) : null}
72
72
  </div>
73
- <div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] text-og-fg-subtle">
73
+ <div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-og-xs text-og-fg-subtle">
74
74
  <span className="capitalize">{machine.isSessionGroup ? "session sandbox" : machine.kind}</span>
75
75
  <span aria-hidden>·</span>
76
76
  <span className="font-og-mono">
@@ -94,7 +94,7 @@ export function MachineCard({ machine, onAttach, attaching, className }: Machine
94
94
  {shared ? (
95
95
  <p
96
96
  data-shared-disclosure
97
- className="flex items-center gap-1.5 rounded-og-md border border-og-accent/25 bg-og-accent-soft px-2.5 py-1.5 text-[11px] text-og-fg-muted"
97
+ className="flex items-center gap-1.5 rounded-og-md border border-og-accent/25 bg-og-accent-soft px-2.5 py-1.5 text-og-xs text-og-fg-muted"
98
98
  >
99
99
  <UsersIcon className="size-3.5 shrink-0 text-og-accent" aria-hidden />
100
100
  Shared — {machine.sharedSessionCount} sessions are on this machine.
@@ -103,7 +103,7 @@ export function MachineCard({ machine, onAttach, attaching, className }: Machine
103
103
 
104
104
  <MachineMetrics metrics={machine.metrics} />
105
105
 
106
- <div className="mt-1 flex items-center justify-between gap-3 text-[11px] text-og-fg-subtle">
106
+ <div className="mt-1 flex items-center justify-between gap-3 text-og-xs text-og-fg-subtle">
107
107
  <span>
108
108
  {machine.lastSeenAt ? <>Last seen {formatRelativeTime(machine.lastSeenAt)}</> : "Never connected"}
109
109
  </span>
@@ -116,7 +116,7 @@ export function MachineCard({ machine, onAttach, attaching, className }: Machine
116
116
  disabled={attaching}
117
117
  onClick={() => onAttach?.(machine)}
118
118
  className={cn(
119
- "rounded-og-sm border border-og-border px-2.5 py-1 text-[11px] font-medium text-og-fg-muted transition-colors",
119
+ "rounded-og-sm border border-og-border px-2.5 py-1 text-og-xs font-medium text-og-fg-muted transition-colors pointer-coarse:min-h-10",
120
120
  "hover:border-og-border-strong hover:text-og-fg disabled:cursor-not-allowed disabled:opacity-50",
121
121
  )}
122
122
  >
@@ -80,7 +80,7 @@ export function ConnectionStatusPill({ status, label, size = "md", className }:
80
80
  data-connection-status={status}
81
81
  className={cn(
82
82
  "og-root inline-flex shrink-0 items-center rounded-full border font-medium",
83
- size === "sm" ? "gap-1 px-1.5 py-px text-[10px]" : "gap-1.5 px-2 py-0.5 text-xs",
83
+ size === "sm" ? "gap-1 px-1.5 py-px text-og-xs" : "gap-1.5 px-2 py-0.5 text-og-sm",
84
84
  meta.badgeClassName,
85
85
  className,
86
86
  )}
@@ -132,7 +132,7 @@ export function MachineStatusPill({ state, sharedSessionCount, size = "md", clas
132
132
  data-state-badge={state}
133
133
  className={cn(
134
134
  "inline-flex shrink-0 items-center rounded-full border font-medium",
135
- size === "sm" ? "px-1.5 py-px text-[10px]" : "px-2 py-0.5 text-xs",
135
+ size === "sm" ? "px-1.5 py-px text-og-xs" : "px-2 py-0.5 text-og-sm",
136
136
  stateBadge.badgeClassName,
137
137
  )}
138
138
  >
@@ -145,7 +145,7 @@ export function MachineStatusPill({ state, sharedSessionCount, size = "md", clas
145
145
  className={cn(
146
146
  "inline-flex shrink-0 items-center gap-1 rounded-full border font-medium",
147
147
  "text-og-accent border-og-accent/30 bg-og-accent-soft",
148
- size === "sm" ? "px-1.5 py-px text-[10px]" : "px-2 py-0.5 text-xs",
148
+ size === "sm" ? "px-1.5 py-px text-og-xs" : "px-2 py-0.5 text-og-sm",
149
149
  )}
150
150
  title={`${sharedSessionCount} sessions are on this machine`}
151
151
  >
@@ -30,8 +30,8 @@ function EmptyState({ onEnroll }: { onEnroll?: (() => void) | undefined }) {
30
30
  <LaptopIcon className="size-5" aria-hidden />
31
31
  </span>
32
32
  <div className="space-y-1">
33
- <p className="text-sm font-medium text-og-fg">No machines yet</p>
34
- <p className="max-w-xs text-[12px] text-og-fg-muted">
33
+ <p className="text-og-base font-medium text-og-fg">No machines yet</p>
34
+ <p className="max-w-xs text-og-sm text-og-fg-muted">
35
35
  Enroll your own computer to run the agent on it — your files, your terminal, your desktop.
36
36
  </p>
37
37
  </div>
@@ -40,7 +40,7 @@ function EmptyState({ onEnroll }: { onEnroll?: (() => void) | undefined }) {
40
40
  type="button"
41
41
  data-enroll-cta
42
42
  onClick={onEnroll}
43
- className="inline-flex items-center gap-1.5 rounded-og-sm bg-og-accent px-3 py-1.5 text-xs font-medium text-og-accent-fg transition-colors hover:bg-og-accent-strong"
43
+ className="inline-flex items-center gap-1.5 rounded-og-sm bg-og-accent px-3 py-1.5 text-og-sm font-medium text-og-accent-fg transition-colors hover:bg-og-accent-strong pointer-coarse:min-h-10"
44
44
  >
45
45
  <PlusIcon className="size-3.5" aria-hidden />
46
46
  Enroll a machine
@@ -54,16 +54,18 @@ function Header({
54
54
  count,
55
55
  onEnroll,
56
56
  onRefresh,
57
+ loading,
57
58
  }: {
58
59
  count: number;
59
60
  onEnroll?: (() => void) | undefined;
60
61
  onRefresh?: (() => void) | undefined;
62
+ loading?: boolean | undefined;
61
63
  }): ReactNode {
62
64
  return (
63
65
  <div className="flex items-center justify-between gap-3">
64
66
  <div className="flex items-baseline gap-2">
65
- <h2 className="text-sm font-semibold text-og-fg">Machines</h2>
66
- <span className="font-og-mono text-[11px] text-og-fg-subtle">{count}</span>
67
+ <h2 className="text-og-base font-semibold text-og-fg">Machines</h2>
68
+ <span className="font-og-mono text-og-xs text-og-fg-subtle">{count}</span>
67
69
  </div>
68
70
  <div className="flex items-center gap-1.5">
69
71
  {onRefresh ? (
@@ -74,7 +76,7 @@ function Header({
74
76
  title="Refresh"
75
77
  className="rounded-og-sm p-1.5 text-og-fg-subtle transition-colors hover:bg-og-surface-2 hover:text-og-fg"
76
78
  >
77
- <RefreshCwIcon className="size-3.5" aria-hidden />
79
+ <RefreshCwIcon className={cn("size-3.5", loading && "animate-og-spin")} aria-hidden />
78
80
  </button>
79
81
  ) : null}
80
82
  {onEnroll ? (
@@ -82,10 +84,10 @@ function Header({
82
84
  type="button"
83
85
  data-enroll-cta
84
86
  onClick={onEnroll}
85
- className="inline-flex items-center gap-1.5 rounded-og-sm border border-og-border px-2.5 py-1 text-xs font-medium text-og-fg-muted transition-colors hover:border-og-border-strong hover:text-og-fg"
87
+ className="inline-flex items-center gap-1.5 rounded-og-sm border border-og-border px-2.5 py-1 text-og-sm font-medium text-og-fg-muted transition-colors hover:border-og-border-strong hover:text-og-fg pointer-coarse:min-h-10"
86
88
  >
87
89
  <PlusIcon className="size-3.5" aria-hidden />
88
- Enroll
90
+ Enroll machine
89
91
  </button>
90
92
  ) : null}
91
93
  </div>
@@ -95,7 +97,7 @@ function Header({
95
97
 
96
98
  /**
97
99
  * The workspace Machines dashboard: the fleet of selfhosted enrollments + the
98
- * session's Modal sandbox, each with its connection-status pill, state badges,
100
+ * session's managed sandbox, each with its connection-status pill, state badges,
99
101
  * latest metrics, and an attach/swap affordance. Renders the empty state (no
100
102
  * machines → enroll CTA), a load error, and the populated grid. The component
101
103
  * is purely presentational — feed it `MachinesResponse` data via `useMachines`.
@@ -115,15 +117,27 @@ export function MachinesDashboard({
115
117
 
116
118
  return (
117
119
  <section data-machines-dashboard className={cn("og-root flex flex-col gap-3", className)}>
118
- <Header count={machines.length} onEnroll={onEnroll} onRefresh={onRefresh} />
120
+ <Header count={machines.length} onEnroll={onEnroll} onRefresh={onRefresh} loading={loading} />
119
121
 
120
122
  {error ? (
121
- <p
123
+ <div
122
124
  data-machines-error
123
- className="rounded-og-md border border-og-status-failed/30 bg-og-status-failed/10 px-3 py-2 text-[12px] text-og-status-failed"
125
+ className="flex flex-wrap items-center justify-between gap-2 rounded-og-md border border-og-status-failed/30 bg-og-status-failed/10 px-3 py-2 text-og-sm text-og-status-failed"
124
126
  >
125
- Could not load machines: {error.message}
126
- </p>
127
+ <span>Could not load machines: {error.message}</span>
128
+ {onRefresh ? (
129
+ <button
130
+ type="button"
131
+ data-machines-retry
132
+ onClick={onRefresh}
133
+ disabled={loading}
134
+ className="inline-flex items-center gap-1.5 rounded-og-sm border border-og-status-failed/30 px-2 py-1 text-og-xs font-medium transition-colors hover:border-og-status-failed/50 disabled:cursor-not-allowed disabled:opacity-60 pointer-coarse:min-h-10"
135
+ >
136
+ <RefreshCwIcon className={cn("size-3.5", loading && "animate-og-spin")} aria-hidden />
137
+ {loading ? "Retrying…" : "Retry"}
138
+ </button>
139
+ ) : null}
140
+ </div>
127
141
  ) : null}
128
142
 
129
143
  {loading && machines.length === 0 ? (
@@ -36,12 +36,12 @@ const components: Components = {
36
36
  </h2>
37
37
  ),
38
38
  h3: ({ children, ...props }) => (
39
- <h3 className="mt-4 mb-1.5 text-[15px] font-semibold tracking-tight text-og-fg first:mt-0" {...props}>
39
+ <h3 className="mt-4 mb-1.5 text-og-md font-semibold tracking-tight text-og-fg first:mt-0" {...props}>
40
40
  {children}
41
41
  </h3>
42
42
  ),
43
43
  h4: ({ children, ...props }) => (
44
- <h4 className="mt-4 mb-1.5 text-sm font-semibold uppercase tracking-[0.04em] text-og-fg-muted first:mt-0" {...props}>
44
+ <h4 className="mt-4 mb-1.5 text-og-sm font-semibold uppercase tracking-[0.04em] text-og-fg-muted first:mt-0" {...props}>
45
45
  {children}
46
46
  </h4>
47
47
  ),
@@ -94,7 +94,7 @@ const components: Components = {
94
94
  {...props}
95
95
  type="checkbox"
96
96
  disabled
97
- className="mr-2 size-3.5 translate-y-[2px] cursor-default appearance-none rounded-[3px] border border-og-border bg-og-surface-1 align-baseline checked:border-og-accent checked:bg-og-accent checked:[background-image:url('data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2012%2012%22%3E%3Cpath%20fill%3D%22none%22%20stroke%3D%22white%22%20stroke-width%3D%221.6%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20d%3D%22M2.5%206.2l2.2%202.2%204.6-4.8%22%2F%3E%3C%2Fsvg%3E')] checked:bg-[length:11px_11px] checked:bg-center checked:bg-no-repeat"
97
+ className="mr-2 size-3.5 translate-y-[2px] cursor-default accent-og-accent align-baseline"
98
98
  />
99
99
  ) : (
100
100
  <input type={type} {...props} />
@@ -113,7 +113,7 @@ const components: Components = {
113
113
  // the `pre` renderer), so a `code` reaching here is treated as inline.
114
114
  code: ({ children, className: _className, ...props }) => (
115
115
  <code
116
- className="rounded-og-xs border border-og-border bg-og-surface-1 px-1 py-0.5 font-og-mono text-[0.85em] text-og-fg"
116
+ className="rounded-og-xs border border-og-border bg-og-surface-1 px-1 py-0.5 font-og-mono text-og-sm text-og-fg"
117
117
  {...props}
118
118
  >
119
119
  {children}
@@ -123,7 +123,7 @@ const components: Components = {
123
123
  // visual consistency (bordered, scrollable, mono, surface background).
124
124
  pre: ({ children, ...props }) => (
125
125
  <pre
126
- className="my-3 max-h-96 overflow-auto rounded-og-md border border-og-border bg-og-bg/60 p-3 font-og-mono text-[12.5px] leading-5 text-og-fg-muted [&>code]:border-0 [&>code]:bg-transparent [&>code]:p-0 [&>code]:text-inherit first:mt-0 last:mb-0"
126
+ className="my-3 max-h-96 overflow-auto rounded-og-md border border-og-border bg-og-bg/60 p-3 font-og-mono text-og-sm text-og-fg-muted [&>code]:border-0 [&>code]:bg-transparent [&>code]:p-0 [&>code]:text-inherit first:mt-0 last:mb-0"
127
127
  {...props}
128
128
  >
129
129
  {children}
@@ -131,7 +131,7 @@ const components: Components = {
131
131
  ),
132
132
  table: ({ children, ...props }) => (
133
133
  <div className="my-3 max-w-full overflow-x-auto rounded-og-md border border-og-border first:mt-0 last:mb-0">
134
- <table className="w-full border-collapse text-[13px]" {...props}>
134
+ <table className="w-full border-collapse text-og-base" {...props}>
135
135
  {children}
136
136
  </table>
137
137
  </div>
@@ -11,7 +11,7 @@ import {
11
11
  } from "lucide-react";
12
12
  import type { ComponentType } from "react";
13
13
  import { AnimatePresence, motion } from "motion/react";
14
- import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
14
+ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
15
15
  import { cn } from "../lib/cn";
16
16
  import { formatRelativeTime, truncate } from "../lib/format";
17
17
  import { Markdown } from "./markdown";
@@ -78,10 +78,38 @@ export function MessageTimeline({
78
78
 
79
79
  const scrollRef = useRef<HTMLDivElement | null>(null);
80
80
  const [pinned, setPinned] = useState(true);
81
+ // Mirror `pinned` into a ref so the ResizeObserver callback (a stable closure)
82
+ // always reads the live value without re-subscribing on every scroll.
83
+ const pinnedRef = useRef(true);
84
+ // The reader's visual anchor: the topmost still-visible timeline element and
85
+ // its offset from the viewport top. Recaptured on scroll and after every
86
+ // height change, it lets us hold the reader's position when content above the
87
+ // viewport expands or collapses (e.g. a turn folds when it settles).
88
+ const anchorRef = useRef<{ el: Element; top: number } | null>(null);
81
89
  const lastItem = resolvedItems[resolvedItems.length - 1];
82
90
  const streaming = lastItem !== undefined && (lastItem.kind === "agent-message" || lastItem.kind === "reasoning") && lastItem.streaming;
83
91
  const working = status === "running" && !streaming;
84
92
 
93
+ // Snapshot the topmost visible element and where it sits in the viewport, so a
94
+ // later reflow can restore it to the same spot.
95
+ const captureAnchor = useCallback(() => {
96
+ const node = scrollRef.current;
97
+ const inner = node?.firstElementChild;
98
+ if (!node || !inner) {
99
+ anchorRef.current = null;
100
+ return;
101
+ }
102
+ const containerTop = node.getBoundingClientRect().top;
103
+ for (const child of Array.from(inner.children)) {
104
+ const rect = child.getBoundingClientRect();
105
+ if (rect.bottom > containerTop + 1) {
106
+ anchorRef.current = { el: child, top: rect.top - containerTop };
107
+ return;
108
+ }
109
+ }
110
+ anchorRef.current = null;
111
+ }, []);
112
+
85
113
  // Follow the stream while pinned to the bottom; never fight the reader.
86
114
  useEffect(() => {
87
115
  const node = scrollRef.current;
@@ -90,12 +118,51 @@ export function MessageTimeline({
90
118
  }
91
119
  }, [resolvedItems, working, autoFollow, pinned]);
92
120
 
121
+ // Scroll anchoring: when the content reflows (a fold expands/collapses, a
122
+ // stream appends), keep following the bottom if pinned; otherwise pin the
123
+ // reader's anchor in place. A change ABOVE the anchor shifts its viewport
124
+ // offset — we correct scrollTop by that shift so the reader never gets yanked.
125
+ // A change BELOW the anchor (a bottom append while scrolled up) leaves the
126
+ // anchor put, so `diff` is 0 and we leave scrollTop alone.
127
+ useEffect(() => {
128
+ const node = scrollRef.current;
129
+ const inner = node?.firstElementChild;
130
+ if (!node || !inner || typeof ResizeObserver === "undefined") {
131
+ return;
132
+ }
133
+ const observer = new ResizeObserver(() => {
134
+ const current = scrollRef.current;
135
+ if (!current) {
136
+ return;
137
+ }
138
+ if (autoFollow && pinnedRef.current) {
139
+ current.scrollTop = current.scrollHeight;
140
+ } else {
141
+ const anchor = anchorRef.current;
142
+ if (anchor && anchor.el.isConnected) {
143
+ const containerTop = current.getBoundingClientRect().top;
144
+ const now = anchor.el.getBoundingClientRect().top - containerTop;
145
+ const diff = now - anchor.top;
146
+ if (diff !== 0) {
147
+ current.scrollTop += diff;
148
+ }
149
+ }
150
+ }
151
+ captureAnchor();
152
+ });
153
+ observer.observe(inner);
154
+ return () => observer.disconnect();
155
+ }, [autoFollow, captureAnchor]);
156
+
93
157
  const onScroll = () => {
94
158
  const node = scrollRef.current;
95
159
  if (!node) {
96
160
  return;
97
161
  }
98
- setPinned(node.scrollHeight - node.scrollTop - node.clientHeight < 48);
162
+ const nextPinned = node.scrollHeight - node.scrollTop - node.clientHeight < 48;
163
+ pinnedRef.current = nextPinned;
164
+ setPinned(nextPinned);
165
+ captureAnchor();
99
166
  };
100
167
 
101
168
  return (
@@ -135,6 +202,7 @@ export function MessageTimeline({
135
202
  if (node) {
136
203
  node.scrollTo({ top: node.scrollHeight, behavior: "smooth" });
137
204
  }
205
+ pinnedRef.current = true;
138
206
  setPinned(true);
139
207
  }}
140
208
  className={cn(
@@ -287,8 +355,11 @@ function UserMessageRow({
287
355
  }) {
288
356
  return (
289
357
  <div className="animate-og-enter flex justify-end">
290
- <div className="max-w-[85%] 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">
291
- {renderMessageText ? renderMessageText(item.text, item) : <Markdown>{item.text}</Markdown>}
358
+ <div className="flex max-w-[85%] min-w-0 flex-col items-end gap-1">
359
+ {item.pending ? <span className="px-1 text-og-xs text-og-fg-subtle">queued</span> : null}
360
+ <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">
361
+ {renderMessageText ? renderMessageText(item.text, item) : <Markdown>{item.text}</Markdown>}
362
+ </div>
292
363
  </div>
293
364
  </div>
294
365
  );
@@ -98,16 +98,15 @@ export function PierreDiff({
98
98
 
99
99
  // Pierre renders inside a shadow DOM, so host CSS can't reach it — but it reads
100
100
  // a set of `--diffs-*-override` custom properties through the shadow boundary.
101
- // Pin the diff's own base background to the dock surface (Pierre's dark default
102
- // is pure #000, which reads as a seam against our #0d0d0d panel) and quiet the
103
- // hunk-separator slab so the collapsed-context row isn't a heavy gray bar.
101
+ // Pin the diff's own base background to the dock surface and quiet the hunk
102
+ // separator slab so collapsed context rows do not become heavy bars.
104
103
  const pierreVars = {
105
- "--diffs-dark-bg": "var(--og-color-bg, #0d0d0d)",
106
- "--diffs-light-bg": "var(--og-color-bg, #ffffff)",
107
- "--diffs-bg-buffer-override": "var(--og-color-surface-1, #161616)",
108
- "--diffs-bg-separator-override": "var(--og-color-surface-1, #161616)",
109
- "--diffs-font-size": "12.5px",
110
- "--diffs-line-height": "20px",
104
+ "--diffs-dark-bg": "var(--og-color-bg)",
105
+ "--diffs-light-bg": "var(--og-color-bg)",
106
+ "--diffs-bg-buffer-override": "var(--og-color-surface-1)",
107
+ "--diffs-bg-separator-override": "var(--og-color-surface-1)",
108
+ "--diffs-font-size": "var(--og-code-font-size)",
109
+ "--diffs-line-height": "var(--og-code-line-height)",
111
110
  } as CSSProperties;
112
111
 
113
112
  return (
@@ -133,7 +132,7 @@ export function PierreDiff({
133
132
 
134
133
  function DiffSkeleton() {
135
134
  return (
136
- <div className="p-3 text-xs text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]">
135
+ <div className="p-3 text-og-sm text-og-fg-subtle">
137
136
  Loading diff…
138
137
  </div>
139
138
  );