@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.
- package/README.md +17 -6
- package/dist/{chunk-DEW2ZNF2.js → chunk-5C7RGAWA.js} +70 -37
- package/dist/chunk-5C7RGAWA.js.map +1 -0
- package/dist/index.d.ts +45 -9
- package/dist/index.js +1385 -732
- package/dist/index.js.map +1 -1
- package/dist/{machines-BD6h9P_s.d.ts → machines-BeZ3bD3t.d.ts} +2 -2
- package/dist/machines.d.ts +1 -1
- package/dist/machines.js +1 -1
- package/package.json +2 -2
- package/src/client.ts +1 -0
- package/src/components/chat-composer.tsx +127 -60
- package/src/components/code-editor.tsx +15 -15
- package/src/components/desktop-viewer.tsx +40 -36
- package/src/components/diff-view.tsx +22 -22
- package/src/components/enrollment-consent.tsx +13 -13
- package/src/components/file-browser.tsx +74 -67
- package/src/components/fleet-tile.tsx +3 -3
- package/src/components/machine-card.tsx +6 -6
- package/src/components/machine-status-pill.tsx +3 -3
- package/src/components/machines-dashboard.tsx +28 -14
- package/src/components/markdown.tsx +6 -6
- package/src/components/message-timeline.tsx +207 -12
- package/src/components/pierre-diff.tsx +9 -10
- package/src/components/pierre-file.tsx +8 -8
- package/src/components/sandbox-files.tsx +37 -40
- package/src/components/sandbox-terminal.tsx +9 -11
- package/src/components/session-status.tsx +2 -2
- package/src/components/workspace-dock.tsx +153 -49
- package/src/hooks/use-file-attachments.ts +45 -15
- package/src/hooks/use-session-events.ts +283 -8
- package/src/index.ts +2 -2
- package/src/lib/format.ts +32 -0
- package/src/lib/xterm-theme.ts +8 -11
- package/src/timeline/activity-rail.tsx +5 -2
- package/src/timeline/entrance.tsx +30 -0
- package/src/timeline/parsers.ts +104 -0
- package/src/timeline/projection.ts +22 -5
- package/src/timeline/screenshot-lightbox.tsx +1 -1
- package/src/timeline/shared.tsx +4 -1
- package/src/timeline/tool-diff.tsx +1 -1
- package/src/timeline/tool-renderers.tsx +46 -7
- package/src/timeline/turn-summary.tsx +24 -4
- package/styles/tokens.css +8 -0
- 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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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="
|
|
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
|
|
102
|
-
//
|
|
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
|
|
106
|
-
"--diffs-light-bg": "var(--og-color-bg
|
|
107
|
-
"--diffs-bg-buffer-override": "var(--og-color-surface-1
|
|
108
|
-
"--diffs-bg-separator-override": "var(--og-color-surface-1
|
|
109
|
-
"--diffs-font-size": "
|
|
110
|
-
"--diffs-line-height": "
|
|
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-
|
|
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
|
|
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": "
|
|
107
|
-
"--diffs-line-height": "
|
|
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-
|
|
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-
|
|
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-
|
|
34
|
-
modified: "text-
|
|
35
|
-
deleted: "text-
|
|
36
|
-
renamed: "text-
|
|
37
|
-
copied: "text-
|
|
38
|
-
untracked: "text-
|
|
39
|
-
ignored: "text-
|
|
40
|
-
conflicted: "text-
|
|
41
|
-
typechange: "text-
|
|
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-
|
|
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-
|
|
167
|
-
<div className="px-2 py-1 text-
|
|
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-
|
|
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-
|
|
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-
|
|
193
|
-
<span className="text-
|
|
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-
|
|
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-
|
|
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="
|
|
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-
|
|
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-
|
|
230
|
-
<span className="truncate font-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
401
|
-
: "text-
|
|
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-
|
|
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
|
>
|