@saadnvd1/agent-os 0.3.591 → 0.3.594
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/app/api/bus/resurrect/route.test.ts +47 -0
- package/app/api/bus/resurrect/route.ts +36 -0
- package/app/api/sessions/[id]/pause/route.ts +5 -2
- package/app/api/sessions/[id]/resurrect/route.test.ts +32 -0
- package/app/api/sessions/[id]/resurrect/route.ts +24 -0
- package/app/api/sessions/ended/route.ts +19 -0
- package/app/globals.css +11 -0
- package/components/Archived/ArchivedRow.tsx +11 -7
- package/components/Chat/ChatPanel.tsx +45 -11
- package/components/Chat/Messages.test.ts +40 -1
- package/components/Chat/Messages.tsx +96 -26
- package/components/Chat/PausedBanner.tsx +1 -1
- package/components/Chat/PlanCard.tsx +4 -2
- package/components/Chat/Undo.tsx +3 -0
- package/components/Chat/idle.ts +9 -0
- package/components/SessionList/RowSubtitle.tsx +1 -0
- package/components/SessionList/SessionRowMenu.tsx +12 -1
- package/components/SessionList/useDoneAction.ts +14 -0
- package/components/SessionList/useResurrectAction.test.ts +96 -0
- package/components/Tasks/TaskRow.tsx +18 -0
- package/data/chat/useChat.ts +83 -13
- package/data/chat/usePending.ts +70 -0
- package/data/done/index.ts +17 -0
- package/lib/chat/client-send.ts +8 -1
- package/lib/chat/demo.ts +22 -3
- package/lib/chat/drivers/claude.ts +15 -2
- package/lib/chat/drivers/opencode.ts +1 -0
- package/lib/chat/events.ts +7 -2
- package/lib/chat/pending.ts +169 -0
- package/lib/chat/runner.ts +11 -2
- package/lib/chat/socket.ts +12 -1
- package/lib/db/migrations.ts +11 -0
- package/lib/db/types.ts +2 -0
- package/lib/done/worktree.ts +18 -2
- package/lib/orchestrator/act.ts +44 -5
- package/lib/orchestrator/brief.ts +2 -1
- package/lib/orchestrator/digest.ts +3 -2
- package/lib/orchestrator/serve.ts +6 -0
- package/lib/orchestrator/shell-guard.ts +2 -1
- package/lib/orchestrator/tool-names.ts +1 -0
- package/lib/orchestrator/tool-schemas.ts +6 -0
- package/lib/orchestrator/tools.ts +3 -1
- package/lib/resurrect/find.ts +111 -0
- package/lib/resurrect/index.ts +422 -0
- package/lib/resurrect/plan.ts +61 -0
- package/lib/resurrect/snapshot.ts +229 -0
- package/lib/security/demo.ts +1 -0
- package/lib/sidebar/paused.ts +8 -1
- package/lib/sidebar/row-actions.ts +7 -3
- package/lib/sidebar/shelves.ts +7 -1
- package/lib/tasks/auto-hold.ts +17 -0
- package/lib/tasks/finish.ts +9 -0
- package/lib/tasks/pause-hold.ts +1 -1
- package/lib/tasks/pause.ts +76 -8
- package/lib/tasks/queue.ts +39 -12
- package/package.json +1 -1
|
@@ -34,7 +34,11 @@ import { schedulesUiActions } from "@/stores/schedulesUi";
|
|
|
34
34
|
import { recipesUiActions } from "@/stores/recipesUi";
|
|
35
35
|
import { rowGroup } from "@/lib/recipes/chip";
|
|
36
36
|
import { finishActions } from "@/lib/sidebar/row-actions";
|
|
37
|
-
import {
|
|
37
|
+
import {
|
|
38
|
+
useArchiveAction,
|
|
39
|
+
useDoneAction,
|
|
40
|
+
useResurrectAction,
|
|
41
|
+
} from "./useDoneAction";
|
|
38
42
|
import { useShelfActions } from "./useShelfActions";
|
|
39
43
|
import {
|
|
40
44
|
PAUSE_PRESETS,
|
|
@@ -106,6 +110,7 @@ function FinishItems({
|
|
|
106
110
|
}) {
|
|
107
111
|
const markDone = useDoneAction();
|
|
108
112
|
const archive = useArchiveAction();
|
|
113
|
+
const restore = useResurrectAction();
|
|
109
114
|
const actions = finishActions(session, plain);
|
|
110
115
|
return (
|
|
111
116
|
<>
|
|
@@ -115,6 +120,12 @@ function FinishItems({
|
|
|
115
120
|
Done
|
|
116
121
|
</Item>
|
|
117
122
|
)}
|
|
123
|
+
{actions.includes("restore") && (
|
|
124
|
+
<Item onClick={() => restore(session)}>
|
|
125
|
+
<RotateCcw className={icon} />
|
|
126
|
+
Restore
|
|
127
|
+
</Item>
|
|
128
|
+
)}
|
|
118
129
|
{actions.includes("archive") && (
|
|
119
130
|
<Item onClick={() => archive(session)}>
|
|
120
131
|
<Archive className={icon} />
|
|
@@ -3,6 +3,7 @@ import type { Session } from "@/lib/db";
|
|
|
3
3
|
import {
|
|
4
4
|
useArchiveSession,
|
|
5
5
|
useDoneSession,
|
|
6
|
+
useResurrectSession,
|
|
6
7
|
useUnarchiveSession,
|
|
7
8
|
} from "@/data/done";
|
|
8
9
|
import { useSettleSession } from "@/data/sessions/shelf";
|
|
@@ -68,3 +69,16 @@ export function useArchiveAction() {
|
|
|
68
69
|
onError: (e) => toast.error(e.message, { duration: 12000 }),
|
|
69
70
|
});
|
|
70
71
|
}
|
|
72
|
+
|
|
73
|
+
// Restore from a session's menu: the same conversation, its worktree back.
|
|
74
|
+
export function useResurrectAction() {
|
|
75
|
+
const resurrect = useResurrectSession();
|
|
76
|
+
return (session: Session) => {
|
|
77
|
+
const id = toast.loading(`Restoring ${session.name}…`);
|
|
78
|
+
resurrect.mutate(session.id, {
|
|
79
|
+
onSuccess: (outcome) =>
|
|
80
|
+
toast.success(outcome.text, { id, duration: 8000 }),
|
|
81
|
+
onError: (e) => toast.error(e.message, { id, duration: 12000 }),
|
|
82
|
+
});
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import "@/components/Chat/composer/test-dom";
|
|
2
|
+
import { act, createElement, useEffect } from "react";
|
|
3
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
4
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import type { Session } from "@/lib/db/types";
|
|
7
|
+
|
|
8
|
+
const toast = vi.hoisted(() =>
|
|
9
|
+
Object.assign(vi.fn(), {
|
|
10
|
+
loading: vi.fn(() => "t1"),
|
|
11
|
+
success: vi.fn(),
|
|
12
|
+
error: vi.fn(),
|
|
13
|
+
})
|
|
14
|
+
);
|
|
15
|
+
vi.mock("sonner", () => ({ toast }));
|
|
16
|
+
|
|
17
|
+
const { useResurrectAction } = await import("./useDoneAction");
|
|
18
|
+
|
|
19
|
+
(
|
|
20
|
+
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
21
|
+
).IS_REACT_ACT_ENVIRONMENT = true;
|
|
22
|
+
|
|
23
|
+
let root: Root;
|
|
24
|
+
let host: HTMLElement;
|
|
25
|
+
const hook: { current: (s: Session) => void } = { current: () => {} };
|
|
26
|
+
const fetchSpy = vi.fn();
|
|
27
|
+
|
|
28
|
+
function Probe() {
|
|
29
|
+
const restore = useResurrectAction();
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
hook.current = restore;
|
|
32
|
+
});
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
vi.clearAllMocks();
|
|
38
|
+
vi.stubGlobal("fetch", fetchSpy);
|
|
39
|
+
host = document.createElement("div");
|
|
40
|
+
document.body.append(host);
|
|
41
|
+
root = createRoot(host);
|
|
42
|
+
act(() =>
|
|
43
|
+
root.render(
|
|
44
|
+
createElement(
|
|
45
|
+
QueryClientProvider,
|
|
46
|
+
{ client: new QueryClient() },
|
|
47
|
+
createElement(Probe)
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
act(() => root.unmount());
|
|
54
|
+
host.remove();
|
|
55
|
+
vi.unstubAllGlobals();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const task = {
|
|
59
|
+
id: "s1",
|
|
60
|
+
name: "Bookmark plan",
|
|
61
|
+
task_status: "done",
|
|
62
|
+
} as Session;
|
|
63
|
+
const respond = (status: number, body: object) =>
|
|
64
|
+
fetchSpy.mockResolvedValue({
|
|
65
|
+
ok: status < 400,
|
|
66
|
+
status,
|
|
67
|
+
json: async () => body,
|
|
68
|
+
});
|
|
69
|
+
const restore = async () => {
|
|
70
|
+
await act(async () => {
|
|
71
|
+
hook.current(task);
|
|
72
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
73
|
+
});
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
describe("Restore from a session's menu", () => {
|
|
77
|
+
it("posts to the resurrect route and shows what came back", async () => {
|
|
78
|
+
respond(200, { outcome: { text: "Bookmark plan is back" } });
|
|
79
|
+
await restore();
|
|
80
|
+
expect(fetchSpy.mock.calls[0][0]).toBe("/api/sessions/s1/resurrect");
|
|
81
|
+
expect(fetchSpy.mock.calls[0][1]).toMatchObject({ method: "POST" });
|
|
82
|
+
expect(toast.success).toHaveBeenCalledWith(
|
|
83
|
+
"Bookmark plan is back",
|
|
84
|
+
expect.objectContaining({ id: "t1" })
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("shows a refusal's reason", async () => {
|
|
89
|
+
respond(409, { error: "its PR #3 was merged" });
|
|
90
|
+
await restore();
|
|
91
|
+
expect(toast.error).toHaveBeenCalledWith(
|
|
92
|
+
"its PR #3 was merged",
|
|
93
|
+
expect.objectContaining({ id: "t1" })
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
ChefHat,
|
|
6
6
|
ExternalLink,
|
|
7
7
|
GitMerge,
|
|
8
|
+
RotateCcw,
|
|
8
9
|
KanbanSquare,
|
|
9
10
|
SquareTerminal,
|
|
10
11
|
} from "lucide-react";
|
|
@@ -12,6 +13,7 @@ import { Button } from "@/components/ui/button";
|
|
|
12
13
|
import type { TaskView } from "@/lib/tasks";
|
|
13
14
|
import { isFinished } from "@/lib/tasks/state";
|
|
14
15
|
import { useArmTask, useDropTask, useSignOffTask } from "@/data/tasks";
|
|
16
|
+
import { useResurrectSession } from "@/data/done";
|
|
15
17
|
import { tmuxAttachActions } from "@/stores/tmuxAttach";
|
|
16
18
|
import { tasksUiActions } from "@/stores/tasksUi";
|
|
17
19
|
import { recipesUiActions } from "@/stores/recipesUi";
|
|
@@ -27,6 +29,7 @@ export function TaskRow({ task }: { task: TaskView }) {
|
|
|
27
29
|
const signOff = useSignOffTask();
|
|
28
30
|
const drop = useDropTask();
|
|
29
31
|
const arm = useArmTask();
|
|
32
|
+
const restore = useResurrectSession();
|
|
30
33
|
const [confirmDrop, setConfirmDrop] = useState(false);
|
|
31
34
|
const [moveError, setMoveError] = useState<string | null>(null);
|
|
32
35
|
const live = !isFinished(task.state);
|
|
@@ -34,6 +37,7 @@ export function TaskRow({ task }: { task: TaskView }) {
|
|
|
34
37
|
signOff.error?.message ||
|
|
35
38
|
drop.error?.message ||
|
|
36
39
|
arm.error?.message ||
|
|
40
|
+
restore.error?.message ||
|
|
37
41
|
moveError ||
|
|
38
42
|
task.hostError;
|
|
39
43
|
const settingUp = live && task.setup?.status === "running";
|
|
@@ -165,6 +169,20 @@ export function TaskRow({ task }: { task: TaskView }) {
|
|
|
165
169
|
</div>
|
|
166
170
|
)}
|
|
167
171
|
{live && task.state === "plan-ready" && <PlanReview task={task} />}
|
|
172
|
+
{!live && task.state !== "merged" && (
|
|
173
|
+
<div>
|
|
174
|
+
<Button
|
|
175
|
+
size="sm"
|
|
176
|
+
variant="outline"
|
|
177
|
+
className="h-11 md:h-8"
|
|
178
|
+
disabled={restore.isPending}
|
|
179
|
+
onClick={() => restore.mutate(task.id)}
|
|
180
|
+
>
|
|
181
|
+
<RotateCcw className="h-3.5 w-3.5" />
|
|
182
|
+
{restore.isPending ? "Restoring..." : "Restore"}
|
|
183
|
+
</Button>
|
|
184
|
+
</div>
|
|
185
|
+
)}
|
|
168
186
|
{!live && !task.hostId && (
|
|
169
187
|
<div>
|
|
170
188
|
<Button
|
package/data/chat/useChat.ts
CHANGED
|
@@ -23,6 +23,26 @@ import type {
|
|
|
23
23
|
import type { SearchResult } from "@/lib/chat/find";
|
|
24
24
|
import { addBody, touchBody } from "@/lib/chat/bodyCache";
|
|
25
25
|
import { backgroundFailure, type TaskOutput } from "@/lib/chat/background";
|
|
26
|
+
import {
|
|
27
|
+
NOT_SENT,
|
|
28
|
+
clientId,
|
|
29
|
+
overSends,
|
|
30
|
+
toResend,
|
|
31
|
+
type PendingSend,
|
|
32
|
+
} from "@/lib/chat/pending";
|
|
33
|
+
import { usePending } from "./usePending";
|
|
34
|
+
|
|
35
|
+
// A pending send as the socket's `send`, under its own id.
|
|
36
|
+
const sendFrame = (
|
|
37
|
+
p: Pick<PendingSend, "id" | "text" | "images" | "compact">
|
|
38
|
+
) =>
|
|
39
|
+
JSON.stringify({
|
|
40
|
+
type: "send",
|
|
41
|
+
text: p.text,
|
|
42
|
+
images: p.images,
|
|
43
|
+
clientId: p.id,
|
|
44
|
+
...(p.compact && { compact: true }),
|
|
45
|
+
});
|
|
26
46
|
|
|
27
47
|
// The index of the item with `id`, searched from the end: what changes is
|
|
28
48
|
// almost always the latest.
|
|
@@ -180,6 +200,12 @@ export function useChat(
|
|
|
180
200
|
onUndoneRef.current = onUndone;
|
|
181
201
|
});
|
|
182
202
|
const wsRef = useRef<WebSocket | null>(null);
|
|
203
|
+
const {
|
|
204
|
+
pending,
|
|
205
|
+
pendingRef,
|
|
206
|
+
dispatch: pend,
|
|
207
|
+
restore: restorePending,
|
|
208
|
+
} = usePending(sessionId, connected);
|
|
183
209
|
|
|
184
210
|
useEffect(() => {
|
|
185
211
|
let closed = false;
|
|
@@ -214,7 +240,18 @@ export function useChat(
|
|
|
214
240
|
`${protocol}//${window.location.host}/ws/chat?session=${encodeURIComponent(sessionId)}&paged=1`
|
|
215
241
|
);
|
|
216
242
|
wsRef.current = ws;
|
|
217
|
-
ws.onopen = () =>
|
|
243
|
+
ws.onopen = () => {
|
|
244
|
+
setConnected(true);
|
|
245
|
+
restorePending();
|
|
246
|
+
// Sends the last socket took and may have lost: the server drops
|
|
247
|
+
// any it already has.
|
|
248
|
+
for (const p of overSends(pendingRef.current))
|
|
249
|
+
pend({ type: "failed", id: p.id, error: NOT_SENT });
|
|
250
|
+
for (const p of toResend(pendingRef.current)) {
|
|
251
|
+
ws.send(sendFrame(p));
|
|
252
|
+
pend({ type: "resent", id: p.id, now: Date.now() });
|
|
253
|
+
}
|
|
254
|
+
};
|
|
218
255
|
ws.onmessage = (event) => {
|
|
219
256
|
const m = JSON.parse(event.data) as ChatServerMessage;
|
|
220
257
|
if (m.type === "snapshot") {
|
|
@@ -235,8 +272,17 @@ export function useChat(
|
|
|
235
272
|
setQueue(m.queue ?? []);
|
|
236
273
|
setSuggestion(m.suggestion ?? null);
|
|
237
274
|
setLoadedFor(sessionId);
|
|
275
|
+
pend({
|
|
276
|
+
type: "resolved",
|
|
277
|
+
ids: [...m.items, ...(m.queue ?? [])].map((i) => i.id),
|
|
278
|
+
});
|
|
238
279
|
} else if (m.type === "queue") {
|
|
239
280
|
setQueue(m.queue);
|
|
281
|
+
pend({ type: "resolved", ids: m.queue.map((q) => q.id) });
|
|
282
|
+
} else if (m.type === "accepted") {
|
|
283
|
+
pend({ type: "accepted", id: m.id, now: Date.now() });
|
|
284
|
+
} else if (m.type === "send_failed") {
|
|
285
|
+
pend({ type: "failed", id: m.id, error: m.message });
|
|
240
286
|
} else if (m.type === "suggestion") {
|
|
241
287
|
setSuggestion(m.text);
|
|
242
288
|
} else if (m.type === "files") {
|
|
@@ -303,6 +349,8 @@ export function useChat(
|
|
|
303
349
|
addBody(prev, m.id, m.body, visibleBodies.current)
|
|
304
350
|
);
|
|
305
351
|
} else if (m.type === "item") {
|
|
352
|
+
if (m.item.kind === "user")
|
|
353
|
+
pend({ type: "resolved", ids: [m.item.id] });
|
|
306
354
|
withDeltas(() => {
|
|
307
355
|
const routed = routeItem(
|
|
308
356
|
itemsRef.current,
|
|
@@ -346,25 +394,44 @@ export function useChat(
|
|
|
346
394
|
document.removeEventListener("visibilitychange", onVisible);
|
|
347
395
|
wsRef.current?.close();
|
|
348
396
|
};
|
|
349
|
-
}, [sessionId, setItems, setEarlierTasks]);
|
|
397
|
+
}, [sessionId, setItems, setEarlierTasks, pend, pendingRef, restorePending]);
|
|
350
398
|
|
|
351
399
|
const queryClient = useQueryClient();
|
|
352
400
|
const send = useCallback(
|
|
353
401
|
// `compact`: the reader let a stale chat compact first (lib/chat/stale).
|
|
402
|
+
// It shows as a pending bubble at once, until it's in the chat.
|
|
354
403
|
(text: string, images?: ChatImage[], compact?: boolean) => {
|
|
404
|
+
const p = { id: clientId(), text, images, compact };
|
|
405
|
+
pend({ type: "add", send: p, now: Date.now() });
|
|
355
406
|
const ws = wsRef.current;
|
|
356
|
-
if (
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
...(compact && { compact: true }),
|
|
363
|
-
})
|
|
364
|
-
);
|
|
365
|
-
if (ws.readyState === WebSocket.OPEN) markRunning(queryClient, sessionId);
|
|
407
|
+
if (ws?.readyState !== WebSocket.OPEN) {
|
|
408
|
+
pend({ type: "failed", id: p.id, error: NOT_SENT });
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
ws.send(sendFrame(p));
|
|
412
|
+
markRunning(queryClient, sessionId);
|
|
366
413
|
},
|
|
367
|
-
[queryClient, sessionId]
|
|
414
|
+
[queryClient, sessionId, pend]
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
const retrySend = useCallback(
|
|
418
|
+
(id: string) => {
|
|
419
|
+
const p = pendingRef.current.find((s) => s.id === id);
|
|
420
|
+
const ws = wsRef.current;
|
|
421
|
+
if (!p) return;
|
|
422
|
+
if (ws?.readyState !== WebSocket.OPEN) {
|
|
423
|
+
pend({ type: "failed", id, error: NOT_SENT });
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
ws.send(sendFrame(p));
|
|
427
|
+
pend({ type: "resent", id, now: Date.now() });
|
|
428
|
+
},
|
|
429
|
+
[pend, pendingRef]
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
const dropSend = useCallback(
|
|
433
|
+
(id: string) => pend({ type: "drop", id }),
|
|
434
|
+
[pend]
|
|
368
435
|
);
|
|
369
436
|
|
|
370
437
|
const interrupt = useCallback(() => {
|
|
@@ -549,6 +616,9 @@ export function useChat(
|
|
|
549
616
|
loadTaskOutput,
|
|
550
617
|
taskOutputs,
|
|
551
618
|
queue,
|
|
619
|
+
pending,
|
|
620
|
+
retrySend,
|
|
621
|
+
dropSend,
|
|
552
622
|
queueEdit,
|
|
553
623
|
queueMove,
|
|
554
624
|
queueDelete,
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
import {
|
|
5
|
+
loadPending,
|
|
6
|
+
pendingReducer,
|
|
7
|
+
savePending,
|
|
8
|
+
type PendingAction,
|
|
9
|
+
type PendingSend,
|
|
10
|
+
} from "@/lib/chat/pending";
|
|
11
|
+
|
|
12
|
+
const NONE: PendingSend[] = [];
|
|
13
|
+
|
|
14
|
+
// The composer's sends not yet in the chat (lib/chat/pending), kept in this
|
|
15
|
+
// browser per session. `dispatch` and `restore` are stable and read no
|
|
16
|
+
// render's state, so the socket's handlers can call them.
|
|
17
|
+
export function usePending(sessionId: string, connected: boolean) {
|
|
18
|
+
const [state, setState] = useState({ session: sessionId, list: NONE });
|
|
19
|
+
// Another session's sends never show here, even for the render before
|
|
20
|
+
// its own are restored.
|
|
21
|
+
const pending = state.session === sessionId ? state.list : NONE;
|
|
22
|
+
const ref = useRef<PendingSend[]>(NONE);
|
|
23
|
+
const session = useRef(sessionId);
|
|
24
|
+
const restored = useRef<string | null>(null);
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
session.current = sessionId;
|
|
27
|
+
ref.current = NONE;
|
|
28
|
+
}, [sessionId]);
|
|
29
|
+
|
|
30
|
+
const replace = useCallback((list: PendingSend[]) => {
|
|
31
|
+
ref.current = list;
|
|
32
|
+
setState({ session: session.current, list });
|
|
33
|
+
}, []);
|
|
34
|
+
const dispatch = useCallback(
|
|
35
|
+
(action: PendingAction) => {
|
|
36
|
+
const next = pendingReducer(ref.current, action);
|
|
37
|
+
if (next === ref.current) return;
|
|
38
|
+
replace(next);
|
|
39
|
+
savePending(session.current, next);
|
|
40
|
+
},
|
|
41
|
+
[replace]
|
|
42
|
+
);
|
|
43
|
+
// Once per session, as its socket first opens: what an earlier page left
|
|
44
|
+
// mid-send, to be sent again.
|
|
45
|
+
const restore = useCallback(() => {
|
|
46
|
+
if (restored.current === session.current) return;
|
|
47
|
+
restored.current = session.current;
|
|
48
|
+
const now = Date.now();
|
|
49
|
+
replace(
|
|
50
|
+
loadPending(session.current).map((p) =>
|
|
51
|
+
p.status === "failed"
|
|
52
|
+
? p
|
|
53
|
+
: { ...p, status: "sending" as const, at: now }
|
|
54
|
+
)
|
|
55
|
+
);
|
|
56
|
+
}, [replace]);
|
|
57
|
+
|
|
58
|
+
// Timeouts only count while connected: a reconnect sends them again.
|
|
59
|
+
const live = connected && pending.some((p) => p.status !== "failed");
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (!live) return;
|
|
62
|
+
const timer = setInterval(
|
|
63
|
+
() => dispatch({ type: "tick", now: Date.now() }),
|
|
64
|
+
1000
|
|
65
|
+
);
|
|
66
|
+
return () => clearInterval(timer);
|
|
67
|
+
}, [live, dispatch]);
|
|
68
|
+
|
|
69
|
+
return { pending, pendingRef: ref, dispatch, restore };
|
|
70
|
+
}
|
package/data/done/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
|
2
2
|
import type { DoneOutcome } from "@/lib/done";
|
|
3
3
|
import type { BulkResult, PreviewRow } from "@/lib/done/bulk";
|
|
4
4
|
import type { ArchivedView } from "@/lib/done/archive";
|
|
5
|
+
import type { ResurrectOutcome } from "@/lib/resurrect";
|
|
5
6
|
import { sessionKeys } from "../sessions/keys";
|
|
6
7
|
import { taskKeys } from "../tasks";
|
|
7
8
|
|
|
@@ -90,6 +91,22 @@ export function useUnarchiveSession() {
|
|
|
90
91
|
);
|
|
91
92
|
}
|
|
92
93
|
|
|
94
|
+
// Brings a dropped, done or archived session back (lib/resurrect).
|
|
95
|
+
export function useResurrectSession() {
|
|
96
|
+
return useDoneMutation(
|
|
97
|
+
async (id: string) =>
|
|
98
|
+
(
|
|
99
|
+
await json<{ outcome: ResurrectOutcome }>(
|
|
100
|
+
await post(
|
|
101
|
+
`/api/sessions/${id}/resurrect`,
|
|
102
|
+
undefined,
|
|
103
|
+
DONE_REQUEST_TIMEOUT_MS
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
).outcome
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
93
110
|
export function useArchivedQuery(workspaceId?: string, enabled = true) {
|
|
94
111
|
return useQuery({
|
|
95
112
|
queryKey: archivedKeys.list(workspaceId),
|
package/lib/chat/client-send.ts
CHANGED
|
@@ -3,20 +3,27 @@ import type { CompactFirst } from "./stale";
|
|
|
3
3
|
|
|
4
4
|
// What a browser's send may carry: its text and images, and whether the
|
|
5
5
|
// reader chose to compact a stale chat first. Who a message is from (`from`,
|
|
6
|
-
// `peer`, `origin`) is set by the server alone, never by a client.
|
|
6
|
+
// `peer`, `origin`) is set by the server alone, never by a client. Its own
|
|
7
|
+
// id for the message is kept only in a shape no server id takes.
|
|
7
8
|
export function clientSend(msg: unknown): {
|
|
8
9
|
text: string;
|
|
9
10
|
images?: ChatImage[];
|
|
10
11
|
compact?: CompactFirst;
|
|
12
|
+
id?: string;
|
|
11
13
|
} {
|
|
12
14
|
const m = (msg ?? {}) as {
|
|
13
15
|
text?: unknown;
|
|
14
16
|
images?: unknown;
|
|
15
17
|
compact?: unknown;
|
|
18
|
+
clientId?: unknown;
|
|
16
19
|
};
|
|
17
20
|
return {
|
|
18
21
|
text: typeof m.text === "string" ? m.text : "",
|
|
19
22
|
images: Array.isArray(m.images) ? (m.images as ChatImage[]) : undefined,
|
|
20
23
|
...(m.compact === true && { compact: "yes" as const }),
|
|
24
|
+
...(isClientId(m.clientId) && { id: m.clientId }),
|
|
21
25
|
};
|
|
22
26
|
}
|
|
27
|
+
|
|
28
|
+
export const isClientId = (id: unknown): id is string =>
|
|
29
|
+
typeof id === "string" && /^user-c-[A-Za-z0-9-]{8,64}$/.test(id);
|
package/lib/chat/demo.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { randomUUID } from "crypto";
|
|
8
|
+
import { clientSend } from "./client-send";
|
|
8
9
|
import type { ChatConversation } from "./driver";
|
|
9
10
|
import type { ChatClientMessage, ChatServerMessage } from "./events";
|
|
10
11
|
import { demoConversation } from "./drivers/demo";
|
|
@@ -37,7 +38,11 @@ function conversation(sessionId: string): ChatConversation {
|
|
|
37
38
|
return c;
|
|
38
39
|
}
|
|
39
40
|
|
|
40
|
-
export function sendDemoChat(
|
|
41
|
+
export function sendDemoChat(
|
|
42
|
+
sessionId: string,
|
|
43
|
+
text: string,
|
|
44
|
+
id = `user-${Date.now()}-${randomUUID().slice(0, 5)}`
|
|
45
|
+
): void {
|
|
41
46
|
const trimmed = text.trim();
|
|
42
47
|
if (!trimmed) return;
|
|
43
48
|
getSession(sessionId);
|
|
@@ -45,7 +50,7 @@ export function sendDemoChat(sessionId: string, text: string): void {
|
|
|
45
50
|
if (count >= MAX_SENDS) throw new Error(DEMO_REFUSAL);
|
|
46
51
|
sends.set(sessionId, count + 1);
|
|
47
52
|
const item = {
|
|
48
|
-
id
|
|
53
|
+
id,
|
|
49
54
|
kind: "user" as const,
|
|
50
55
|
text: DEMO_VISITOR_TEXT,
|
|
51
56
|
tagged: true as const,
|
|
@@ -84,7 +89,21 @@ export function handleDemoChat(
|
|
|
84
89
|
msg: ChatClientMessage,
|
|
85
90
|
reply: (m: ChatServerMessage) => void
|
|
86
91
|
): void {
|
|
87
|
-
if (msg.type === "send")
|
|
92
|
+
if (msg.type === "send") {
|
|
93
|
+
const { id } = clientSend(msg);
|
|
94
|
+
if (!id) return sendDemoChat(sessionId, String(msg.text));
|
|
95
|
+
reply({ type: "accepted", id });
|
|
96
|
+
try {
|
|
97
|
+
return sendDemoChat(sessionId, String(msg.text), id);
|
|
98
|
+
} catch (err) {
|
|
99
|
+
reply({
|
|
100
|
+
type: "send_failed",
|
|
101
|
+
id,
|
|
102
|
+
message: err instanceof Error ? err.message : String(err),
|
|
103
|
+
});
|
|
104
|
+
throw err;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
88
107
|
if (msg.type === "interrupt") {
|
|
89
108
|
void conversations.get(sessionId)?.interrupt();
|
|
90
109
|
return;
|
|
@@ -183,6 +183,19 @@ export const claudeDriver: ChatDriver = {
|
|
|
183
183
|
);
|
|
184
184
|
let access = options.access;
|
|
185
185
|
let plan = !options.permissionMode && !!options.plan;
|
|
186
|
+
// The mode the CLI is in. Asking again for the same one waits on the
|
|
187
|
+
// CLI starting up (seconds, on a new worker) for nothing.
|
|
188
|
+
let applied = sdkMode(access, plan);
|
|
189
|
+
const applyMode = async () => {
|
|
190
|
+
const mode = sdkMode(access, plan);
|
|
191
|
+
if (mode === applied) return;
|
|
192
|
+
const before = applied;
|
|
193
|
+
applied = mode;
|
|
194
|
+
await q.setPermissionMode(mode).catch((error: unknown) => {
|
|
195
|
+
if (applied === mode) applied = before;
|
|
196
|
+
throw error;
|
|
197
|
+
});
|
|
198
|
+
};
|
|
186
199
|
const q = query({
|
|
187
200
|
prompt: input,
|
|
188
201
|
options: {
|
|
@@ -344,12 +357,12 @@ export const claudeDriver: ChatDriver = {
|
|
|
344
357
|
async setAccess(next) {
|
|
345
358
|
access = next;
|
|
346
359
|
if (options.permissionMode || plan) return;
|
|
347
|
-
await
|
|
360
|
+
await applyMode();
|
|
348
361
|
},
|
|
349
362
|
async setPlan(next) {
|
|
350
363
|
if (options.permissionMode) return;
|
|
351
364
|
plan = next;
|
|
352
|
-
await
|
|
365
|
+
await applyMode();
|
|
353
366
|
},
|
|
354
367
|
respond(id, answer) {
|
|
355
368
|
approvals.respond(id, answer);
|
package/lib/chat/events.ts
CHANGED
|
@@ -381,11 +381,16 @@ export type ChatServerMessage =
|
|
|
381
381
|
ok: boolean;
|
|
382
382
|
error?: string;
|
|
383
383
|
}
|
|
384
|
-
| { type: "undone"; from: string; text: string }
|
|
384
|
+
| { type: "undone"; from: string; text: string }
|
|
385
|
+
// A send with a `clientId` was taken in (before any worker is up), or
|
|
386
|
+
// failed. The message itself still comes as an item or in the queue.
|
|
387
|
+
| { type: "accepted"; id: string }
|
|
388
|
+
| { type: "send_failed"; id: string; message: string };
|
|
385
389
|
|
|
386
390
|
// What a browser sends.
|
|
387
391
|
export type ChatClientMessage =
|
|
388
|
-
|
|
392
|
+
// `clientId` (`user-c-…`) becomes the message's id: a resend is a no-op.
|
|
393
|
+
| { type: "send"; text: string; images?: ChatImage[]; clientId?: string }
|
|
389
394
|
| { type: "queue_edit"; id: string; text: string }
|
|
390
395
|
| { type: "queue_move"; id: string; by: -1 | 1 }
|
|
391
396
|
| { type: "queue_delete"; id: string }
|