@opengeni/react 0.12.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -14
- package/dist/chunk-TOJR776I.js +2280 -0
- package/dist/chunk-TOJR776I.js.map +1 -0
- package/dist/index.d.ts +1040 -363
- package/dist/index.js +10938 -6281
- package/dist/index.js.map +1 -1
- package/dist/{machines-BeZ3bD3t.d.ts → machines-BpdwuQcD.d.ts} +134 -14
- package/dist/machines.d.ts +1 -1
- package/dist/machines.js +23 -1
- package/package.json +20 -12
- package/src/approvals.ts +16 -4
- package/src/client.ts +41 -5
- package/src/commands/index.ts +1 -7
- package/src/commands/registry.ts +46 -12
- package/src/components/chat-composer.tsx +417 -69
- package/src/components/code-editor.tsx +28 -36
- package/src/components/command-palette.tsx +26 -5
- package/src/components/desktop-viewer.tsx +29 -16
- package/src/components/diff-view.tsx +27 -189
- package/src/components/enrollment-consent.tsx +28 -10
- package/src/components/enrollment-device-flow.tsx +8 -5
- package/src/components/file-browser.tsx +190 -56
- package/src/components/fleet-tile.tsx +13 -4
- package/src/components/machine-card.tsx +93 -15
- package/src/components/machine-dock-bar.tsx +20 -8
- package/src/components/machine-health-pill.tsx +68 -0
- package/src/components/machine-metrics.tsx +28 -19
- package/src/components/machine-status-pill.tsx +26 -5
- package/src/components/machines/health.ts +146 -0
- package/src/components/machines/machine-detail.tsx +220 -0
- package/src/components/machines/metric-history-chart.tsx +298 -0
- package/src/components/machines/metric-sparkline.tsx +76 -0
- package/src/components/machines/series.ts +113 -0
- package/src/components/machines-dashboard.tsx +21 -3
- package/src/components/markdown.tsx +39 -9
- package/src/components/message-timeline.tsx +633 -109
- package/src/components/pierre-diff.tsx +83 -15
- package/src/components/pierre-file.tsx +10 -9
- package/src/components/queue-surface.tsx +578 -0
- package/src/components/sandbox-files.tsx +193 -225
- package/src/components/sandbox-terminal.tsx +256 -88
- package/src/components/sandbox-workspace.tsx +957 -0
- package/src/components/session-status.tsx +22 -2
- package/src/components/workbench-changes.tsx +585 -0
- package/src/components/workspace-dock.tsx +213 -59
- package/src/hooks/internal.ts +5 -2
- package/src/hooks/use-available-models.ts +7 -2
- package/src/hooks/use-billing-usage.ts +4 -1
- package/src/hooks/use-codex-accounts.ts +74 -44
- package/src/hooks/use-composer.ts +414 -63
- package/src/hooks/use-environments.ts +92 -47
- package/src/hooks/use-file-attachments.ts +101 -55
- package/src/hooks/use-goal.ts +52 -16
- package/src/hooks/use-machine-chip.ts +134 -0
- package/src/hooks/use-machines.ts +34 -16
- package/src/hooks/use-packs.ts +24 -19
- package/src/hooks/use-relay-frame-stream.ts +5 -2
- package/src/hooks/use-rigs.ts +335 -0
- package/src/hooks/use-sandbox-files.ts +213 -39
- package/src/hooks/use-sandbox-git.ts +188 -11
- package/src/hooks/use-sandbox-terminal.ts +18 -14
- package/src/hooks/use-scheduled-tasks.ts +10 -2
- package/src/hooks/use-session-capabilities.ts +77 -20
- package/src/hooks/use-session-control.ts +50 -23
- package/src/hooks/use-session-events.ts +50 -21
- package/src/hooks/use-session-lineage.ts +128 -0
- package/src/hooks/use-session.ts +53 -30
- package/src/hooks/use-slash-commands.ts +6 -4
- package/src/hooks/use-turn-queue.ts +202 -147
- package/src/hooks/use-windowed-sections.ts +204 -0
- package/src/hooks/use-workspace-capture.ts +233 -0
- package/src/hooks/use-workspace-edit.ts +231 -0
- package/src/hooks/use-workspace-sessions.ts +48 -5
- package/src/hooks/use-workspaces.ts +18 -15
- package/src/index.ts +133 -23
- package/src/lib/format.ts +3 -3
- package/src/lib/xterm-renderer.ts +65 -0
- package/src/lib/xterm-theme.ts +224 -18
- package/src/machines.ts +29 -3
- package/src/provider.tsx +195 -6
- package/src/timeline/activity-rail.tsx +213 -54
- package/src/timeline/disclosure-context.tsx +12 -2
- package/src/timeline/index.ts +21 -2
- package/src/timeline/parsers.ts +87 -16
- package/src/timeline/projection.ts +412 -87
- package/src/timeline/shared.tsx +111 -24
- package/src/timeline/tool-diff.tsx +24 -20
- package/src/timeline/tool-renderers.tsx +98 -21
- package/src/timeline/turn-summary.tsx +76 -23
- package/src/timeline/types.ts +96 -12
- package/styles/index.css +22 -0
- package/styles/tokens.css +2 -2
- package/dist/chunk-5TIXRCSI.js +0 -1291
- package/dist/chunk-5TIXRCSI.js.map +0 -1
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
GetWorkspaceCaptureResponse,
|
|
3
|
+
SessionEvent,
|
|
4
|
+
WorkspaceCaptureDegradedReason,
|
|
5
|
+
WorkspaceCaptureManifest,
|
|
6
|
+
WorkspaceRevisionCapturedPayload,
|
|
7
|
+
WorkspaceRevisionDegradedPayload,
|
|
8
|
+
} from "@opengeni/sdk";
|
|
9
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
10
|
+
import { useOpenGeni, type ClientOverride } from "../provider";
|
|
11
|
+
|
|
12
|
+
/** The announce event M1 emits at turn end (metadata only, never content). */
|
|
13
|
+
const REVISION_CAPTURED = "workspace.revision.captured";
|
|
14
|
+
const REVISION_DEGRADED = "workspace.revision.degraded";
|
|
15
|
+
const MANIFEST_FETCH_TIMEOUT_MS = 15_000;
|
|
16
|
+
|
|
17
|
+
export type UseWorkspaceCaptureOptions = ClientOverride & {
|
|
18
|
+
/** Live event log (usually `useSessionEvents().events`) — a
|
|
19
|
+
* `workspace.revision.captured` for a NEWER revision refreshes the manifest. */
|
|
20
|
+
events?: SessionEvent[] | undefined;
|
|
21
|
+
/** Hold off the mount fetch (e.g. the workbench panel is collapsed). Default true. */
|
|
22
|
+
enabled?: boolean | undefined;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type UseWorkspaceCaptureResult = {
|
|
26
|
+
/** The resolved manifest (tree index + per-repo diffs + file refs), or null when
|
|
27
|
+
* no capture exists (falls back to the live/wake path — status quo). */
|
|
28
|
+
capture: WorkspaceCaptureManifest | null;
|
|
29
|
+
/** The loaded capture's monotonic revision, or null when unavailable. */
|
|
30
|
+
revision: number | null;
|
|
31
|
+
/** When the loaded capture was taken (ISO), for the "as of <time>" source badge. */
|
|
32
|
+
capturedAt: string | null;
|
|
33
|
+
/** Whether a capture is available at all (the `{available:false}` discriminator). */
|
|
34
|
+
available: boolean;
|
|
35
|
+
/** Why the newest durable revision is unavailable. null means no capture has
|
|
36
|
+
* been attempted yet; a value means capture explicitly failed closed and
|
|
37
|
+
* consumers must use the live box rather than trust an incomplete snapshot. */
|
|
38
|
+
degradedReason: WorkspaceCaptureDegradedReason | null;
|
|
39
|
+
/** The changed-file count from the capture's stats, resolved on the FIRST GET
|
|
40
|
+
* (from the response's top-level `stats`, before any manifest-URL hop). null
|
|
41
|
+
* until that first resolve; 0 when no capture exists. This is the pre-paint
|
|
42
|
+
* "changes exist?" signal the dock uses to pick its default tab with no
|
|
43
|
+
* embedder events-at-mount contract. */
|
|
44
|
+
fileCount: number | null;
|
|
45
|
+
/** A newer revision has been ANNOUNCED than the one currently loaded (a refresh
|
|
46
|
+
* is in flight or pending). M5's source badge can show a subtle "updating…". */
|
|
47
|
+
isStale: boolean;
|
|
48
|
+
loading: boolean;
|
|
49
|
+
error: Error | null;
|
|
50
|
+
/** Force a re-fetch of the latest capture. */
|
|
51
|
+
refresh: () => Promise<void>;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The cold-paint data source: fetch the latest turn-end workspace capture with a
|
|
56
|
+
* SINGLE api round-trip on mount (no machine, no Channel-A — this is the <200ms
|
|
57
|
+
* first paint, dossier §10.4/§12-A1). The manifest is served inline in the common
|
|
58
|
+
* case; a rare >2MB manifest comes back as a short-TTL signed URL we follow.
|
|
59
|
+
*
|
|
60
|
+
* Subscribes to the live event log: a `workspace.revision.captured` for a revision
|
|
61
|
+
* newer than the one loaded triggers a background refresh (stale-while-revalidate).
|
|
62
|
+
* `{available:false}` is a value, never a crash — consumers fall back to the
|
|
63
|
+
* live/wake path exactly as before the capture feature existed.
|
|
64
|
+
*/
|
|
65
|
+
export function useWorkspaceCapture(
|
|
66
|
+
sessionId: string | null | undefined,
|
|
67
|
+
options: UseWorkspaceCaptureOptions = {},
|
|
68
|
+
): UseWorkspaceCaptureResult {
|
|
69
|
+
const { client, workspaceId } = useOpenGeni(options);
|
|
70
|
+
const enabled = (options.enabled ?? true) && Boolean(sessionId);
|
|
71
|
+
const identityKey = `${workspaceId}\u0000${sessionId ?? ""}`;
|
|
72
|
+
|
|
73
|
+
const [capture, setCapture] = useState<WorkspaceCaptureManifest | null>(null);
|
|
74
|
+
const [available, setAvailable] = useState(false);
|
|
75
|
+
const [degradedReason, setDegradedReason] = useState<WorkspaceCaptureDegradedReason | null>(null);
|
|
76
|
+
// Resolved from the FIRST GET's top-level stats (before any manifest-URL hop),
|
|
77
|
+
// so the dock can pick its default tab as early as possible. null until then.
|
|
78
|
+
const [fileCount, setFileCount] = useState<number | null>(null);
|
|
79
|
+
const [loading, setLoading] = useState(false);
|
|
80
|
+
const [error, setError] = useState<Error | null>(null);
|
|
81
|
+
// The highest revision ANNOUNCED on the event log — compared against the loaded
|
|
82
|
+
// manifest's revision to compute `isStale` and to gate refreshes.
|
|
83
|
+
const [announcedRevision, setAnnouncedRevision] = useState<number | null>(null);
|
|
84
|
+
// State updates are asynchronous, so the previous session's capture still
|
|
85
|
+
// exists during the first render after an identity switch. Tag it and hide it
|
|
86
|
+
// synchronously until the new identity's request owns the state.
|
|
87
|
+
const [stateIdentity, setStateIdentity] = useState(identityKey);
|
|
88
|
+
|
|
89
|
+
// A generation counter fences a slow in-flight fetch against a newer one (or an
|
|
90
|
+
// identity change) so a stale response can never overwrite fresher state.
|
|
91
|
+
const generationRef = useRef(0);
|
|
92
|
+
// Event sequence numbers are scoped to a session. Keeping this cursor across an
|
|
93
|
+
// identity switch would discard the new session's low-numbered announcements.
|
|
94
|
+
const lastSeqRef = useRef(0);
|
|
95
|
+
|
|
96
|
+
const refresh = useCallback(async () => {
|
|
97
|
+
if (!sessionId) return;
|
|
98
|
+
const generation = (generationRef.current += 1);
|
|
99
|
+
setStateIdentity(identityKey);
|
|
100
|
+
setLoading(true);
|
|
101
|
+
setError(null);
|
|
102
|
+
try {
|
|
103
|
+
const res: GetWorkspaceCaptureResponse = await client.getWorkspaceCapture(
|
|
104
|
+
workspaceId,
|
|
105
|
+
sessionId,
|
|
106
|
+
);
|
|
107
|
+
if (generationRef.current !== generation) return;
|
|
108
|
+
if (!res.available) {
|
|
109
|
+
setCapture(null);
|
|
110
|
+
setAvailable(false);
|
|
111
|
+
setFileCount(0);
|
|
112
|
+
setDegradedReason(res.degradedReason ?? null);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
setDegradedReason(null);
|
|
116
|
+
// Resolve the changed-file count immediately from the response's stats — the
|
|
117
|
+
// default-tab signal must not wait on a >2MB manifest-URL hop.
|
|
118
|
+
setFileCount(res.stats.fileCount);
|
|
119
|
+
// Exactly one of manifest / manifestUrl is non-null (M2 contract). The inline
|
|
120
|
+
// manifest is the <200ms common case; a >2MB manifest is a signed URL hop.
|
|
121
|
+
let manifest = res.manifest;
|
|
122
|
+
if (!manifest && res.manifestUrl) {
|
|
123
|
+
const response = await fetch(res.manifestUrl.url, {
|
|
124
|
+
signal: AbortSignal.timeout(MANIFEST_FETCH_TIMEOUT_MS),
|
|
125
|
+
});
|
|
126
|
+
if (generationRef.current !== generation) return;
|
|
127
|
+
if (!response.ok)
|
|
128
|
+
throw new Error(`workspace capture manifest fetch failed: ${response.status}`);
|
|
129
|
+
manifest = (await response.json()) as WorkspaceCaptureManifest;
|
|
130
|
+
if (generationRef.current !== generation) return;
|
|
131
|
+
}
|
|
132
|
+
if (!manifest) {
|
|
133
|
+
// available:true but neither manifest nor a working URL — degrade, never crash.
|
|
134
|
+
setCapture(null);
|
|
135
|
+
setAvailable(false);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
setCapture(manifest);
|
|
139
|
+
setAvailable(true);
|
|
140
|
+
// Fold the served revision into the announced high-water mark so a capture we
|
|
141
|
+
// JUST loaded is never reported stale against an older announce.
|
|
142
|
+
setAnnouncedRevision((prev) =>
|
|
143
|
+
prev === null || manifest.revision > prev ? manifest.revision : prev,
|
|
144
|
+
);
|
|
145
|
+
} catch (cause) {
|
|
146
|
+
if (generationRef.current !== generation) return;
|
|
147
|
+
setError(cause instanceof Error ? cause : new Error(String(cause)));
|
|
148
|
+
} finally {
|
|
149
|
+
if (generationRef.current === generation) setLoading(false);
|
|
150
|
+
}
|
|
151
|
+
}, [client, workspaceId, sessionId, identityKey]);
|
|
152
|
+
|
|
153
|
+
// Mount fetch + reset on identity change.
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
// Invalidate a request started for the previous identity before clearing its
|
|
156
|
+
// state. The cleanup also fences a response that arrives after unmount.
|
|
157
|
+
generationRef.current += 1;
|
|
158
|
+
lastSeqRef.current = 0;
|
|
159
|
+
setCapture(null);
|
|
160
|
+
setAvailable(false);
|
|
161
|
+
setDegradedReason(null);
|
|
162
|
+
setFileCount(null);
|
|
163
|
+
setAnnouncedRevision(null);
|
|
164
|
+
setError(null);
|
|
165
|
+
if (!enabled) {
|
|
166
|
+
setLoading(false);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
void refresh();
|
|
170
|
+
return () => {
|
|
171
|
+
generationRef.current += 1;
|
|
172
|
+
};
|
|
173
|
+
}, [enabled, refresh]);
|
|
174
|
+
|
|
175
|
+
// Fold `workspace.revision.captured` announcements. A revision NEWER than the one
|
|
176
|
+
// loaded bumps the announced high-water mark (→ isStale) and refreshes in the
|
|
177
|
+
// background. The event is announce-only (metadata) — we still re-fetch the
|
|
178
|
+
// manifest, since the payload carries no content.
|
|
179
|
+
const events = options.events;
|
|
180
|
+
useEffect(() => {
|
|
181
|
+
if (!enabled || !events) return;
|
|
182
|
+
let newest: number | null = null;
|
|
183
|
+
for (const event of events) {
|
|
184
|
+
if (event.sequence <= lastSeqRef.current) continue;
|
|
185
|
+
if (event.type === REVISION_CAPTURED) {
|
|
186
|
+
const payload = event.payload as WorkspaceRevisionCapturedPayload | null;
|
|
187
|
+
if (payload && typeof payload === "object" && typeof payload.revision === "number") {
|
|
188
|
+
newest = newest === null ? payload.revision : Math.max(newest, payload.revision);
|
|
189
|
+
}
|
|
190
|
+
} else if (event.type === REVISION_DEGRADED) {
|
|
191
|
+
const payload = event.payload as WorkspaceRevisionDegradedPayload | null;
|
|
192
|
+
if (payload && typeof payload === "object" && typeof payload.revision === "number") {
|
|
193
|
+
newest = newest === null ? payload.revision : Math.max(newest, payload.revision);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
for (const event of events)
|
|
198
|
+
if (event.sequence > lastSeqRef.current) lastSeqRef.current = event.sequence;
|
|
199
|
+
if (newest === null) return;
|
|
200
|
+
setAnnouncedRevision((prev) => (prev === null || newest > prev ? newest : prev));
|
|
201
|
+
}, [enabled, events]);
|
|
202
|
+
|
|
203
|
+
// A newer announced revision than the loaded manifest → refresh in the background.
|
|
204
|
+
const identityMatches = enabled && stateIdentity === identityKey;
|
|
205
|
+
const visibleCapture = identityMatches ? capture : null;
|
|
206
|
+
const visibleAnnouncedRevision = identityMatches ? announcedRevision : null;
|
|
207
|
+
const loadedRevision = visibleCapture?.revision ?? null;
|
|
208
|
+
useEffect(() => {
|
|
209
|
+
if (!enabled) return;
|
|
210
|
+
if (visibleAnnouncedRevision === null) return;
|
|
211
|
+
if (loadedRevision !== null && visibleAnnouncedRevision <= loadedRevision) return;
|
|
212
|
+
// A newer capture exists than the one we hold (or we hold none) — pull it.
|
|
213
|
+
void refresh();
|
|
214
|
+
}, [enabled, visibleAnnouncedRevision, loadedRevision, refresh]);
|
|
215
|
+
|
|
216
|
+
const isStale =
|
|
217
|
+
loadedRevision !== null &&
|
|
218
|
+
visibleAnnouncedRevision !== null &&
|
|
219
|
+
visibleAnnouncedRevision > loadedRevision;
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
capture: visibleCapture,
|
|
223
|
+
revision: loadedRevision,
|
|
224
|
+
capturedAt: visibleCapture?.capturedAt ?? null,
|
|
225
|
+
available: identityMatches && available,
|
|
226
|
+
degradedReason: identityMatches ? degradedReason : null,
|
|
227
|
+
fileCount: identityMatches ? fileCount : null,
|
|
228
|
+
isStale,
|
|
229
|
+
loading: identityMatches && loading,
|
|
230
|
+
error: identityMatches ? error : null,
|
|
231
|
+
refresh,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { useOpenGeni, type ClientOverride } from "../provider";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The wake-on-edit state machine (dossier §3 #5 / §10.4 / §12-C). M3 OWNS this
|
|
6
|
+
* logic; M5 renders it. States are explicit and exhaustive:
|
|
7
|
+
*
|
|
8
|
+
* viewing-cold — cold snapshot open, no local edits (editable, cloud).
|
|
9
|
+
* buffering — local edits buffered; the box is not warm and no warm is
|
|
10
|
+
* yet in flight (the host has not attached).
|
|
11
|
+
* warming — edits buffered; the box is actively coming up (the host
|
|
12
|
+
* attached a viewer in response to `wantsWarm`).
|
|
13
|
+
* flushing — the box is warm; the guarded write is in flight.
|
|
14
|
+
* flushed — the buffer was written to the live box.
|
|
15
|
+
* conflict — the live file diverged from the captured base between
|
|
16
|
+
* capture and flush; NO write was issued (C2).
|
|
17
|
+
* readonly-offline — a self-hosted machine that is offline; read-only, no wake.
|
|
18
|
+
*/
|
|
19
|
+
export type WorkspaceEditState =
|
|
20
|
+
| "viewing-cold"
|
|
21
|
+
| "buffering"
|
|
22
|
+
| "warming"
|
|
23
|
+
| "flushing"
|
|
24
|
+
| "flushed"
|
|
25
|
+
| "conflict"
|
|
26
|
+
| "readonly-offline";
|
|
27
|
+
|
|
28
|
+
export type WorkspaceEditConflict = {
|
|
29
|
+
path: string;
|
|
30
|
+
/** The capture-served content the edit was based on. */
|
|
31
|
+
base: string;
|
|
32
|
+
/** The live content found on the box at flush time (what would be overwritten). */
|
|
33
|
+
live: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type UseWorkspaceEditOptions = ClientOverride & {
|
|
37
|
+
/** The file being edited (workspace-relative). null disables the machine. */
|
|
38
|
+
path?: string | null | undefined;
|
|
39
|
+
/** The capture-served content loaded into the editor — the flush base (C2). */
|
|
40
|
+
baseContent?: string | null | undefined;
|
|
41
|
+
/** `capabilities.liveness` — "warm" | "draining" | "cold". Drives the flush. */
|
|
42
|
+
liveness?: string | undefined;
|
|
43
|
+
/** True while the host is actively warming the box (attach in flight, not warm
|
|
44
|
+
* yet) — lifts `buffering` → `warming`. The host sets this after acting on
|
|
45
|
+
* `wantsWarm` (e.g. flipping `attachFiles` on `useSessionCapabilities`). */
|
|
46
|
+
warming?: boolean | undefined;
|
|
47
|
+
/** A self-hosted machine that is OFFLINE: read-only, no remote wake possible. */
|
|
48
|
+
offline?: boolean | undefined;
|
|
49
|
+
/** Called once when the first cold edit needs the box warmed — the host wires
|
|
50
|
+
* this to its liveness primitive (flip `attachFiles`/`attachViewer`). */
|
|
51
|
+
onWarmRequested?: (() => void) | undefined;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type UseWorkspaceEditResult = {
|
|
55
|
+
state: WorkspaceEditState;
|
|
56
|
+
/** The editor should be read-only (self-hosted offline). */
|
|
57
|
+
readOnly: boolean;
|
|
58
|
+
/** The current buffered content (null = no local edit yet). */
|
|
59
|
+
buffer: string | null;
|
|
60
|
+
/** The host should warm the box (flip its attach primitive). Latches true from
|
|
61
|
+
* the first cold edit until the buffer flushes or is discarded. */
|
|
62
|
+
wantsWarm: boolean;
|
|
63
|
+
/** Record an edit from the editor (idempotent per identical content). */
|
|
64
|
+
edit: (content: string) => void;
|
|
65
|
+
/** The conflict detail when `state === "conflict"`. */
|
|
66
|
+
conflict: WorkspaceEditConflict | null;
|
|
67
|
+
/** Flush the buffer regardless of the base guard (the user chose "overwrite"
|
|
68
|
+
* from the conflict bar). Writes last-writer-wins. */
|
|
69
|
+
overwrite: () => Promise<void>;
|
|
70
|
+
/** Drop the local buffer and return to viewing the snapshot. */
|
|
71
|
+
discard: () => void;
|
|
72
|
+
error: Error | null;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
function isLive(liveness: string | undefined): boolean {
|
|
76
|
+
return liveness === "warm" || liveness === "draining";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Cold-editing without a spinner: the editor opens instantly from the capture,
|
|
81
|
+
* the first keystroke buffers locally AND signals the host to warm the box, and on
|
|
82
|
+
* warm the buffer flushes IF the live file still matches the captured base — else a
|
|
83
|
+
* non-blocking conflict is surfaced and nothing is overwritten (dossier §12-C2).
|
|
84
|
+
*
|
|
85
|
+
* The base guard compares the live content at flush time against `baseContent`
|
|
86
|
+
* (the exact bytes the editor loaded). This is equivalent to the sha256 compare
|
|
87
|
+
* the dossier describes — M1 leaves `baseHash` null, so the loaded content IS the
|
|
88
|
+
* authoritative base — and avoids async Web Crypto in the render path.
|
|
89
|
+
*/
|
|
90
|
+
export function useWorkspaceEdit(
|
|
91
|
+
sessionId: string | null | undefined,
|
|
92
|
+
options: UseWorkspaceEditOptions = {},
|
|
93
|
+
): UseWorkspaceEditResult {
|
|
94
|
+
const { client, workspaceId } = useOpenGeni(options);
|
|
95
|
+
const path = options.path ?? null;
|
|
96
|
+
const baseContent = options.baseContent ?? null;
|
|
97
|
+
const live = isLive(options.liveness);
|
|
98
|
+
const offline = options.offline === true;
|
|
99
|
+
|
|
100
|
+
const [buffer, setBuffer] = useState<string | null>(null);
|
|
101
|
+
const [wantsWarm, setWantsWarm] = useState(false);
|
|
102
|
+
const [flush, setFlush] = useState<"idle" | "flushing" | "flushed" | "conflict">("idle");
|
|
103
|
+
const [conflict, setConflict] = useState<WorkspaceEditConflict | null>(null);
|
|
104
|
+
const [error, setError] = useState<Error | null>(null);
|
|
105
|
+
|
|
106
|
+
const onWarmRequested = options.onWarmRequested;
|
|
107
|
+
const bufferRef = useRef<string | null>(null);
|
|
108
|
+
bufferRef.current = buffer;
|
|
109
|
+
// Guards a single in-flight flush against re-entry (liveness can re-render).
|
|
110
|
+
const flushingRef = useRef(false);
|
|
111
|
+
|
|
112
|
+
// Reset the machine when the edited file (or session) changes.
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
setBuffer(null);
|
|
115
|
+
setWantsWarm(false);
|
|
116
|
+
setFlush("idle");
|
|
117
|
+
setConflict(null);
|
|
118
|
+
setError(null);
|
|
119
|
+
flushingRef.current = false;
|
|
120
|
+
}, [sessionId, path]);
|
|
121
|
+
|
|
122
|
+
const edit = useCallback(
|
|
123
|
+
(content: string) => {
|
|
124
|
+
if (offline) return; // read-only — no buffering, no wake.
|
|
125
|
+
setBuffer((prev) => (prev === content ? prev : content));
|
|
126
|
+
// Any new edit invalidates a prior flushed/conflict resolution.
|
|
127
|
+
setFlush((prev) => (prev === "flushing" ? prev : "idle"));
|
|
128
|
+
setConflict(null);
|
|
129
|
+
if (!live) {
|
|
130
|
+
// First cold edit signals the host to warm the box (once).
|
|
131
|
+
setWantsWarm((prev) => {
|
|
132
|
+
if (!prev) onWarmRequested?.();
|
|
133
|
+
return true;
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
[offline, live, onWarmRequested],
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const doFlush = useCallback(
|
|
141
|
+
async (force: boolean) => {
|
|
142
|
+
if (!sessionId || !path) return;
|
|
143
|
+
const content = bufferRef.current;
|
|
144
|
+
if (content === null) return;
|
|
145
|
+
if (flushingRef.current) return;
|
|
146
|
+
flushingRef.current = true;
|
|
147
|
+
setFlush("flushing");
|
|
148
|
+
setError(null);
|
|
149
|
+
try {
|
|
150
|
+
if (!force) {
|
|
151
|
+
// Base guard: re-read the live file and compare to the captured base. A
|
|
152
|
+
// divergence means the box changed under us — surface a conflict, write
|
|
153
|
+
// NOTHING (C2).
|
|
154
|
+
const liveRead = await client.fsRead(workspaceId, sessionId, { path });
|
|
155
|
+
const liveContent = liveRead.content;
|
|
156
|
+
if (baseContent !== null && liveContent !== baseContent) {
|
|
157
|
+
setConflict({ path, base: baseContent, live: liveContent });
|
|
158
|
+
setFlush("conflict");
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
await client.fsWrite(workspaceId, sessionId, { path, content, overwrite: true });
|
|
163
|
+
setConflict(null);
|
|
164
|
+
setFlush("flushed");
|
|
165
|
+
setWantsWarm(false);
|
|
166
|
+
} catch (cause) {
|
|
167
|
+
setError(cause instanceof Error ? cause : new Error(String(cause)));
|
|
168
|
+
// Leave the buffer intact so the user can retry; fall back to buffering.
|
|
169
|
+
setFlush("idle");
|
|
170
|
+
} finally {
|
|
171
|
+
flushingRef.current = false;
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
[client, workspaceId, sessionId, path, baseContent],
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
// Auto-flush once the box is warm and a buffer is pending (the wake completed).
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
if (offline) return;
|
|
180
|
+
if (!live) return;
|
|
181
|
+
if (buffer === null) return;
|
|
182
|
+
if (flush === "flushed" || flush === "conflict" || flush === "flushing") return;
|
|
183
|
+
void doFlush(false);
|
|
184
|
+
}, [offline, live, buffer, flush, doFlush]);
|
|
185
|
+
|
|
186
|
+
const overwrite = useCallback(async () => {
|
|
187
|
+
await doFlush(true);
|
|
188
|
+
}, [doFlush]);
|
|
189
|
+
|
|
190
|
+
const discard = useCallback(() => {
|
|
191
|
+
setBuffer(null);
|
|
192
|
+
setWantsWarm(false);
|
|
193
|
+
setFlush("idle");
|
|
194
|
+
setConflict(null);
|
|
195
|
+
setError(null);
|
|
196
|
+
}, []);
|
|
197
|
+
|
|
198
|
+
// Derive the exhaustive public state.
|
|
199
|
+
let state: WorkspaceEditState;
|
|
200
|
+
if (offline) {
|
|
201
|
+
state = "readonly-offline";
|
|
202
|
+
} else if (flush === "flushing") {
|
|
203
|
+
state = "flushing";
|
|
204
|
+
} else if (flush === "flushed") {
|
|
205
|
+
state = "flushed";
|
|
206
|
+
} else if (flush === "conflict") {
|
|
207
|
+
state = "conflict";
|
|
208
|
+
} else if (buffer === null) {
|
|
209
|
+
state = "viewing-cold";
|
|
210
|
+
} else if (live) {
|
|
211
|
+
// Warm with a pending buffer that hasn't entered flush yet — a transient tick
|
|
212
|
+
// before the auto-flush effect runs.
|
|
213
|
+
state = "flushing";
|
|
214
|
+
} else if (options.warming === true) {
|
|
215
|
+
state = "warming";
|
|
216
|
+
} else {
|
|
217
|
+
state = "buffering";
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
state,
|
|
222
|
+
readOnly: offline,
|
|
223
|
+
buffer,
|
|
224
|
+
wantsWarm,
|
|
225
|
+
edit,
|
|
226
|
+
conflict,
|
|
227
|
+
overwrite,
|
|
228
|
+
discard,
|
|
229
|
+
error,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
@@ -5,26 +5,69 @@ import { usePolledValue } from "./internal";
|
|
|
5
5
|
|
|
6
6
|
export type UseWorkspaceSessionsOptions = ClientOverride & {
|
|
7
7
|
limit?: number | undefined;
|
|
8
|
+
parentSessionId?: string | null | undefined;
|
|
9
|
+
cursor?: string | undefined;
|
|
10
|
+
search?: string | undefined;
|
|
8
11
|
/** Refresh interval (ms) for fleet/manager views. Off by default. */
|
|
9
12
|
pollIntervalMs?: number | undefined;
|
|
10
13
|
enabled?: boolean | undefined;
|
|
11
14
|
};
|
|
12
15
|
|
|
13
16
|
export type UseWorkspaceSessionsResult = {
|
|
17
|
+
/**
|
|
18
|
+
* All visible rows, with pins first. This preserves the pre-pinning hook
|
|
19
|
+
* contract for consumers that only read `sessions`.
|
|
20
|
+
*/
|
|
14
21
|
sessions: Session[];
|
|
22
|
+
/** The complete personal pinned section, also present in `sessions`. */
|
|
23
|
+
pinned: Session[];
|
|
24
|
+
nextCursor: string | null;
|
|
15
25
|
loading: boolean;
|
|
16
26
|
error: Error | null;
|
|
17
27
|
refresh: () => Promise<void>;
|
|
18
28
|
};
|
|
19
29
|
|
|
20
30
|
/** List the workspace's sessions — the data behind fleet and manager views. */
|
|
21
|
-
export function useWorkspaceSessions(
|
|
31
|
+
export function useWorkspaceSessions(
|
|
32
|
+
options: UseWorkspaceSessionsOptions = {},
|
|
33
|
+
): UseWorkspaceSessionsResult {
|
|
22
34
|
const { client, workspaceId } = useOpenGeni(options);
|
|
23
35
|
const limit = options.limit;
|
|
36
|
+
const parentSessionId = options.parentSessionId;
|
|
37
|
+
const cursor = options.cursor;
|
|
38
|
+
const search = options.search;
|
|
39
|
+
const queryKey = JSON.stringify({ workspaceId, limit, parentSessionId, cursor, search });
|
|
24
40
|
const load = useCallback(
|
|
25
|
-
async () =>
|
|
26
|
-
|
|
41
|
+
async () => ({
|
|
42
|
+
queryKey,
|
|
43
|
+
page: await client.listSessionPage(workspaceId, {
|
|
44
|
+
...(limit !== undefined ? { limit } : {}),
|
|
45
|
+
...(parentSessionId !== undefined ? { parentSessionId } : {}),
|
|
46
|
+
...(cursor !== undefined ? { cursor } : {}),
|
|
47
|
+
...(search !== undefined ? { search } : {}),
|
|
48
|
+
}),
|
|
49
|
+
}),
|
|
50
|
+
[client, workspaceId, limit, parentSessionId, cursor, search, queryKey],
|
|
27
51
|
);
|
|
28
|
-
const state = usePolledValue(load, {
|
|
29
|
-
|
|
52
|
+
const state = usePolledValue(load, {
|
|
53
|
+
pollIntervalMs: options.pollIntervalMs,
|
|
54
|
+
enabled: options.enabled,
|
|
55
|
+
});
|
|
56
|
+
// usePolledValue drops stale async completions, while the explicit query key
|
|
57
|
+
// also prevents the previous query's cached value from painting for the one
|
|
58
|
+
// render before its loader-change effect clears state.
|
|
59
|
+
const page = state.data?.queryKey === queryKey ? state.data.page : null;
|
|
60
|
+
const pinned = page?.pinned ?? [];
|
|
61
|
+
const ordinary = page?.sessions ?? [];
|
|
62
|
+
return {
|
|
63
|
+
// The old hook returned every visible session. Keep that public behavior
|
|
64
|
+
// while exposing `pinned` separately for the compact section. The API page
|
|
65
|
+
// itself continues to paginate only `ordinary` rows via nextCursor.
|
|
66
|
+
sessions: [...pinned, ...ordinary],
|
|
67
|
+
pinned,
|
|
68
|
+
nextCursor: page?.nextCursor ?? null,
|
|
69
|
+
loading: state.loading,
|
|
70
|
+
error: state.error,
|
|
71
|
+
refresh: state.refresh,
|
|
72
|
+
};
|
|
30
73
|
}
|
|
@@ -27,40 +27,43 @@ export type UseWorkspacesResult = {
|
|
|
27
27
|
export function useWorkspaces(options: UseWorkspacesOptions = {}): UseWorkspacesResult {
|
|
28
28
|
const client = useOpenGeniClient(options);
|
|
29
29
|
const load = useCallback(async () => await client.listWorkspaces(), [client]);
|
|
30
|
-
const
|
|
31
|
-
|
|
30
|
+
const { data, loading, error, refresh } = usePolledValue(load, {
|
|
31
|
+
pollIntervalMs: options.pollIntervalMs,
|
|
32
|
+
enabled: options.enabled,
|
|
33
|
+
});
|
|
34
|
+
const { run, mutating, mutationError, clearMutationError } = useMutationRunner();
|
|
32
35
|
|
|
33
36
|
const create = useCallback(
|
|
34
37
|
async (request: CreateWorkspaceRequest): Promise<Workspace | null> => {
|
|
35
|
-
const result = await
|
|
38
|
+
const result = await run(() => client.createWorkspace(request));
|
|
36
39
|
if (result) {
|
|
37
|
-
await
|
|
40
|
+
await refresh();
|
|
38
41
|
}
|
|
39
42
|
return result;
|
|
40
43
|
},
|
|
41
|
-
[client,
|
|
44
|
+
[client, run, refresh],
|
|
42
45
|
);
|
|
43
46
|
|
|
44
47
|
const update = useCallback(
|
|
45
48
|
async (workspaceId: string, request: UpdateWorkspaceRequest): Promise<Workspace | null> => {
|
|
46
|
-
const result = await
|
|
49
|
+
const result = await run(() => client.updateWorkspace(workspaceId, request));
|
|
47
50
|
if (result) {
|
|
48
|
-
await
|
|
51
|
+
await refresh();
|
|
49
52
|
}
|
|
50
53
|
return result;
|
|
51
54
|
},
|
|
52
|
-
[client,
|
|
55
|
+
[client, run, refresh],
|
|
53
56
|
);
|
|
54
57
|
|
|
55
58
|
return {
|
|
56
|
-
workspaces:
|
|
57
|
-
loading
|
|
58
|
-
error
|
|
59
|
-
refresh
|
|
59
|
+
workspaces: data ?? [],
|
|
60
|
+
loading,
|
|
61
|
+
error,
|
|
62
|
+
refresh,
|
|
60
63
|
create,
|
|
61
64
|
update,
|
|
62
|
-
mutating
|
|
63
|
-
mutationError
|
|
64
|
-
clearMutationError
|
|
65
|
+
mutating,
|
|
66
|
+
mutationError,
|
|
67
|
+
clearMutationError,
|
|
65
68
|
};
|
|
66
69
|
}
|