@opengeni/react 0.6.2 → 0.8.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 (45) hide show
  1. package/README.md +17 -6
  2. package/dist/{chunk-DEW2ZNF2.js → chunk-5C7RGAWA.js} +70 -37
  3. package/dist/chunk-5C7RGAWA.js.map +1 -0
  4. package/dist/index.d.ts +45 -9
  5. package/dist/index.js +1385 -732
  6. package/dist/index.js.map +1 -1
  7. package/dist/{machines-BD6h9P_s.d.ts → machines-BeZ3bD3t.d.ts} +2 -2
  8. package/dist/machines.d.ts +1 -1
  9. package/dist/machines.js +1 -1
  10. package/package.json +2 -2
  11. package/src/client.ts +1 -0
  12. package/src/components/chat-composer.tsx +127 -60
  13. package/src/components/code-editor.tsx +15 -15
  14. package/src/components/desktop-viewer.tsx +40 -36
  15. package/src/components/diff-view.tsx +22 -22
  16. package/src/components/enrollment-consent.tsx +13 -13
  17. package/src/components/file-browser.tsx +74 -67
  18. package/src/components/fleet-tile.tsx +3 -3
  19. package/src/components/machine-card.tsx +6 -6
  20. package/src/components/machine-status-pill.tsx +3 -3
  21. package/src/components/machines-dashboard.tsx +28 -14
  22. package/src/components/markdown.tsx +6 -6
  23. package/src/components/message-timeline.tsx +207 -12
  24. package/src/components/pierre-diff.tsx +9 -10
  25. package/src/components/pierre-file.tsx +8 -8
  26. package/src/components/sandbox-files.tsx +37 -40
  27. package/src/components/sandbox-terminal.tsx +9 -11
  28. package/src/components/session-status.tsx +2 -2
  29. package/src/components/workspace-dock.tsx +153 -49
  30. package/src/hooks/use-file-attachments.ts +45 -15
  31. package/src/hooks/use-session-events.ts +283 -8
  32. package/src/index.ts +2 -2
  33. package/src/lib/format.ts +32 -0
  34. package/src/lib/xterm-theme.ts +8 -11
  35. package/src/timeline/activity-rail.tsx +5 -2
  36. package/src/timeline/entrance.tsx +30 -0
  37. package/src/timeline/parsers.ts +104 -0
  38. package/src/timeline/projection.ts +22 -5
  39. package/src/timeline/screenshot-lightbox.tsx +1 -1
  40. package/src/timeline/shared.tsx +4 -1
  41. package/src/timeline/tool-diff.tsx +1 -1
  42. package/src/timeline/tool-renderers.tsx +46 -7
  43. package/src/timeline/turn-summary.tsx +24 -4
  44. package/styles/tokens.css +8 -0
  45. package/dist/chunk-DEW2ZNF2.js.map +0 -1
@@ -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, useLayoutEffect, 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";
@@ -32,6 +32,7 @@ import {
32
32
  TurnSummary,
33
33
  } from "../timeline";
34
34
  import { SESSION_STATUS_META, StatusDot } from "./session-status";
35
+ import { EntranceAnimationProvider, useEntranceAnimation } from "../timeline/entrance";
35
36
 
36
37
  export type MessageTimelineProps = {
37
38
  /** Raw session events (projected internally) … */
@@ -52,6 +53,12 @@ export type MessageTimelineProps = {
52
53
  toolRegistry?: ToolRegistry | undefined;
53
54
  /** Follow new events when pinned to the bottom. Defaults to true. */
54
55
  autoFollow?: boolean | undefined;
56
+ /** Older durable history exists above the current window (see useSessionEvents). */
57
+ hasOlder?: boolean | undefined;
58
+ /** An older window is being fetched; shows the quiet top shimmer. */
59
+ loadingOlder?: boolean | undefined;
60
+ /** Called when the reader nears the top and older history should backfill. */
61
+ onLoadOlder?: (() => void) | undefined;
55
62
  emptyState?: ReactNode | undefined;
56
63
  className?: string | undefined;
57
64
  };
@@ -70,42 +77,208 @@ export function MessageTimeline({
70
77
  onOpenSession,
71
78
  toolRegistry = defaultToolRegistry,
72
79
  autoFollow = true,
80
+ hasOlder = false,
81
+ loadingOlder = false,
82
+ onLoadOlder,
73
83
  emptyState,
74
84
  className,
75
85
  }: MessageTimelineProps) {
76
86
  const resolvedItems = useMemo(() => items ?? buildTimeline(events ?? []), [items, events]);
77
87
  const groups = useMemo(() => groupTimeline(resolvedItems), [resolvedItems]);
88
+ const firstGroupKey = groups[0] ? timelineGroupKey(groups[0]) : null;
78
89
 
79
90
  const scrollRef = useRef<HTMLDivElement | null>(null);
91
+ const topSentinelRef = useRef<HTMLDivElement | null>(null);
92
+ const previousBulkFirstKeyRef = useRef<string | null | undefined>(undefined);
80
93
  const [pinned, setPinned] = useState(true);
94
+ const [bulkActive, setBulkActive] = useState(true);
95
+ // Content stays invisible until its first bottom-anchored frame, so a flash
96
+ // of the window's TOP while a large timeline lays out across commits is
97
+ // structurally impossible — the reader only ever sees it already at the
98
+ // bottom. An empty timeline reveals immediately (there is nothing to anchor).
99
+ const [revealed, setRevealed] = useState(false);
100
+ // Our own scrollTop assignments echo back as scroll events; those must never
101
+ // UNPIN the reader (they are not reader intent). Marked around every
102
+ // programmatic assignment and consumed by onScroll. When an assignment is a
103
+ // NO-OP (already at the target) no scroll event will fire, so the mark must
104
+ // self-clear — a stale mark would eat the reader's next real scroll-up.
105
+ const programmaticScrollRef = useRef(false);
106
+ const assignScrollTop = useCallback((node: HTMLElement, value: number) => {
107
+ const previous = node.scrollTop;
108
+ programmaticScrollRef.current = true;
109
+ node.scrollTop = value;
110
+ if (node.scrollTop === previous) {
111
+ programmaticScrollRef.current = false;
112
+ }
113
+ }, []);
114
+ // Mirror `pinned` into a ref so the ResizeObserver callback (a stable closure)
115
+ // always reads the live value without re-subscribing on every scroll.
116
+ const pinnedRef = useRef(true);
117
+ // The reader's visual anchor: the topmost still-visible timeline element and
118
+ // its offset from the viewport top. Recaptured on scroll and after every
119
+ // height change, it lets us hold the reader's position when content above the
120
+ // viewport expands or collapses (e.g. a turn folds when it settles).
121
+ const anchorRef = useRef<{ el: Element; top: number } | null>(null);
81
122
  const lastItem = resolvedItems[resolvedItems.length - 1];
82
123
  const streaming = lastItem !== undefined && (lastItem.kind === "agent-message" || lastItem.kind === "reasoning") && lastItem.streaming;
83
124
  const working = status === "running" && !streaming;
125
+ // Bulk paints (the initial tail window, a prepended older window — detected
126
+ // by the first group key changing) must not run per-row entrance animations.
127
+ const firstKeyChangedForBulk = previousBulkFirstKeyRef.current !== undefined && previousBulkFirstKeyRef.current !== firstGroupKey;
128
+ const bulkRender = groups.length > 0 && (bulkActive || firstKeyChangedForBulk);
129
+
130
+ // Snapshot the topmost visible element and where it sits in the viewport, so a
131
+ // later reflow can restore it to the same spot. Transient chrome (the backfill
132
+ // sentinel and shimmer) is skipped — anchoring to a row that unmounts when the
133
+ // older window lands would drop the correction mid-prepend.
134
+ const captureAnchor = useCallback(() => {
135
+ const node = scrollRef.current;
136
+ const inner = node?.firstElementChild;
137
+ if (!node || !inner) {
138
+ anchorRef.current = null;
139
+ return;
140
+ }
141
+ const containerTop = node.getBoundingClientRect().top;
142
+ for (const child of Array.from(inner.children)) {
143
+ if (child instanceof HTMLElement && child.dataset.ogTimelineChrome !== undefined) {
144
+ continue;
145
+ }
146
+ const rect = child.getBoundingClientRect();
147
+ if (rect.bottom > containerTop + 1) {
148
+ anchorRef.current = { el: child, top: rect.top - containerTop };
149
+ return;
150
+ }
151
+ }
152
+ anchorRef.current = null;
153
+ }, []);
84
154
 
85
155
  // Follow the stream while pinned to the bottom; never fight the reader.
86
- useEffect(() => {
156
+ // A LAYOUT effect so the very first paint of a freshly loaded session is
157
+ // already anchored at the bottom — no visible traversal down the history.
158
+ useLayoutEffect(() => {
87
159
  const node = scrollRef.current;
88
160
  if (node && autoFollow && pinned) {
89
- node.scrollTop = node.scrollHeight;
161
+ assignScrollTop(node, node.scrollHeight);
162
+ }
163
+ if (!revealed && groups.length > 0) {
164
+ setRevealed(true);
90
165
  }
91
- }, [resolvedItems, working, autoFollow, pinned]);
166
+ }, [resolvedItems, working, autoFollow, pinned, revealed, groups.length, assignScrollTop]);
167
+
168
+ // A cleared timeline (stream identity change) re-arms the reveal so the next
169
+ // session also first paints at its bottom.
170
+ useLayoutEffect(() => {
171
+ if (groups.length === 0 && revealed) {
172
+ setRevealed(false);
173
+ }
174
+ }, [groups.length, revealed]);
175
+
176
+ // Clear the bulk-paint marker a frame after it renders, so rows appended
177
+ // live (streams, new turns) animate exactly as before.
178
+ useLayoutEffect(() => {
179
+ previousBulkFirstKeyRef.current = firstGroupKey;
180
+ if (!bulkRender) {
181
+ return;
182
+ }
183
+ setBulkActive(true);
184
+ const frame = requestFrame(() => setBulkActive(false));
185
+ return () => cancelFrame(frame);
186
+ }, [bulkRender, firstGroupKey]);
187
+
188
+ // Prefetch older history well before the reader reaches the top: the
189
+ // sentinel sits above the first group and trips 1600px early, so backfill
190
+ // is usually rendered (and anchored by the ResizeObserver below) before the
191
+ // top of the window ever becomes visible.
192
+ useEffect(() => {
193
+ const root = scrollRef.current;
194
+ const target = topSentinelRef.current;
195
+ if (!root || !target || !hasOlder || loadingOlder || !onLoadOlder || typeof IntersectionObserver === "undefined") {
196
+ return;
197
+ }
198
+ const observer = new IntersectionObserver((entries) => {
199
+ if (entries.some((entry) => entry.isIntersecting)) {
200
+ onLoadOlder();
201
+ }
202
+ }, { root, rootMargin: "1600px 0px 0px 0px" });
203
+ observer.observe(target);
204
+ return () => observer.disconnect();
205
+ }, [hasOlder, loadingOlder, onLoadOlder, firstGroupKey]);
206
+
207
+ // Scroll anchoring: when the content reflows (a fold expands/collapses, a
208
+ // stream appends), keep following the bottom if pinned; otherwise pin the
209
+ // reader's anchor in place. A change ABOVE the anchor shifts its viewport
210
+ // offset — we correct scrollTop by that shift so the reader never gets yanked.
211
+ // A change BELOW the anchor (a bottom append while scrolled up) leaves the
212
+ // anchor put, so `diff` is 0 and we leave scrollTop alone.
213
+ useEffect(() => {
214
+ const node = scrollRef.current;
215
+ const inner = node?.firstElementChild;
216
+ if (!node || !inner || typeof ResizeObserver === "undefined") {
217
+ return;
218
+ }
219
+ const observer = new ResizeObserver(() => {
220
+ const current = scrollRef.current;
221
+ if (!current) {
222
+ return;
223
+ }
224
+ if (autoFollow && pinnedRef.current) {
225
+ assignScrollTop(current, current.scrollHeight);
226
+ } else {
227
+ const anchor = anchorRef.current;
228
+ if (anchor && anchor.el.isConnected) {
229
+ const containerTop = current.getBoundingClientRect().top;
230
+ const now = anchor.el.getBoundingClientRect().top - containerTop;
231
+ const diff = now - anchor.top;
232
+ if (diff !== 0) {
233
+ assignScrollTop(current, current.scrollTop + diff);
234
+ }
235
+ }
236
+ }
237
+ captureAnchor();
238
+ });
239
+ observer.observe(inner);
240
+ return () => observer.disconnect();
241
+ }, [autoFollow, captureAnchor, assignScrollTop]);
92
242
 
93
243
  const onScroll = () => {
94
244
  const node = scrollRef.current;
95
245
  if (!node) {
96
246
  return;
97
247
  }
98
- setPinned(node.scrollHeight - node.scrollTop - node.clientHeight < 48);
248
+ const programmatic = programmaticScrollRef.current;
249
+ programmaticScrollRef.current = false;
250
+ const nextPinned = node.scrollHeight - node.scrollTop - node.clientHeight < 48;
251
+ // Echoes of our own assignments may PIN but never UNPIN — only the reader
252
+ // scrolling away releases the bottom-follow.
253
+ if (nextPinned || !programmatic) {
254
+ pinnedRef.current = nextPinned;
255
+ setPinned(nextPinned);
256
+ }
257
+ captureAnchor();
99
258
  };
100
259
 
101
260
  return (
102
261
  <LightboxProvider>
262
+ <EntranceAnimationProvider value={!bulkRender}>
103
263
  <div className={cn("og-root relative flex min-h-0 flex-col", className)}>
104
- <div ref={scrollRef} onScroll={onScroll} className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-6 sm:px-6">
264
+ {/* overflow-anchor off: the browser's native scroll anchoring would fight
265
+ the ResizeObserver corrections above — one authority only. */}
266
+ <div
267
+ ref={scrollRef}
268
+ onScroll={onScroll}
269
+ style={groups.length > 0 && !revealed ? { visibility: "hidden" } : undefined}
270
+ className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-6 [overflow-anchor:none] sm:px-6"
271
+ >
105
272
  <div className="mx-auto flex w-full max-w-3xl flex-col gap-5">
106
273
  {groups.length === 0 && !working
107
274
  ? (emptyState ?? <p className="py-10 text-center text-sm text-og-fg-subtle">No activity yet.</p>)
108
275
  : null}
276
+ {hasOlder ? <div ref={topSentinelRef} data-og-top-sentinel="" data-og-timeline-chrome="" aria-hidden="true" className="h-px w-full shrink-0" /> : null}
277
+ {loadingOlder ? (
278
+ <div data-og-timeline-chrome="" className="flex items-center gap-2 text-sm">
279
+ <span className="og-shimmer-text font-medium">Loading earlier activity…</span>
280
+ </div>
281
+ ) : null}
109
282
  {groups.map((group) => (
110
283
  <TimelineGroupView
111
284
  key={timelineGroupKey(group)}
@@ -116,7 +289,7 @@ export function MessageTimeline({
116
289
  />
117
290
  ))}
118
291
  {working ? (
119
- <div className="animate-og-enter flex items-center gap-2 text-sm">
292
+ <div className="flex items-center gap-2 text-sm">
120
293
  <span className="og-shimmer-text font-medium">Working…</span>
121
294
  </div>
122
295
  ) : null}
@@ -135,6 +308,7 @@ export function MessageTimeline({
135
308
  if (node) {
136
309
  node.scrollTo({ top: node.scrollHeight, behavior: "smooth" });
137
310
  }
311
+ pinnedRef.current = true;
138
312
  setPinned(true);
139
313
  }}
140
314
  className={cn(
@@ -150,10 +324,26 @@ export function MessageTimeline({
150
324
  ) : null}
151
325
  </AnimatePresence>
152
326
  </div>
327
+ </EntranceAnimationProvider>
153
328
  </LightboxProvider>
154
329
  );
155
330
  }
156
331
 
332
+ function requestFrame(callback: FrameRequestCallback): number {
333
+ if (typeof requestAnimationFrame === "function") {
334
+ return requestAnimationFrame(callback);
335
+ }
336
+ return window.setTimeout(() => callback(performance.now()), 16);
337
+ }
338
+
339
+ function cancelFrame(id: number): void {
340
+ if (typeof cancelAnimationFrame === "function") {
341
+ cancelAnimationFrame(id);
342
+ return;
343
+ }
344
+ window.clearTimeout(id);
345
+ }
346
+
157
347
  function TimelineGroupView({
158
348
  group,
159
349
  renderMessageText,
@@ -285,8 +475,9 @@ function UserMessageRow({
285
475
  item: UserMessageItem;
286
476
  renderMessageText?: ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode) | undefined;
287
477
  }) {
478
+ const enter = useEntranceAnimation();
288
479
  return (
289
- <div className="animate-og-enter flex justify-end">
480
+ <div className={cn(enter && "animate-og-enter", "flex justify-end")}>
290
481
  <div className="flex max-w-[85%] min-w-0 flex-col items-end gap-1">
291
482
  {item.pending ? <span className="px-1 text-og-xs text-og-fg-subtle">queued</span> : null}
292
483
  <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">
@@ -304,11 +495,12 @@ function AgentMessageRow({
304
495
  item: AgentMessageItem;
305
496
  renderMessageText?: ((text: string, item: AgentMessageItem | UserMessageItem) => ReactNode) | undefined;
306
497
  }) {
498
+ const enter = useEntranceAnimation();
307
499
  const caret = item.streaming ? (
308
500
  <span className="ml-0.5 inline-block h-[1.1em] w-[2px] translate-y-[3px] animate-og-blink rounded-full bg-og-accent" aria-hidden />
309
501
  ) : null;
310
502
  return (
311
- <div className="animate-og-enter min-w-0 text-og-md leading-7 text-og-fg">
503
+ <div className={cn(enter && "animate-og-enter", "min-w-0 text-og-md leading-7 text-og-fg")}>
312
504
  {renderMessageText ? (
313
505
  <>
314
506
  {renderMessageText(item.text, item)}
@@ -328,9 +520,10 @@ function AgentMessageRow({
328
520
  }
329
521
 
330
522
  function SessionStatusRow({ item }: { item: { status: SessionStatus; occurredAt: string } }) {
523
+ const enter = useEntranceAnimation();
331
524
  const meta = SESSION_STATUS_META[item.status];
332
525
  return (
333
- <div className="animate-og-enter flex items-center gap-3 text-og-xs text-og-fg-subtle" role="status">
526
+ <div className={cn(enter && "animate-og-enter", "flex items-center gap-3 text-og-xs text-og-fg-subtle")} role="status">
334
527
  <span className="h-px flex-1 bg-og-border" />
335
528
  <span className="inline-flex items-center gap-1.5">
336
529
  <StatusDot status={item.status} className="size-1" />
@@ -377,9 +570,10 @@ const GOAL_META: Record<GoalItem["action"], GoalMeta> = {
377
570
  * palette stays restrained — see that table for the per-action rationale.
378
571
  */
379
572
  function GoalRow({ item }: { item: GoalItem }) {
573
+ const enter = useEntranceAnimation();
380
574
  const { label, pill, icon: Icon } = GOAL_META[item.action];
381
575
  return (
382
- <div className="animate-og-enter flex justify-center">
576
+ <div className={cn(enter && "animate-og-enter", "flex justify-center")}>
383
577
  <span className={cn("inline-flex max-w-full items-center gap-1.5 rounded-full border px-3 py-1 text-og-sm", pill)}>
384
578
  <Icon className="size-3.5 shrink-0" />
385
579
  <span className="truncate">
@@ -392,6 +586,7 @@ function GoalRow({ item }: { item: GoalItem }) {
392
586
  }
393
587
 
394
588
  function NoticeRow({ item }: { item: NoticeItem }) {
589
+ const enter = useEntranceAnimation();
395
590
  const tone =
396
591
  item.tone === "failed"
397
592
  ? "border-og-status-failed/35 bg-og-status-failed/10 text-og-status-failed"
@@ -399,7 +594,7 @@ function NoticeRow({ item }: { item: NoticeItem }) {
399
594
  ? "border-og-status-waiting/35 bg-og-status-waiting/10 text-og-status-waiting"
400
595
  : "border-og-border bg-og-surface-1 text-og-fg-muted";
401
596
  return (
402
- <div className={cn("animate-og-enter flex items-start gap-2.5 rounded-og-md border px-3.5 py-2.5 text-sm", tone)} role="status">
597
+ <div className={cn(enter && "animate-og-enter", "flex items-start gap-2.5 rounded-og-md border px-3.5 py-2.5 text-sm", tone)} role="status">
403
598
  <TriangleAlertIcon className={cn("mt-0.5 size-4 shrink-0", item.tone === "cancelled" && "opacity-60")} />
404
599
  <span className="min-w-0 whitespace-pre-wrap break-words">{item.text}</span>
405
600
  </div>
@@ -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
  );
@@ -99,12 +99,12 @@ export function PierreFile({
99
99
  // Same shadow-DOM override vars as PierreDiff: pin the base background to the
100
100
  // dock surface so the viewer reads as part of the panel, not a black seam.
101
101
  const pierreVars = {
102
- "--diffs-dark-bg": "var(--og-color-bg, #0d0d0d)",
103
- "--diffs-light-bg": "var(--og-color-bg, #ffffff)",
104
- "--diffs-bg-buffer-override": "var(--og-color-surface-1, #161616)",
105
- "--diffs-bg-separator-override": "var(--og-color-surface-1, #161616)",
106
- "--diffs-font-size": "12.5px",
107
- "--diffs-line-height": "20px",
102
+ "--diffs-dark-bg": "var(--og-color-bg)",
103
+ "--diffs-light-bg": "var(--og-color-bg)",
104
+ "--diffs-bg-buffer-override": "var(--og-color-surface-1)",
105
+ "--diffs-bg-separator-override": "var(--og-color-surface-1)",
106
+ "--diffs-font-size": "var(--og-code-font-size)",
107
+ "--diffs-line-height": "var(--og-code-line-height)",
108
108
  } as CSSProperties;
109
109
 
110
110
  return (
@@ -125,7 +125,7 @@ export function PierreFile({
125
125
  function PlainFile({ name, contents }: { name: string; contents: string }) {
126
126
  return (
127
127
  <pre
128
- className="overflow-auto whitespace-pre p-2 font-[family-name:var(--og-font-mono,var(--font-mono,monospace))] text-[12px] leading-[18px] text-[color:var(--og-color-fg,var(--color-fg,#e6e6e6))]"
128
+ className="overflow-auto whitespace-pre p-2 font-og-mono text-og-sm text-og-fg"
129
129
  data-file={name}
130
130
  >
131
131
  {contents}
@@ -135,7 +135,7 @@ function PlainFile({ name, contents }: { name: string; contents: string }) {
135
135
 
136
136
  function FileSkeleton() {
137
137
  return (
138
- <div className="p-3 text-xs text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]">
138
+ <div className="p-3 text-og-sm text-og-fg-subtle">
139
139
  Loading file…
140
140
  </div>
141
141
  );
@@ -30,15 +30,15 @@ export type SandboxFilesProps = {
30
30
  };
31
31
 
32
32
  const STATUS_TINT: Record<GitFileDiff["status"], string> = {
33
- added: "text-[color:var(--og-color-status-idle,var(--color-success,#3fb950))]",
34
- modified: "text-[color:var(--og-color-status-running,var(--color-warning,#d29922))]",
35
- deleted: "text-[color:var(--og-color-danger,var(--color-danger,#f85149))]",
36
- renamed: "text-[color:var(--og-color-accent,var(--color-info,#58a6ff))]",
37
- copied: "text-[color:var(--og-color-accent,var(--color-info,#58a6ff))]",
38
- untracked: "text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]",
39
- ignored: "text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]",
40
- conflicted: "text-[color:var(--og-color-danger,var(--color-danger,#f85149))]",
41
- typechange: "text-[color:var(--og-color-status-running,var(--color-warning,#d29922))]",
33
+ added: "text-og-status-idle",
34
+ modified: "text-og-status-running",
35
+ deleted: "text-og-status-failed",
36
+ renamed: "text-og-accent",
37
+ copied: "text-og-accent",
38
+ untracked: "text-og-fg-subtle",
39
+ ignored: "text-og-fg-subtle",
40
+ conflicted: "text-og-status-failed",
41
+ typechange: "text-og-status-running",
42
42
  };
43
43
 
44
44
  const STATUS_LETTER: Record<GitFileDiff["status"], string> = {
@@ -158,13 +158,13 @@ export function SandboxFiles({
158
158
  className={cn(
159
159
  "min-h-0 overflow-auto",
160
160
  wide
161
- ? "w-[280px] shrink-0 border-r border-[color:var(--og-color-border,var(--color-border,#2a2a2a))]"
161
+ ? "w-[280px] shrink-0 border-r border-og-border"
162
162
  : "flex-1",
163
163
  )}
164
164
  >
165
165
  {changed.length > 0 && (
166
- <div className="border-b border-[color:var(--og-color-border,var(--color-border,#2a2a2a))]">
167
- <div className="px-2 py-1 text-[10px] font-medium uppercase tracking-wide text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]">
166
+ <div className="border-b border-og-border">
167
+ <div className="px-2 py-1 text-og-xs font-medium uppercase tracking-wide text-og-fg-subtle">
168
168
  Changes · {changed.length}
169
169
  </div>
170
170
  <ul className="pb-1">
@@ -174,14 +174,13 @@ export function SandboxFiles({
174
174
  type="button"
175
175
  onClick={() => selectFile(file.path)}
176
176
  className={cn(
177
- "flex w-full items-center gap-1.5 truncate px-2 py-0.5 text-left text-xs hover:bg-[color:var(--og-color-surface-2,var(--color-bg-subtle,#1c1c1c))]",
178
- file.path === effectiveSelected &&
179
- "bg-[color:var(--og-color-surface-2,var(--color-bg-subtle,#1c1c1c))]",
177
+ "flex w-full items-center gap-1.5 truncate px-2 py-0.5 text-left text-og-sm hover:bg-og-surface-2 pointer-coarse:min-h-10",
178
+ file.path === effectiveSelected && "bg-og-surface-2",
180
179
  )}
181
180
  >
182
181
  <span
183
182
  className={cn(
184
- "w-3 shrink-0 text-center font-[family-name:var(--og-font-mono,var(--font-mono,monospace))] text-[10px]",
183
+ "w-3 shrink-0 text-center font-og-mono text-og-xs",
185
184
  STATUS_TINT[file.status],
186
185
  )}
187
186
  >
@@ -189,11 +188,11 @@ export function SandboxFiles({
189
188
  </span>
190
189
  <FileIcon className="size-3.5 shrink-0 opacity-70" />
191
190
  <span className="truncate">{file.path}</span>
192
- <span className="ml-auto flex shrink-0 items-center gap-1.5 pl-2 text-[10px]">
193
- <span className="text-[color:var(--og-color-status-idle,var(--color-success,#3fb950))]">
191
+ <span className="ml-auto flex shrink-0 items-center gap-1.5 pl-2 text-og-xs">
192
+ <span className="text-og-status-idle">
194
193
  +{file.additions}
195
194
  </span>
196
- <span className="text-[color:var(--og-color-danger,var(--color-danger,#f85149))]">
195
+ <span className="text-og-status-failed">
197
196
  −{file.deletions}
198
197
  </span>
199
198
  </span>
@@ -204,14 +203,14 @@ export function SandboxFiles({
204
203
  </div>
205
204
  )}
206
205
 
207
- <div className="px-2 py-1 text-[10px] font-medium uppercase tracking-wide text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]">
206
+ <div className="px-2 py-1 text-og-xs font-medium uppercase tracking-wide text-og-fg-subtle">
208
207
  Files
209
208
  </div>
210
209
  <FileBrowser
211
210
  result={files}
212
211
  selectedPath={effectiveSelected ?? undefined}
213
212
  onSelectFile={selectFile}
214
- emptyState="No files."
213
+ emptyState="This directory is empty"
215
214
  className="min-w-0"
216
215
  />
217
216
  </div>
@@ -223,14 +222,14 @@ export function SandboxFiles({
223
222
  "flex min-h-0 min-w-0 flex-col",
224
223
  wide
225
224
  ? "flex-1"
226
- : "flex-[1.4] border-t border-[color:var(--og-color-border,var(--color-border,#2a2a2a))]",
225
+ : "flex-[1.4] border-t border-og-border",
227
226
  )}
228
227
  >
229
- <div className="flex shrink-0 items-center justify-between gap-2 border-b border-[color:var(--og-color-border,var(--color-border,#2a2a2a))] bg-[color:var(--og-color-surface-1,var(--color-surface,#161616))] px-2 py-1">
230
- <span className="truncate font-[family-name:var(--og-font-mono,var(--font-mono,monospace))] text-[11px] text-[color:var(--og-color-fg-muted,var(--color-fg-muted,#aaa))]">
228
+ <div className="flex shrink-0 items-center justify-between gap-2 border-b border-og-border bg-og-surface-1 px-2 py-1">
229
+ <span className="min-w-0 truncate font-og-mono text-og-xs text-og-fg-muted">
231
230
  {effectiveSelected ?? "No file selected"}
232
231
  </span>
233
- <div className="flex shrink-0 items-center gap-1">
232
+ <div className="flex min-w-0 shrink-0 flex-wrap items-center justify-end gap-1">
234
233
  {/* The Working/Staged + Unified/Split toggles only apply to a diff.
235
234
  Hide them in the read-only viewer (a non-changed tree file). */}
236
235
  {selectedDiff && stagedGit && (
@@ -306,7 +305,7 @@ export function SandboxFiles({
306
305
  ) : fileView.content !== null ? (
307
306
  <>
308
307
  {fileView.truncated && (
309
- <div className="border-b border-[color:var(--og-color-border,var(--color-border,#2a2a2a))] bg-[color:var(--og-color-surface-1,var(--color-surface,#161616))] px-2 py-1 text-[10px] text-[color:var(--og-color-status-running,var(--color-warning,#d29922))]">
308
+ <div className="border-b border-og-border bg-og-surface-1 px-2 py-1 text-og-xs text-og-status-running">
310
309
  Large file — showing a truncated preview ({fileView.sizeBytes ?? 0} bytes loaded). Editing is disabled to avoid corrupting the file.
311
310
  </div>
312
311
  )}
@@ -316,14 +315,14 @@ export function SandboxFiles({
316
315
  contents={fileView.content}
317
316
  themeType={resolvedTheme}
318
317
  fallback={
319
- <pre className="overflow-auto whitespace-pre p-2 font-[family-name:var(--og-font-mono,var(--font-mono,monospace))] text-[12px] leading-[18px]">
318
+ <pre className="overflow-auto whitespace-pre p-2 font-og-mono text-og-sm text-og-fg">
320
319
  {fileView.content}
321
320
  </pre>
322
321
  }
323
322
  className="p-1"
324
323
  />
325
324
  ) : (
326
- <pre className="overflow-auto whitespace-pre p-2 font-[family-name:var(--og-font-mono,var(--font-mono,monospace))] text-[12px] leading-[18px]">
325
+ <pre className="overflow-auto whitespace-pre p-2 font-og-mono text-og-sm text-og-fg">
327
326
  {fileView.content}
328
327
  </pre>
329
328
  )}
@@ -350,27 +349,25 @@ export function SandboxFiles({
350
349
  function GitHeader({ git, dirtyCount }: { git: UseSandboxGitResult; dirtyCount: number }) {
351
350
  const dirty = dirtyCount > 0;
352
351
  return (
353
- <div className="flex shrink-0 items-center gap-2 border-b border-[color:var(--og-color-border,var(--color-border,#2a2a2a))] bg-[color:var(--og-color-surface-1,var(--color-surface,#161616))] px-2 py-1 text-xs">
352
+ <div className="flex shrink-0 items-center gap-2 border-b border-og-border bg-og-surface-1 px-2 py-1 text-og-sm">
354
353
  <span
355
354
  className={cn(
356
355
  "size-2 shrink-0 rounded-full",
357
- dirty
358
- ? "bg-[color:var(--og-color-status-running,var(--color-warning,#d29922))]"
359
- : "bg-[color:var(--og-color-status-idle,var(--color-success,#3fb950))]",
356
+ dirty ? "bg-og-status-running" : "bg-og-status-idle",
360
357
  )}
361
358
  title={dirty ? `${dirtyCount} changed` : "clean"}
362
359
  />
363
- <span className="truncate font-[family-name:var(--og-font-mono,var(--font-mono,monospace))] text-[color:var(--og-color-fg,var(--color-fg,#e6e6e6))]">
360
+ <span className="truncate font-og-mono text-og-fg">
364
361
  {git.branch ?? (git.isRepo ? "(detached)" : "no repo")}
365
362
  </span>
366
363
  {(git.ahead > 0 || git.behind > 0) && (
367
- <span className="flex shrink-0 items-center gap-1.5 text-[10px] text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]">
364
+ <span className="flex shrink-0 items-center gap-1.5 text-og-xs text-og-fg-subtle">
368
365
  {git.ahead > 0 && <span>↑{git.ahead}</span>}
369
366
  {git.behind > 0 && <span>↓{git.behind}</span>}
370
367
  </span>
371
368
  )}
372
369
  {dirty && (
373
- <span className="ml-auto shrink-0 text-[10px] text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]">
370
+ <span className="ml-auto shrink-0 text-og-xs text-og-fg-subtle">
374
371
  {dirtyCount} changed
375
372
  </span>
376
373
  )}
@@ -388,17 +385,17 @@ function Segmented({
388
385
  onChange: (value: string) => void;
389
386
  }) {
390
387
  return (
391
- <div className="flex items-center rounded-[var(--og-radius-sm,4px)] border border-[color:var(--og-color-border,var(--color-border,#2a2a2a))] p-0.5">
388
+ <div className="flex flex-wrap items-center rounded-og-sm border border-og-border p-0.5">
392
389
  {options.map((opt) => (
393
390
  <button
394
391
  key={opt.value}
395
392
  type="button"
396
393
  onClick={() => onChange(opt.value)}
397
394
  className={cn(
398
- "rounded-[var(--og-radius-xs,3px)] px-1.5 py-0.5 text-[10px]",
395
+ "rounded-og-xs px-1.5 py-0.5 text-og-xs pointer-coarse:min-h-10",
399
396
  opt.value === value
400
- ? "bg-[color:var(--og-color-accent-soft,var(--color-surface-2,#222))] text-[color:var(--og-color-fg,var(--color-fg,#e6e6e6))]"
401
- : "text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))] hover:text-[color:var(--og-color-fg,var(--color-fg,#e6e6e6))]",
397
+ ? "bg-og-accent-soft text-og-fg"
398
+ : "text-og-fg-subtle hover:text-og-fg",
402
399
  )}
403
400
  >
404
401
  {opt.label}
@@ -499,7 +496,7 @@ function Notice({ children, className }: { children: ReactNode; className?: stri
499
496
  return (
500
497
  <div
501
498
  className={cn(
502
- "flex h-full items-center justify-center p-4 text-center text-xs text-[color:var(--og-color-fg-subtle,var(--color-fg-subtle,#888))]",
499
+ "flex h-full items-center justify-center p-4 text-center text-og-sm text-og-fg-subtle",
503
500
  className,
504
501
  )}
505
502
  >