@tt-a1i/openpi 0.6.1 → 0.7.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 (37) hide show
  1. package/THIRD_PARTY_NOTICES.md +242 -0
  2. package/extensions/ai-providers/cursor/connect-frame-reader.ts +76 -0
  3. package/extensions/ai-providers/cursor/provider.ts +6 -19
  4. package/extensions/file-mutation-display/index.ts +13 -9
  5. package/extensions/shared/agent-transcript.ts +3 -2
  6. package/extensions/web/index.ts +69 -5
  7. package/extensions/workflows/artifacts.ts +362 -23
  8. package/extensions/workflows/dashboard.ts +2 -0
  9. package/package.json +28 -4
  10. package/web/dist/app.js +87 -0
  11. package/web/dist/favicon.svg +9 -0
  12. package/web/dist/index.html +15 -0
  13. package/web/dist/styles.css +3 -0
  14. package/web/host/web-host.ts +6 -9
  15. package/web/ui/index.html +3 -131
  16. package/web/ui/public/favicon.svg +9 -0
  17. package/web/ui/src/app/App.tsx +134 -0
  18. package/web/ui/src/app/providers.tsx +38 -0
  19. package/web/ui/src/components/Markdown.tsx +58 -0
  20. package/web/ui/src/components/OpenPiLogo.tsx +41 -0
  21. package/web/ui/src/features/activity/ActivityBar.tsx +120 -0
  22. package/web/ui/src/features/composer/Composer.tsx +237 -0
  23. package/web/ui/src/features/sessions/SessionSidebar.tsx +418 -0
  24. package/web/ui/src/features/transcript/Transcript.tsx +860 -0
  25. package/web/ui/src/i18n.ts +159 -0
  26. package/web/ui/src/lib/format.ts +57 -0
  27. package/web/ui/src/main.tsx +16 -0
  28. package/web/ui/src/protocol/client.ts +199 -0
  29. package/web/ui/src/protocol/event-stream.ts +88 -0
  30. package/web/ui/src/store/web-store.ts +926 -0
  31. package/web/ui/src/styles.css +420 -0
  32. package/web/ui/tsconfig.json +12 -0
  33. package/web/ui/vite-env.d.ts +1 -0
  34. package/web/vite.config.mjs +21 -1
  35. package/web/host/static-assets.ts +0 -4
  36. package/web/ui/app.js +0 -1700
  37. package/web/ui/styles.css +0 -680
@@ -0,0 +1,926 @@
1
+ import { createStore } from "zustand/vanilla";
2
+ import type {
3
+ WebEvent,
4
+ WebLiveMessage,
5
+ WebSnapshot,
6
+ } from "../../../protocol/types.ts";
7
+ import { WebApiError, WebClient } from "../protocol/client.ts";
8
+ import { consumeEventStream } from "../protocol/event-stream.ts";
9
+
10
+ const collapsedWorkspacesStorageKey = "openpi.collapsed-workspaces";
11
+ const sidebarCollapsedStorageKey = "openpi.sidebar-collapsed";
12
+ const refreshEventTypes = new Set([
13
+ "agent_start",
14
+ "turn_started",
15
+ "turn_settled",
16
+ "agent_settled",
17
+ "prompt_settled",
18
+ "message_end",
19
+ "tool_execution_end",
20
+ "session_start",
21
+ "session_switched",
22
+ "session_progress",
23
+ "prompt_failed",
24
+ "model_select",
25
+ "workspace_imported",
26
+ "workspace_removed",
27
+ "workspace_renamed",
28
+ "session_renamed",
29
+ "session_archived",
30
+ "session_created",
31
+ "prompt_accepted",
32
+ "runtime_changed",
33
+ ]);
34
+
35
+ function readStringSet(key: string) {
36
+ try {
37
+ const value: unknown = JSON.parse(
38
+ window.sessionStorage.getItem(key) || "[]",
39
+ );
40
+ return new Set(
41
+ Array.isArray(value)
42
+ ? value.filter((item): item is string => typeof item === "string")
43
+ : [],
44
+ );
45
+ } catch {
46
+ return new Set<string>();
47
+ }
48
+ }
49
+
50
+ function readBoolean(key: string) {
51
+ try {
52
+ return window.sessionStorage.getItem(key) === "true";
53
+ } catch {
54
+ return false;
55
+ }
56
+ }
57
+
58
+ function persist(key: string, value: unknown) {
59
+ try {
60
+ window.sessionStorage.setItem(key, JSON.stringify(value));
61
+ } catch {}
62
+ }
63
+
64
+ export interface LiveEntry {
65
+ key: string;
66
+ message: WebLiveMessage;
67
+ }
68
+
69
+ interface SessionActivation {
70
+ epoch: number;
71
+ kind: "create" | "select";
72
+ commandId?: string;
73
+ expectedPath: string | null;
74
+ observedPath?: string | null;
75
+ }
76
+
77
+ export interface WebStoreState {
78
+ activeTurn: WebSnapshot["runtime"]["activeTurn"] | null;
79
+ turnCancellationPending: boolean;
80
+ turnTerminalStatus: string | null;
81
+ pendingFollowUpsReceipt: number | null;
82
+ snapshot: WebSnapshot | null;
83
+ cursor: number | null;
84
+ selectedPath: string | null;
85
+ selectedWorkspace: string | null;
86
+ collapsed: Set<string>;
87
+ sidebarCollapsed: boolean;
88
+ mobileSidebarOpen: boolean;
89
+ query: string;
90
+ searchOpen: boolean;
91
+ connection: "connected" | "connecting" | "reconnecting" | "unavailable";
92
+ notice: string | null;
93
+ liveMessages: LiveEntry[];
94
+ liveRunning: boolean;
95
+ livePhase: "idle" | "preparing" | "running";
96
+ liveRetry: { attempt: number; maxAttempts: number } | null;
97
+ thinkingStarts: Record<string, number>;
98
+ thinkingDurations: Record<string, number>;
99
+ promptAdmissionPending: boolean;
100
+ sessionSwitching: boolean;
101
+ scrollToBottom: number;
102
+ actions: WebStoreActions;
103
+ }
104
+
105
+ export interface WebStoreActions {
106
+ start: () => void;
107
+ stop: () => void;
108
+ refreshSnapshot: (options?: {
109
+ resetCursor?: boolean;
110
+ epoch?: number;
111
+ canonicalRetry?: boolean;
112
+ }) => Promise<boolean>;
113
+ chooseWorkspace: () => Promise<void>;
114
+ setWorkspace: (path: string | null) => void;
115
+ renameWorkspace: (path: string, name: string) => Promise<void>;
116
+ removeWorkspace: (path: string) => Promise<void>;
117
+ createSession: (workspacePath: string) => Promise<void>;
118
+ selectSession: (path: string) => Promise<void>;
119
+ renameSession: (path: string, name: string) => Promise<void>;
120
+ archiveSession: (path: string) => Promise<void>;
121
+ selectModel: (value: string) => Promise<void>;
122
+ cancelActiveTurn: () => Promise<void>;
123
+ sendPrompt: (content: string) => Promise<boolean>;
124
+ setQuery: (query: string) => void;
125
+ setSearchOpen: (open: boolean) => void;
126
+ toggleWorkspace: (path: string) => void;
127
+ toggleSidebar: (narrow: boolean) => void;
128
+ closeMobileSidebar: () => void;
129
+ clearNotice: () => void;
130
+ }
131
+
132
+ export interface WebStoreDependencies {
133
+ consumeEvents?: typeof consumeEventStream;
134
+ }
135
+
136
+ function waitForReconnect(delay: number, signal: AbortSignal) {
137
+ if (signal.aborted) return Promise.resolve();
138
+ return new Promise<void>((resolve) => {
139
+ const finish = () => {
140
+ window.clearTimeout(timer);
141
+ signal.removeEventListener("abort", finish);
142
+ resolve();
143
+ };
144
+ const timer = window.setTimeout(finish, delay);
145
+ signal.addEventListener("abort", finish, { once: true });
146
+ });
147
+ }
148
+
149
+ export function createWebStore(
150
+ client = new WebClient(),
151
+ dependencies: WebStoreDependencies = {},
152
+ ) {
153
+ const consumeEvents = dependencies.consumeEvents ?? consumeEventStream;
154
+ let sessionEpoch = 0;
155
+ let snapshotGeneration = 0;
156
+ let promptAdmissionSequence = 0;
157
+ let promptAdmissionToken: number | null = null;
158
+ let promptAdmission: {
159
+ sessionId: string;
160
+ content: string;
161
+ commandId: string;
162
+ optimisticKey: string;
163
+ } | null = null;
164
+ let sessionActivation: SessionActivation | null = null;
165
+ let sessionSelectionTail = Promise.resolve();
166
+ let refreshTimer: number | null = null;
167
+ let refreshInFlight = false;
168
+ let refreshPending = false;
169
+ let streamController: AbortController | null = null;
170
+ const terminalPromptIds = new Set<string>();
171
+ const completedActivationIds = new Set<string>();
172
+
173
+ const rememberBounded = (set: Set<string>, value: unknown) => {
174
+ if (typeof value !== "string") return;
175
+ set.add(value);
176
+ while (set.size > 32) {
177
+ const first = set.values().next().value;
178
+ if (first) set.delete(first);
179
+ }
180
+ };
181
+
182
+ const resetLivePatch = () => ({
183
+ activeTurn: null,
184
+ turnCancellationPending: false,
185
+ turnTerminalStatus: null,
186
+ pendingFollowUpsReceipt: null,
187
+ liveMessages: [] as LiveEntry[],
188
+ liveRunning: false,
189
+ livePhase: "idle" as const,
190
+ liveRetry: null,
191
+ thinkingStarts: {},
192
+ thinkingDurations: {},
193
+ });
194
+
195
+ const promptAcceptedLivePatch = (
196
+ settled: boolean,
197
+ currentPhase: WebStoreState["livePhase"],
198
+ ) => ({
199
+ liveRunning: currentPhase === "running" || !settled,
200
+ livePhase:
201
+ currentPhase === "running"
202
+ ? ("running" as const)
203
+ : settled
204
+ ? ("idle" as const)
205
+ : ("preparing" as const),
206
+ });
207
+
208
+ const store = createStore<WebStoreState>((set, get) => {
209
+ const showError = (error: unknown) => {
210
+ set({
211
+ notice: error instanceof Error ? error.message : String(error),
212
+ });
213
+ };
214
+
215
+ const scheduleSnapshotRefresh = (delay = 160) => {
216
+ if (refreshInFlight) {
217
+ refreshPending = true;
218
+ return;
219
+ }
220
+ if (refreshTimer !== null) return;
221
+ refreshTimer = window.setTimeout(async () => {
222
+ refreshTimer = null;
223
+ refreshInFlight = true;
224
+ try {
225
+ await get().actions.refreshSnapshot();
226
+ } finally {
227
+ refreshInFlight = false;
228
+ if (refreshPending) {
229
+ refreshPending = false;
230
+ scheduleSnapshotRefresh();
231
+ }
232
+ }
233
+ }, delay);
234
+ };
235
+
236
+ const applyRuntimeEvent = (event: WebEvent) => {
237
+ const current = get();
238
+ const detail = event.detail ?? {};
239
+ const eventSessionId = detail.sessionId;
240
+ const sessionTransition = [
241
+ "session_start",
242
+ "session_switched",
243
+ "session_created",
244
+ ].includes(event.type);
245
+ set({ cursor: event.sequence });
246
+
247
+ if (current.sessionSwitching && !sessionTransition) {
248
+ scheduleSnapshotRefresh();
249
+ return;
250
+ }
251
+ if (
252
+ typeof eventSessionId === "string" &&
253
+ eventSessionId !== current.snapshot?.currentSessionId &&
254
+ !sessionTransition
255
+ ) {
256
+ scheduleSnapshotRefresh();
257
+ return;
258
+ }
259
+
260
+ if (sessionTransition) {
261
+ const eventCommandId = detail.commandId;
262
+ if (
263
+ typeof eventCommandId === "string" &&
264
+ completedActivationIds.has(eventCommandId)
265
+ ) {
266
+ scheduleSnapshotRefresh();
267
+ return;
268
+ }
269
+ const eventPath = detail.sessionPath;
270
+ const knownPath = current.snapshot?.sessions.some(
271
+ (session) => session.path === eventPath,
272
+ );
273
+ let belongs = false;
274
+ if (sessionActivation?.kind === "select") {
275
+ belongs = sessionActivation.expectedPath === eventPath;
276
+ } else if (
277
+ sessionActivation?.kind === "create" &&
278
+ sessionActivation.commandId === eventCommandId &&
279
+ event.type === "session_switched" &&
280
+ typeof eventPath === "string" &&
281
+ !knownPath
282
+ ) {
283
+ sessionActivation.observedPath = eventPath;
284
+ belongs = true;
285
+ } else if (
286
+ sessionActivation?.kind === "create" &&
287
+ sessionActivation.commandId === eventCommandId &&
288
+ event.type === "session_created" &&
289
+ typeof sessionActivation.observedPath === "string"
290
+ ) {
291
+ belongs = true;
292
+ }
293
+ if (belongs && sessionActivation?.epoch !== sessionEpoch) return;
294
+ if (!belongs) {
295
+ const epoch = ++sessionEpoch;
296
+ promptAdmissionToken = null;
297
+ promptAdmission = null;
298
+ set({
299
+ ...resetLivePatch(),
300
+ promptAdmissionPending: false,
301
+ selectedPath: typeof eventPath === "string" ? eventPath : null,
302
+ sessionSwitching: true,
303
+ });
304
+ void get()
305
+ .actions.refreshSnapshot({ epoch })
306
+ .then((refreshed) => {
307
+ if (epoch !== sessionEpoch) return;
308
+ set({
309
+ selectedPath: refreshed ? get().selectedPath : null,
310
+ sessionSwitching: false,
311
+ });
312
+ });
313
+ } else {
314
+ set(resetLivePatch());
315
+ }
316
+ } else if (event.type === "prompt_accepted") {
317
+ const settled = terminalPromptIds.has(String(detail.commandId ?? ""));
318
+ set({
319
+ ...promptAcceptedLivePatch(settled, current.livePhase),
320
+ liveRetry: null,
321
+ pendingFollowUpsReceipt: Number.isInteger(detail.pendingFollowUps)
322
+ ? Number(detail.pendingFollowUps)
323
+ : current.pendingFollowUpsReceipt,
324
+ });
325
+ } else if (event.type === "turn_started") {
326
+ set({
327
+ activeTurn: {
328
+ sessionId: String(detail.sessionId),
329
+ commandId: String(detail.commandId),
330
+ epoch: Number(detail.epoch),
331
+ },
332
+ liveRunning: true,
333
+ livePhase: "running",
334
+ liveRetry: null,
335
+ turnTerminalStatus: null,
336
+ });
337
+ } else if (event.type === "agent_start") {
338
+ set({
339
+ ...(detail.activeTurn
340
+ ? { activeTurn: detail.activeTurn as WebStoreState["activeTurn"] }
341
+ : {}),
342
+ liveRunning: true,
343
+ livePhase: "running",
344
+ liveRetry: null,
345
+ });
346
+ } else if (event.type === "turn_settled") {
347
+ rememberBounded(terminalPromptIds, detail.commandId);
348
+ const turn = current.activeTurn;
349
+ if (
350
+ turn?.sessionId === detail.sessionId &&
351
+ turn?.commandId === detail.commandId &&
352
+ turn?.epoch === detail.epoch
353
+ ) {
354
+ set({
355
+ activeTurn: null,
356
+ liveRunning: false,
357
+ livePhase: "idle",
358
+ liveRetry: null,
359
+ turnTerminalStatus:
360
+ typeof detail.outcome === "string" ? detail.outcome : null,
361
+ });
362
+ }
363
+ } else if (event.type === "agent_settled") {
364
+ set({
365
+ pendingFollowUpsReceipt: null,
366
+ ...(!current.activeTurn
367
+ ? {
368
+ liveRunning: false,
369
+ livePhase: "idle" as const,
370
+ liveRetry: null,
371
+ }
372
+ : {}),
373
+ });
374
+ } else if (event.type === "prompt_settled") {
375
+ rememberBounded(terminalPromptIds, detail.commandId);
376
+ if (current.livePhase !== "running")
377
+ set({ liveRunning: false, livePhase: "idle", liveRetry: null });
378
+ } else if (detail.message && typeof detail.message === "object") {
379
+ const message = detail.message as WebLiveMessage;
380
+ let liveMessages = current.liveMessages;
381
+ if (message.role === "user") {
382
+ liveMessages = liveMessages.filter(
383
+ (entry) =>
384
+ !entry.key.startsWith("optimistic-") ||
385
+ entry.message.content !== message.content,
386
+ );
387
+ }
388
+ const key =
389
+ typeof detail.messageKey === "string"
390
+ ? detail.messageKey
391
+ : `${message.role || "message"}-${event.sequence}`;
392
+ const live = { key, message };
393
+ const index = liveMessages.findIndex((entry) => entry.key === key);
394
+ liveMessages =
395
+ index >= 0
396
+ ? liveMessages.map((entry, entryIndex) =>
397
+ entryIndex === index ? live : entry,
398
+ )
399
+ : [...liveMessages, live].slice(-8);
400
+ const thinkingStarts = { ...current.thinkingStarts };
401
+ const thinkingDurations = { ...current.thinkingDurations };
402
+ if (message.parts?.some((part) => part.type === "thinking")) {
403
+ thinkingStarts[key] ??= Date.now();
404
+ if (event.type === "message_end") {
405
+ thinkingDurations[key] = Date.now() - thinkingStarts[key];
406
+ }
407
+ }
408
+ set({ liveMessages, thinkingDurations, thinkingStarts });
409
+ }
410
+
411
+ if (event.type === "prompt_failed") {
412
+ rememberBounded(terminalPromptIds, detail.commandId);
413
+ set({
414
+ liveMessages: get().liveMessages.filter(
415
+ (entry) => !entry.key.startsWith("optimistic-"),
416
+ ),
417
+ liveRunning: false,
418
+ livePhase: "idle",
419
+ liveRetry: null,
420
+ notice:
421
+ typeof detail.error === "string" ? detail.error : "Prompt failed",
422
+ });
423
+ }
424
+ if (event.type === "auto_retry_start") {
425
+ set({
426
+ liveRunning: true,
427
+ livePhase: "running",
428
+ liveRetry: {
429
+ attempt: Number(detail.attempt) || 0,
430
+ maxAttempts: Number(detail.maxAttempts) || 0,
431
+ },
432
+ });
433
+ }
434
+ if (refreshEventTypes.has(event.type)) scheduleSnapshotRefresh();
435
+ };
436
+
437
+ const runEventLoop = async (signal: AbortSignal) => {
438
+ let reconnectDelay = 500;
439
+ while (!signal.aborted) {
440
+ let recoveryAttempted = false;
441
+ try {
442
+ if (get().cursor === null) {
443
+ recoveryAttempted = true;
444
+ const ready = await get().actions.refreshSnapshot({
445
+ resetCursor: true,
446
+ });
447
+ if (!ready) throw new Error("snapshot unavailable");
448
+ recoveryAttempted = false;
449
+ }
450
+ if (signal.aborted) return;
451
+ await consumeEvents({
452
+ client,
453
+ cursor: get().cursor ?? 0,
454
+ onConnected: () => {
455
+ reconnectDelay = 500;
456
+ set({ connection: "connected", notice: null });
457
+ },
458
+ onEvent: applyRuntimeEvent,
459
+ onHeartbeat: () => scheduleSnapshotRefresh(0),
460
+ signal,
461
+ });
462
+ } catch (error) {
463
+ if (signal.aborted) return;
464
+ set({ connection: "reconnecting" });
465
+ const recovered =
466
+ !recoveryAttempted &&
467
+ (await get().actions.refreshSnapshot({ resetCursor: true }));
468
+ if (!recovered) set(resetLivePatch());
469
+ await waitForReconnect(reconnectDelay, signal);
470
+ reconnectDelay = Math.min(reconnectDelay * 2, 5_000);
471
+ if (error instanceof SyntaxError)
472
+ set({ notice: "Invalid event data" });
473
+ }
474
+ }
475
+ };
476
+
477
+ const actions: WebStoreActions = {
478
+ start() {
479
+ if (streamController) return;
480
+ streamController = new AbortController();
481
+ void runEventLoop(streamController.signal);
482
+ },
483
+ stop() {
484
+ streamController?.abort();
485
+ streamController = null;
486
+ if (refreshTimer !== null) window.clearTimeout(refreshTimer);
487
+ refreshTimer = null;
488
+ },
489
+ async refreshSnapshot(options = {}) {
490
+ const epoch = options.epoch ?? sessionEpoch;
491
+ const generation = ++snapshotGeneration;
492
+ const requestedPath = get().selectedPath;
493
+ try {
494
+ const snapshot = await client.snapshot(requestedPath);
495
+ if (epoch !== sessionEpoch || generation !== snapshotGeneration)
496
+ return false;
497
+ const hasCurrent = typeof snapshot.currentSessionId === "string";
498
+ const currentSession = hasCurrent
499
+ ? snapshot.sessions.find(
500
+ (session) => session.id === snapshot.currentSessionId,
501
+ )
502
+ : undefined;
503
+ const selectedIsCurrent = hasCurrent
504
+ ? snapshot.selectedSession?.id === snapshot.currentSessionId
505
+ : snapshot.selectedSession === undefined;
506
+ const requestedExists =
507
+ !requestedPath ||
508
+ snapshot.sessions.some((session) => session.path === requestedPath);
509
+ const requestedMatches =
510
+ !requestedPath || snapshot.selectedSession?.path === requestedPath;
511
+ if (!requestedExists || !requestedMatches || !selectedIsCurrent) {
512
+ set({ selectedPath: null });
513
+ if (!options.canonicalRetry) {
514
+ return actions.refreshSnapshot({
515
+ ...options,
516
+ canonicalRetry: true,
517
+ epoch,
518
+ });
519
+ }
520
+ return false;
521
+ }
522
+ const selectedSessionWorkspace = snapshot.selectedSession?.cwd;
523
+ const activeWorkspace = snapshot.workspaces.find(
524
+ (workspace) => workspace.current,
525
+ )?.path;
526
+ const retainedWorkspace = snapshot.workspaces.some(
527
+ (workspace) => workspace.path === get().selectedWorkspace,
528
+ )
529
+ ? get().selectedWorkspace
530
+ : undefined;
531
+ const selectedWorkspace = snapshot.workspaces.some(
532
+ (workspace) => workspace.path === selectedSessionWorkspace,
533
+ )
534
+ ? selectedSessionWorkspace
535
+ : (activeWorkspace ?? retainedWorkspace ?? null);
536
+ const shouldReset = options.resetCursor;
537
+ set({
538
+ ...(shouldReset ? resetLivePatch() : {}),
539
+ connection:
540
+ get().connection === "connecting"
541
+ ? "connecting"
542
+ : get().connection,
543
+ cursor:
544
+ shouldReset || get().cursor === null
545
+ ? snapshot.cursor
546
+ : Math.max(get().cursor ?? 0, snapshot.cursor),
547
+ activeTurn: snapshot.runtime.activeTurn ?? null,
548
+ livePhase:
549
+ snapshot.runtime.status !== "running" &&
550
+ !get().promptAdmissionPending &&
551
+ !promptAdmission
552
+ ? "idle"
553
+ : get().livePhase,
554
+ liveRetry:
555
+ snapshot.runtime.status !== "running" &&
556
+ !get().promptAdmissionPending &&
557
+ !promptAdmission
558
+ ? null
559
+ : get().liveRetry,
560
+ liveRunning:
561
+ snapshot.runtime.status === "running"
562
+ ? true
563
+ : !get().promptAdmissionPending && !promptAdmission
564
+ ? false
565
+ : get().liveRunning,
566
+ selectedPath:
567
+ currentSession?.path ?? snapshot.selectedSession?.path ?? null,
568
+ selectedWorkspace,
569
+ snapshot,
570
+ });
571
+ return true;
572
+ } catch (error) {
573
+ if (epoch !== sessionEpoch || generation !== snapshotGeneration)
574
+ return false;
575
+ set({ connection: "unavailable" });
576
+ showError(error);
577
+ return false;
578
+ }
579
+ },
580
+ async chooseWorkspace() {
581
+ try {
582
+ const result = await client.chooseWorkspace();
583
+ if (result.cancelled || !result.path) return;
584
+ set({ selectedWorkspace: result.path });
585
+ await actions.refreshSnapshot();
586
+ } catch (error) {
587
+ showError(error);
588
+ }
589
+ },
590
+ setWorkspace(path) {
591
+ set({ selectedWorkspace: path });
592
+ void actions.refreshSnapshot();
593
+ },
594
+ async renameWorkspace(path, name) {
595
+ try {
596
+ await client.renameWorkspace(path, name);
597
+ await actions.refreshSnapshot();
598
+ } catch (error) {
599
+ showError(error);
600
+ throw error;
601
+ }
602
+ },
603
+ async removeWorkspace(path) {
604
+ try {
605
+ await client.removeWorkspace(path);
606
+ set({
607
+ selectedPath: null,
608
+ selectedWorkspace:
609
+ get().selectedWorkspace === path ? null : get().selectedWorkspace,
610
+ });
611
+ await actions.refreshSnapshot();
612
+ } catch (error) {
613
+ showError(error);
614
+ }
615
+ },
616
+ async createSession(workspacePath) {
617
+ if (!workspacePath) return;
618
+ const epoch = ++sessionEpoch;
619
+ const commandId =
620
+ globalThis.crypto?.randomUUID?.() ??
621
+ `web-create-${Date.now()}-${epoch}`;
622
+ promptAdmissionToken = null;
623
+ promptAdmission = null;
624
+ set({
625
+ ...resetLivePatch(),
626
+ mobileSidebarOpen: false,
627
+ promptAdmissionPending: false,
628
+ selectedPath: null,
629
+ selectedWorkspace: workspacePath,
630
+ sessionSwitching: true,
631
+ });
632
+ const creation = sessionSelectionTail.then(async () => {
633
+ if (epoch !== sessionEpoch) return;
634
+ sessionActivation = {
635
+ commandId,
636
+ epoch,
637
+ expectedPath: null,
638
+ kind: "create",
639
+ observedPath: null,
640
+ };
641
+ try {
642
+ await client.createSession(workspacePath, commandId);
643
+ if (epoch !== sessionEpoch) return;
644
+ set({ selectedPath: null });
645
+ await actions.refreshSnapshot({ epoch });
646
+ } catch (error) {
647
+ if (epoch !== sessionEpoch) return;
648
+ set({ selectedPath: null });
649
+ showError(error);
650
+ await actions.refreshSnapshot({ epoch });
651
+ } finally {
652
+ rememberBounded(completedActivationIds, commandId);
653
+ if (sessionActivation?.epoch === epoch) sessionActivation = null;
654
+ if (epoch === sessionEpoch) set({ sessionSwitching: false });
655
+ }
656
+ });
657
+ sessionSelectionTail = creation.catch(() => undefined);
658
+ await creation;
659
+ },
660
+ async selectSession(path) {
661
+ if (!path) return;
662
+ const epoch = ++sessionEpoch;
663
+ promptAdmissionToken = null;
664
+ promptAdmission = null;
665
+ set({
666
+ ...resetLivePatch(),
667
+ mobileSidebarOpen: false,
668
+ promptAdmissionPending: false,
669
+ selectedPath: path,
670
+ sessionSwitching: true,
671
+ });
672
+ const selection = sessionSelectionTail.then(async () => {
673
+ if (epoch !== sessionEpoch) return;
674
+ sessionActivation = { epoch, expectedPath: path, kind: "select" };
675
+ try {
676
+ await client.selectSession(path);
677
+ if (epoch !== sessionEpoch) return;
678
+ if (!(await actions.refreshSnapshot({ epoch }))) {
679
+ set({ selectedPath: null });
680
+ }
681
+ } catch (error) {
682
+ if (epoch !== sessionEpoch) return;
683
+ set({ selectedPath: null });
684
+ showError(error);
685
+ await actions.refreshSnapshot({ epoch });
686
+ } finally {
687
+ if (sessionActivation?.epoch === epoch) sessionActivation = null;
688
+ if (epoch === sessionEpoch) set({ sessionSwitching: false });
689
+ }
690
+ });
691
+ sessionSelectionTail = selection.catch(() => undefined);
692
+ await selection;
693
+ },
694
+ async renameSession(path, name) {
695
+ try {
696
+ await client.renameSession(path, name);
697
+ await actions.refreshSnapshot();
698
+ } catch (error) {
699
+ showError(error);
700
+ throw error;
701
+ }
702
+ },
703
+ async archiveSession(path) {
704
+ try {
705
+ await client.archiveSession(path);
706
+ await actions.refreshSnapshot();
707
+ } catch (error) {
708
+ showError(error);
709
+ }
710
+ },
711
+ async selectModel(value) {
712
+ const [provider, ...idParts] = value.split("/");
713
+ const modelId = idParts.join("/");
714
+ const epoch = sessionEpoch;
715
+ const sessionId = get().snapshot?.selectedSession?.id;
716
+ if (!provider || !modelId || !sessionId || get().sessionSwitching)
717
+ return;
718
+ try {
719
+ await client.selectModel(provider, modelId, sessionId);
720
+ if (
721
+ epoch !== sessionEpoch ||
722
+ sessionId !== get().snapshot?.selectedSession?.id
723
+ ) {
724
+ return;
725
+ }
726
+ await actions.refreshSnapshot({ epoch });
727
+ } catch (error) {
728
+ if (
729
+ epoch !== sessionEpoch ||
730
+ sessionId !== get().snapshot?.selectedSession?.id
731
+ ) {
732
+ return;
733
+ }
734
+ showError(error);
735
+ await actions.refreshSnapshot({ epoch });
736
+ }
737
+ },
738
+ async cancelActiveTurn() {
739
+ const turn = get().activeTurn ?? get().snapshot?.runtime.activeTurn;
740
+ if (!turn || get().turnCancellationPending || get().sessionSwitching)
741
+ return;
742
+ const epoch = sessionEpoch;
743
+ set({ turnCancellationPending: true });
744
+ try {
745
+ await client.cancelActiveTurn(turn);
746
+ } catch (error) {
747
+ if (epoch !== sessionEpoch) return;
748
+ await actions.refreshSnapshot({ epoch });
749
+ if (epoch === sessionEpoch) showError(error);
750
+ } finally {
751
+ if (epoch === sessionEpoch) set({ turnCancellationPending: false });
752
+ }
753
+ },
754
+ async sendPrompt(rawContent) {
755
+ const content = rawContent.trim();
756
+ const workspace = get().selectedWorkspace;
757
+ if (
758
+ !workspace ||
759
+ !content ||
760
+ get().sessionSwitching ||
761
+ get().promptAdmissionPending
762
+ ) {
763
+ return false;
764
+ }
765
+ if (!get().snapshot?.selectedSession?.id)
766
+ await actions.createSession(workspace);
767
+ const sessionId = get().snapshot?.selectedSession?.id;
768
+ if (
769
+ !sessionId ||
770
+ get().sessionSwitching ||
771
+ get().promptAdmissionPending
772
+ ) {
773
+ return false;
774
+ }
775
+ const epoch = sessionEpoch;
776
+ const admission = ++promptAdmissionSequence;
777
+ const retrying =
778
+ promptAdmission?.sessionId === sessionId &&
779
+ promptAdmission.content === content;
780
+ const commandId = retrying
781
+ ? promptAdmission!.commandId
782
+ : (globalThis.crypto?.randomUUID?.() ??
783
+ `web-prompt-${Date.now()}-${admission}`);
784
+ const optimisticKey = retrying
785
+ ? promptAdmission!.optimisticKey
786
+ : `optimistic-${commandId}`;
787
+ promptAdmission = { sessionId, content, commandId, optimisticKey };
788
+ promptAdmissionToken = admission;
789
+ set({
790
+ liveMessages: retrying
791
+ ? get().liveMessages
792
+ : [
793
+ ...get().liveMessages,
794
+ { key: optimisticKey, message: { role: "user", content } },
795
+ ].slice(-8),
796
+ notice: null,
797
+ pendingFollowUpsReceipt: null,
798
+ turnTerminalStatus: null,
799
+ promptAdmissionPending: true,
800
+ scrollToBottom: get().scrollToBottom + 1,
801
+ });
802
+ try {
803
+ const receipt = await client.prompt(
804
+ sessionId,
805
+ content,
806
+ commandId,
807
+ retrying,
808
+ );
809
+ if (epoch !== sessionEpoch || promptAdmissionToken !== admission)
810
+ return false;
811
+ const settled = terminalPromptIds.has(receipt.id);
812
+ if (promptAdmission?.commandId === commandId) promptAdmission = null;
813
+ set({
814
+ ...promptAcceptedLivePatch(settled, get().livePhase),
815
+ pendingFollowUpsReceipt: receipt.pendingFollowUps ?? null,
816
+ });
817
+ scheduleSnapshotRefresh(120);
818
+ return true;
819
+ } catch (error) {
820
+ if (epoch !== sessionEpoch || promptAdmissionToken !== admission)
821
+ return false;
822
+ const knownRejection =
823
+ error instanceof WebApiError &&
824
+ [
825
+ "WORKSPACE_REQUIRED",
826
+ "SESSION_CONFLICT",
827
+ "PROMPT_REJECTED",
828
+ "COMMAND_CONFLICT",
829
+ "PROMPT_ADMISSION_CAPACITY",
830
+ ].includes(error.code ?? "");
831
+ if (!knownRejection) {
832
+ set({
833
+ liveRunning: true,
834
+ livePhase:
835
+ get().livePhase === "running" ? "running" : "preparing",
836
+ liveRetry: null,
837
+ });
838
+ showError(error);
839
+ return false;
840
+ }
841
+ if (promptAdmission?.commandId === commandId) promptAdmission = null;
842
+ set({
843
+ liveMessages: get().liveMessages.filter(
844
+ (entry) => entry.key !== optimisticKey,
845
+ ),
846
+ livePhase: "idle",
847
+ liveRetry: null,
848
+ liveRunning: false,
849
+ });
850
+ showError(error);
851
+ return false;
852
+ } finally {
853
+ if (epoch === sessionEpoch && promptAdmissionToken === admission) {
854
+ promptAdmissionToken = null;
855
+ set({ promptAdmissionPending: false });
856
+ }
857
+ }
858
+ },
859
+ setQuery(query) {
860
+ set({ query });
861
+ },
862
+ setSearchOpen(open) {
863
+ set({ searchOpen: open, ...(open ? {} : { query: "" }) });
864
+ },
865
+ toggleWorkspace(path) {
866
+ const collapsed = new Set(get().collapsed);
867
+ if (collapsed.has(path)) collapsed.delete(path);
868
+ else collapsed.add(path);
869
+ persist(collapsedWorkspacesStorageKey, [...collapsed]);
870
+ set({ collapsed });
871
+ },
872
+ toggleSidebar(narrow) {
873
+ if (narrow) {
874
+ set({ mobileSidebarOpen: !get().mobileSidebarOpen });
875
+ return;
876
+ }
877
+ const sidebarCollapsed = !get().sidebarCollapsed;
878
+ try {
879
+ window.sessionStorage.setItem(
880
+ sidebarCollapsedStorageKey,
881
+ String(sidebarCollapsed),
882
+ );
883
+ } catch {}
884
+ set({ sidebarCollapsed });
885
+ },
886
+ closeMobileSidebar() {
887
+ set({ mobileSidebarOpen: false });
888
+ },
889
+ clearNotice() {
890
+ set({ notice: null });
891
+ },
892
+ };
893
+
894
+ return {
895
+ activeTurn: null,
896
+ turnCancellationPending: false,
897
+ turnTerminalStatus: null,
898
+ pendingFollowUpsReceipt: null,
899
+ snapshot: null,
900
+ cursor: null,
901
+ selectedPath: null,
902
+ selectedWorkspace: null,
903
+ collapsed: readStringSet(collapsedWorkspacesStorageKey),
904
+ sidebarCollapsed: readBoolean(sidebarCollapsedStorageKey),
905
+ mobileSidebarOpen: false,
906
+ query: "",
907
+ searchOpen: false,
908
+ connection: "connecting",
909
+ notice: null,
910
+ liveMessages: [],
911
+ liveRunning: false,
912
+ livePhase: "idle",
913
+ liveRetry: null,
914
+ thinkingStarts: {},
915
+ thinkingDurations: {},
916
+ promptAdmissionPending: false,
917
+ sessionSwitching: false,
918
+ scrollToBottom: 0,
919
+ actions,
920
+ };
921
+ });
922
+
923
+ return store;
924
+ }
925
+
926
+ export const webStore = createWebStore();