@saadnvd1/agent-os 0.3.591 → 0.3.592
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/globals.css +11 -0
- 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/PlanCard.tsx +4 -2
- package/components/Chat/Undo.tsx +3 -0
- package/components/Chat/idle.ts +9 -0
- package/data/chat/useChat.ts +83 -13
- package/data/chat/usePending.ts +70 -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/package.json +1 -1
package/app/globals.css
CHANGED
|
@@ -131,6 +131,17 @@
|
|
|
131
131
|
animation: status-pulse 1.6s ease-in-out infinite;
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
/* Shown only once something has taken a moment: a quick send never flashes it. */
|
|
135
|
+
@keyframes status-late {
|
|
136
|
+
from {
|
|
137
|
+
opacity: 0;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
@utility status-late {
|
|
142
|
+
animation: status-late 0.2s ease-out 0.6s both;
|
|
143
|
+
}
|
|
144
|
+
|
|
134
145
|
@media (prefers-reduced-motion: reduce) {
|
|
135
146
|
.status-live {
|
|
136
147
|
animation: none;
|
|
@@ -51,6 +51,7 @@ import { FIND_EVENT, isFindKey, useFind } from "./useFind";
|
|
|
51
51
|
import { McpServers } from "./McpServers";
|
|
52
52
|
import { ArtifactCard } from "./Artifact";
|
|
53
53
|
import { PlanCard } from "./PlanCard";
|
|
54
|
+
import { ChatIdle } from "./idle";
|
|
54
55
|
import { ContextMeter } from "./ContextMeter";
|
|
55
56
|
import { SetupCard } from "./SetupCard";
|
|
56
57
|
import { UserTurn } from "./EventRow";
|
|
@@ -77,6 +78,7 @@ import {
|
|
|
77
78
|
CommandOutput,
|
|
78
79
|
Compacted,
|
|
79
80
|
SkillChip,
|
|
81
|
+
UserMessage,
|
|
80
82
|
ErrorMessage,
|
|
81
83
|
NoteLine,
|
|
82
84
|
Reasoning,
|
|
@@ -86,10 +88,10 @@ import {
|
|
|
86
88
|
|
|
87
89
|
interface ItemActions {
|
|
88
90
|
respond: (id: string, answer: ApprovalDecision) => void;
|
|
89
|
-
// Absent
|
|
91
|
+
// Absent inside undone messages; hidden while the agent works (ChatIdle).
|
|
90
92
|
onCarryPlan?: (id: string) => void;
|
|
91
93
|
onKeepPlanning?: () => void;
|
|
92
|
-
// Absent
|
|
94
|
+
// Absent inside undone messages; hidden while the agent works (ChatIdle).
|
|
93
95
|
onUndo?: (id: string) => void;
|
|
94
96
|
onQuote?: (text: string) => void;
|
|
95
97
|
}
|
|
@@ -294,6 +296,9 @@ export function ChatPanel({
|
|
|
294
296
|
loadTaskOutput,
|
|
295
297
|
taskOutputs,
|
|
296
298
|
queue,
|
|
299
|
+
pending,
|
|
300
|
+
retrySend,
|
|
301
|
+
dropSend,
|
|
297
302
|
queueEdit,
|
|
298
303
|
queueMove,
|
|
299
304
|
queueDelete,
|
|
@@ -606,12 +611,12 @@ export function ChatPanel({
|
|
|
606
611
|
const actions: ItemActions = useMemo(
|
|
607
612
|
() => ({
|
|
608
613
|
respond,
|
|
609
|
-
onUndo:
|
|
610
|
-
onCarryPlan:
|
|
614
|
+
onUndo: (id) => undo(id, true),
|
|
615
|
+
onCarryPlan: carryPlan,
|
|
611
616
|
onKeepPlanning: focusComposer,
|
|
612
617
|
onQuote: (text) => setInsert(quoteInsert(text)),
|
|
613
618
|
}),
|
|
614
|
-
[respond,
|
|
619
|
+
[respond, undo, carryPlan, focusComposer]
|
|
615
620
|
);
|
|
616
621
|
|
|
617
622
|
return (
|
|
@@ -653,13 +658,42 @@ export function ChatPanel({
|
|
|
653
658
|
</div>
|
|
654
659
|
)}
|
|
655
660
|
<SetupCard sessionId={sessionId} />
|
|
656
|
-
{blocks.length === 0 &&
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
+
{blocks.length === 0 &&
|
|
662
|
+
!queue.length &&
|
|
663
|
+
!pending.length && (
|
|
664
|
+
<p className="text-muted-foreground py-16 text-center text-sm">
|
|
665
|
+
Ask anything to start.
|
|
666
|
+
</p>
|
|
667
|
+
)}
|
|
661
668
|
</div>
|
|
662
|
-
<
|
|
669
|
+
<ChatIdle.Provider value={idle}>
|
|
670
|
+
<TimelineRows blocks={blocks} actions={actions} />
|
|
671
|
+
</ChatIdle.Provider>
|
|
672
|
+
{pending.map((p) => (
|
|
673
|
+
<div
|
|
674
|
+
key={p.id}
|
|
675
|
+
className="mx-auto w-full max-w-3xl px-4 pb-4"
|
|
676
|
+
>
|
|
677
|
+
<UserMessage
|
|
678
|
+
item={{
|
|
679
|
+
id: p.id,
|
|
680
|
+
kind: "user",
|
|
681
|
+
text: p.text,
|
|
682
|
+
images: p.images,
|
|
683
|
+
createdAt: p.at,
|
|
684
|
+
}}
|
|
685
|
+
pending={{
|
|
686
|
+
status: p.status,
|
|
687
|
+
error: p.error,
|
|
688
|
+
onRetry: () => retrySend(p.id),
|
|
689
|
+
onEdit: () => {
|
|
690
|
+
dropSend(p.id);
|
|
691
|
+
setPrefill({ text: p.text, at: Date.now() });
|
|
692
|
+
},
|
|
693
|
+
}}
|
|
694
|
+
/>
|
|
695
|
+
</div>
|
|
696
|
+
))}
|
|
663
697
|
</div>
|
|
664
698
|
</BookmarkChatContext.Provider>
|
|
665
699
|
</ToolBodies.Provider>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createElement } from "react";
|
|
2
2
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
3
|
import { describe, expect, it, vi } from "vitest";
|
|
4
|
-
import { AssistantMessage } from "./Messages";
|
|
4
|
+
import { AssistantMessage, UserMessage } from "./Messages";
|
|
5
5
|
|
|
6
6
|
const item = {
|
|
7
7
|
id: "a1",
|
|
@@ -47,3 +47,42 @@ describe("AssistantMessage quote action", () => {
|
|
|
47
47
|
expect(html).not.toContain("Quote message");
|
|
48
48
|
});
|
|
49
49
|
});
|
|
50
|
+
|
|
51
|
+
describe("a sent message not in the chat yet", () => {
|
|
52
|
+
const user = { id: "user-c-1", kind: "user", text: "An article" } as never;
|
|
53
|
+
const render = (status: "sending" | "accepted" | "failed") =>
|
|
54
|
+
renderToStaticMarkup(
|
|
55
|
+
createElement(UserMessage, {
|
|
56
|
+
item: user,
|
|
57
|
+
pending: {
|
|
58
|
+
status,
|
|
59
|
+
error: "connection lost",
|
|
60
|
+
onRetry: vi.fn(),
|
|
61
|
+
onEdit: vi.fn(),
|
|
62
|
+
},
|
|
63
|
+
})
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
it("shows the text at once, dimmed, with its progress", () => {
|
|
67
|
+
expect(render("sending")).toContain("An article");
|
|
68
|
+
expect(render("sending")).toContain("opacity-60");
|
|
69
|
+
expect(render("sending")).toContain("Sending");
|
|
70
|
+
expect(render("accepted")).toContain("Starting the agent");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("says a failed one wasn't sent, with Retry and Edit", () => {
|
|
74
|
+
const html = render("failed");
|
|
75
|
+
expect(html).toContain("Not sent · connection lost");
|
|
76
|
+
expect(html).toContain(">Retry</button>");
|
|
77
|
+
expect(html).toContain(">Edit</button>");
|
|
78
|
+
expect(html).not.toContain("opacity-60");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("a message in the chat shows no status", () => {
|
|
82
|
+
const html = renderToStaticMarkup(
|
|
83
|
+
createElement(UserMessage, { item: user })
|
|
84
|
+
);
|
|
85
|
+
expect(html).not.toContain("Sending");
|
|
86
|
+
expect(html).not.toContain("Not sent");
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { memo } from "react";
|
|
3
4
|
import {
|
|
4
5
|
Check,
|
|
5
6
|
ChevronRight,
|
|
@@ -9,6 +10,7 @@ import {
|
|
|
9
10
|
Sparkles,
|
|
10
11
|
} from "lucide-react";
|
|
11
12
|
import type { ChatItem } from "@/lib/chat/events";
|
|
13
|
+
import type { PendingSend } from "@/lib/chat/pending";
|
|
12
14
|
import { cn } from "@/lib/utils";
|
|
13
15
|
import { leadingCommand } from "@/lib/chat/commands";
|
|
14
16
|
import { compactedLine } from "@/lib/chat/stale";
|
|
@@ -68,7 +70,9 @@ function Prose({ text }: { text: string }) {
|
|
|
68
70
|
);
|
|
69
71
|
}
|
|
70
72
|
|
|
71
|
-
|
|
73
|
+
// Memoized: a chat full of pasted articles re-renders its rows when a turn
|
|
74
|
+
// starts and ends, and splitting and linking each one again is the cost.
|
|
75
|
+
const UserText = memo(function UserText({ text }: { text: string }) {
|
|
72
76
|
return (
|
|
73
77
|
<>
|
|
74
78
|
{splitUserText(text).map((part, i) => {
|
|
@@ -89,45 +93,111 @@ function UserText({ text }: { text: string }) {
|
|
|
89
93
|
})}
|
|
90
94
|
</>
|
|
91
95
|
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// A sent message not in the chat yet (lib/chat/pending).
|
|
99
|
+
export interface PendingStatus {
|
|
100
|
+
status: PendingSend["status"];
|
|
101
|
+
error?: string;
|
|
102
|
+
onRetry: () => void;
|
|
103
|
+
onEdit: () => void;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function StatusButton({
|
|
107
|
+
onClick,
|
|
108
|
+
children,
|
|
109
|
+
}: {
|
|
110
|
+
onClick: () => void;
|
|
111
|
+
children: string;
|
|
112
|
+
}) {
|
|
113
|
+
return (
|
|
114
|
+
<button
|
|
115
|
+
type="button"
|
|
116
|
+
onClick={onClick}
|
|
117
|
+
className="text-foreground hover:bg-foreground/[0.06] min-h-11 rounded-md px-2 underline underline-offset-2 md:min-h-7"
|
|
118
|
+
>
|
|
119
|
+
{children}
|
|
120
|
+
</button>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function SendStatus({ pending }: { pending: PendingStatus }) {
|
|
125
|
+
if (pending.status === "failed")
|
|
126
|
+
return (
|
|
127
|
+
<div
|
|
128
|
+
data-copy-skip
|
|
129
|
+
role="alert"
|
|
130
|
+
className="text-destructive flex items-center gap-1 font-mono text-[11px]"
|
|
131
|
+
>
|
|
132
|
+
<span>Not sent · {pending.error}</span>
|
|
133
|
+
<StatusButton onClick={pending.onRetry}>Retry</StatusButton>
|
|
134
|
+
<StatusButton onClick={pending.onEdit}>Edit</StatusButton>
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
return (
|
|
138
|
+
<div
|
|
139
|
+
data-copy-skip
|
|
140
|
+
aria-live="polite"
|
|
141
|
+
className="text-muted-foreground status-late flex items-center gap-1.5 font-mono text-[11px]"
|
|
142
|
+
>
|
|
143
|
+
<span className="status-live bg-muted-foreground h-1.5 w-1.5 rounded-full" />
|
|
144
|
+
{pending.status === "accepted" ? "Starting the agent" : "Sending"}
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
92
147
|
}
|
|
93
148
|
|
|
94
149
|
export function UserMessage({
|
|
95
150
|
item,
|
|
96
151
|
onUndo,
|
|
152
|
+
pending,
|
|
97
153
|
}: {
|
|
98
154
|
item: Of<"user">;
|
|
99
155
|
onUndo?: () => void;
|
|
156
|
+
pending?: PendingStatus;
|
|
100
157
|
}) {
|
|
101
|
-
|
|
102
|
-
<
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
158
|
+
const message = (
|
|
159
|
+
<div className="group flex flex-col items-end gap-1">
|
|
160
|
+
{item.from && (
|
|
161
|
+
<span data-copy-skip className="label-mono text-muted-foreground">
|
|
162
|
+
from {item.from}
|
|
163
|
+
</span>
|
|
164
|
+
)}
|
|
165
|
+
{item.images?.length ? (
|
|
166
|
+
<div data-copy-skip className="flex flex-wrap justify-end gap-1.5">
|
|
167
|
+
{item.images.map((img, i) => (
|
|
168
|
+
<ImageThumb
|
|
169
|
+
key={i}
|
|
170
|
+
src={`data:${img.mediaType};base64,${img.data}`}
|
|
171
|
+
/>
|
|
172
|
+
))}
|
|
173
|
+
</div>
|
|
174
|
+
) : null}
|
|
175
|
+
{item.text && (
|
|
176
|
+
<div
|
|
177
|
+
className={cn(
|
|
178
|
+
"bg-foreground/[0.07] max-w-[85%] rounded-2xl rounded-br-md px-3.5 py-2 text-sm whitespace-pre-wrap",
|
|
179
|
+
pending &&
|
|
180
|
+
(pending.status === "failed"
|
|
181
|
+
? "ring-destructive ring-1 ring-inset"
|
|
182
|
+
: "opacity-60")
|
|
183
|
+
)}
|
|
184
|
+
>
|
|
185
|
+
<UserText text={item.text} />
|
|
186
|
+
</div>
|
|
187
|
+
)}
|
|
188
|
+
{pending ? (
|
|
189
|
+
<SendStatus pending={pending} />
|
|
190
|
+
) : (
|
|
124
191
|
<div className="-mr-1.5 flex items-center">
|
|
125
192
|
{onUndo && <UndoButton onClick={onUndo} />}
|
|
126
193
|
<BookmarkButton itemId={item.id} />
|
|
127
194
|
</div>
|
|
128
|
-
|
|
129
|
-
</
|
|
195
|
+
)}
|
|
196
|
+
</div>
|
|
130
197
|
);
|
|
198
|
+
// Not in the chat yet: nothing to star.
|
|
199
|
+
if (pending) return message;
|
|
200
|
+
return <BookmarkPress itemId={item.id}>{message}</BookmarkPress>;
|
|
131
201
|
}
|
|
132
202
|
|
|
133
203
|
export function AssistantMessage({
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { Check, ListChecks } from "lucide-react";
|
|
4
4
|
import { Button } from "@/components/ui/button";
|
|
5
5
|
import type { ChatItem } from "@/lib/chat/events";
|
|
6
|
+
import { useChatIdle } from "./idle";
|
|
6
7
|
import { Markdown } from "./Markdown";
|
|
7
8
|
|
|
8
9
|
type PlanItem = Extract<ChatItem, { kind: "plan" }>;
|
|
@@ -14,10 +15,11 @@ export function PlanCard({
|
|
|
14
15
|
onKeepPlanning,
|
|
15
16
|
}: {
|
|
16
17
|
item: PlanItem;
|
|
17
|
-
// Absent
|
|
18
|
+
// Absent inside undone messages; a running turn disables it too.
|
|
18
19
|
onCarryOut?: () => void;
|
|
19
20
|
onKeepPlanning: () => void;
|
|
20
21
|
}) {
|
|
22
|
+
const idle = useChatIdle();
|
|
21
23
|
return (
|
|
22
24
|
<div className="bg-primary/[0.06] space-y-3 rounded-xl p-3">
|
|
23
25
|
<div className="text-primary flex items-center gap-2 text-xs font-medium">
|
|
@@ -41,7 +43,7 @@ export function PlanCard({
|
|
|
41
43
|
</Button>
|
|
42
44
|
<Button
|
|
43
45
|
className="h-11 md:h-9"
|
|
44
|
-
disabled={!onCarryOut}
|
|
46
|
+
disabled={!onCarryOut || !idle}
|
|
45
47
|
onClick={onCarryOut}
|
|
46
48
|
>
|
|
47
49
|
Carry it out
|
package/components/Chat/Undo.tsx
CHANGED
|
@@ -14,6 +14,7 @@ import type { UndoPreview } from "@/lib/chat/events";
|
|
|
14
14
|
import type { UndoItem } from "@/lib/chat/group";
|
|
15
15
|
import { shortPath } from "@/lib/chat/diff";
|
|
16
16
|
import { cn } from "@/lib/utils";
|
|
17
|
+
import { useChatIdle } from "./idle";
|
|
17
18
|
import { useRowState } from "./rowState";
|
|
18
19
|
|
|
19
20
|
const counts = (p: { insertions?: number; deletions?: number }) =>
|
|
@@ -86,7 +87,9 @@ export function UndoDialog({
|
|
|
86
87
|
);
|
|
87
88
|
}
|
|
88
89
|
|
|
90
|
+
// Shown only while the chat is idle.
|
|
89
91
|
export function UndoButton({ onClick }: { onClick: () => void }) {
|
|
92
|
+
if (!useChatIdle()) return null;
|
|
90
93
|
return (
|
|
91
94
|
<button
|
|
92
95
|
type="button"
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext, useContext } from "react";
|
|
4
|
+
|
|
5
|
+
// Whether the chat can take an undo or carry out a plan (no turn running,
|
|
6
|
+
// connected). A context rather than a prop, so a turn starting or ending
|
|
7
|
+
// re-renders those buttons and not every row of a long conversation.
|
|
8
|
+
export const ChatIdle = createContext(true);
|
|
9
|
+
export const useChatIdle = () => useContext(ChatIdle);
|
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/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 }
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type { ChatImage } from "./events";
|
|
2
|
+
|
|
3
|
+
// A message the composer sent that isn't in the chat or its queue yet. It
|
|
4
|
+
// shows as a bubble at once, and stays (as failed, with Retry) if it never
|
|
5
|
+
// arrives, so a long paste is never lost to a dead socket.
|
|
6
|
+
export interface PendingSend {
|
|
7
|
+
id: string;
|
|
8
|
+
text: string;
|
|
9
|
+
images?: ChatImage[];
|
|
10
|
+
compact?: boolean;
|
|
11
|
+
// "sending": not taken in yet. "accepted": the server has it and is
|
|
12
|
+
// starting the agent.
|
|
13
|
+
status: "sending" | "accepted" | "failed";
|
|
14
|
+
error?: string;
|
|
15
|
+
// When it was last sent or accepted, for the timeouts below.
|
|
16
|
+
at: number;
|
|
17
|
+
// Sends so far; each reconnect resends once, up to MAX_SENDS.
|
|
18
|
+
sends: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type PendingAction =
|
|
22
|
+
| {
|
|
23
|
+
type: "add";
|
|
24
|
+
send: Omit<PendingSend, "status" | "at" | "sends">;
|
|
25
|
+
now: number;
|
|
26
|
+
}
|
|
27
|
+
// Sent again on a new socket, or by Retry.
|
|
28
|
+
| { type: "resent"; id: string; now: number }
|
|
29
|
+
| { type: "accepted"; id: string; now: number }
|
|
30
|
+
| { type: "failed"; id: string; error: string }
|
|
31
|
+
// Ids now in the chat or its queue: those sends are done.
|
|
32
|
+
| { type: "resolved"; ids: Iterable<string> }
|
|
33
|
+
| { type: "drop"; id: string }
|
|
34
|
+
| { type: "tick"; now: number };
|
|
35
|
+
|
|
36
|
+
// No answer at all: the socket is likely dead though not yet closed.
|
|
37
|
+
export const SEND_TIMEOUT_MS = 10_000;
|
|
38
|
+
// Taken in but never shown: a new agent takes seconds, not a minute.
|
|
39
|
+
export const ACCEPTED_TIMEOUT_MS = 60_000;
|
|
40
|
+
export const MAX_SENDS = 3;
|
|
41
|
+
|
|
42
|
+
export const NOT_SENT = "connection lost";
|
|
43
|
+
const NO_ANSWER = "no answer from AgentOS";
|
|
44
|
+
const NEVER_ARRIVED = "the agent didn't take it";
|
|
45
|
+
|
|
46
|
+
export function pendingReducer(
|
|
47
|
+
state: PendingSend[],
|
|
48
|
+
action: PendingAction
|
|
49
|
+
): PendingSend[] {
|
|
50
|
+
const update = (id: string, fn: (p: PendingSend) => PendingSend | null) => {
|
|
51
|
+
const i = state.findIndex((p) => p.id === id);
|
|
52
|
+
if (i === -1) return state;
|
|
53
|
+
const next = fn(state[i]);
|
|
54
|
+
if (next === state[i]) return state;
|
|
55
|
+
const out = state.slice();
|
|
56
|
+
if (next) out[i] = next;
|
|
57
|
+
else out.splice(i, 1);
|
|
58
|
+
return out;
|
|
59
|
+
};
|
|
60
|
+
switch (action.type) {
|
|
61
|
+
case "add":
|
|
62
|
+
if (state.some((p) => p.id === action.send.id)) return state;
|
|
63
|
+
return [
|
|
64
|
+
...state,
|
|
65
|
+
{ ...action.send, status: "sending", at: action.now, sends: 1 },
|
|
66
|
+
];
|
|
67
|
+
case "resent":
|
|
68
|
+
return update(action.id, (p) => ({
|
|
69
|
+
...p,
|
|
70
|
+
status: "sending",
|
|
71
|
+
error: undefined,
|
|
72
|
+
at: action.now,
|
|
73
|
+
sends: p.sends + 1,
|
|
74
|
+
}));
|
|
75
|
+
case "accepted":
|
|
76
|
+
return update(action.id, (p) =>
|
|
77
|
+
p.status === "sending"
|
|
78
|
+
? { ...p, status: "accepted", at: action.now }
|
|
79
|
+
: p
|
|
80
|
+
);
|
|
81
|
+
case "failed":
|
|
82
|
+
return update(action.id, (p) => ({
|
|
83
|
+
...p,
|
|
84
|
+
status: "failed",
|
|
85
|
+
error: action.error,
|
|
86
|
+
}));
|
|
87
|
+
case "resolved": {
|
|
88
|
+
const ids = new Set(action.ids);
|
|
89
|
+
const kept = state.filter((p) => !ids.has(p.id));
|
|
90
|
+
return kept.length === state.length ? state : kept;
|
|
91
|
+
}
|
|
92
|
+
case "drop":
|
|
93
|
+
return update(action.id, () => null);
|
|
94
|
+
case "tick": {
|
|
95
|
+
let changed = false;
|
|
96
|
+
const next = state.map((p) => {
|
|
97
|
+
const limit =
|
|
98
|
+
p.status === "sending"
|
|
99
|
+
? SEND_TIMEOUT_MS
|
|
100
|
+
: p.status === "accepted"
|
|
101
|
+
? ACCEPTED_TIMEOUT_MS
|
|
102
|
+
: Infinity;
|
|
103
|
+
if (action.now - p.at < limit) return p;
|
|
104
|
+
changed = true;
|
|
105
|
+
return {
|
|
106
|
+
...p,
|
|
107
|
+
status: "failed" as const,
|
|
108
|
+
error: p.status === "sending" ? NO_ANSWER : NEVER_ARRIVED,
|
|
109
|
+
};
|
|
110
|
+
});
|
|
111
|
+
return changed ? next : state;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// What a new socket sends again: anything not failed and not over its sends.
|
|
117
|
+
// Safe to repeat: the server drops an id already in the chat or its queue.
|
|
118
|
+
export const toResend = (state: PendingSend[]) =>
|
|
119
|
+
state.filter((p) => p.status !== "failed" && p.sends < MAX_SENDS);
|
|
120
|
+
|
|
121
|
+
// A new socket that can't send again what's still pending marks it failed.
|
|
122
|
+
export const overSends = (state: PendingSend[]) =>
|
|
123
|
+
state.filter((p) => p.status !== "failed" && p.sends >= MAX_SENDS);
|
|
124
|
+
|
|
125
|
+
// getRandomValues, not randomUUID: a phone on plain http over the tailnet
|
|
126
|
+
// isn't a secure context.
|
|
127
|
+
export const clientId = () =>
|
|
128
|
+
`user-c-${Array.from(crypto.getRandomValues(new Uint8Array(12)), (b) =>
|
|
129
|
+
b.toString(16).padStart(2, "0")
|
|
130
|
+
).join("")}`;
|
|
131
|
+
|
|
132
|
+
const keyFor = (sessionId: string) => `agentos:pending:${sessionId}`;
|
|
133
|
+
|
|
134
|
+
// Kept in this browser so a reload doesn't lose a failed article. Over
|
|
135
|
+
// quota, images go first and the words stay.
|
|
136
|
+
export function savePending(sessionId: string, state: PendingSend[]): void {
|
|
137
|
+
try {
|
|
138
|
+
const key = keyFor(sessionId);
|
|
139
|
+
if (!state.length) return localStorage.removeItem(key);
|
|
140
|
+
for (const kept of [
|
|
141
|
+
state,
|
|
142
|
+
state.map((p) => ({ ...p, images: undefined })),
|
|
143
|
+
]) {
|
|
144
|
+
try {
|
|
145
|
+
localStorage.setItem(key, JSON.stringify(kept));
|
|
146
|
+
return;
|
|
147
|
+
} catch {
|
|
148
|
+
// Too big; try without images.
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
} catch {
|
|
152
|
+
// Storage unavailable.
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function loadPending(sessionId: string): PendingSend[] {
|
|
157
|
+
try {
|
|
158
|
+
const raw = localStorage.getItem(keyFor(sessionId));
|
|
159
|
+
const list = raw ? (JSON.parse(raw) as PendingSend[]) : [];
|
|
160
|
+
// From an earlier page: each is sent once more on connecting.
|
|
161
|
+
return Array.isArray(list)
|
|
162
|
+
? list.filter(
|
|
163
|
+
(p) => typeof p?.id === "string" && typeof p.text === "string"
|
|
164
|
+
)
|
|
165
|
+
: [];
|
|
166
|
+
} catch {
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
}
|
package/lib/chat/runner.ts
CHANGED
|
@@ -384,6 +384,10 @@ const sentBy = ({ from, peer, origin }: SentBy): SentBy => ({
|
|
|
384
384
|
origin,
|
|
385
385
|
});
|
|
386
386
|
|
|
387
|
+
// Already a message in the chat or its queue.
|
|
388
|
+
const taken = (sessionId: string, id: string) =>
|
|
389
|
+
hasItem(sessionId, id) || listQueue(sessionId).some((q) => q.id === id);
|
|
390
|
+
|
|
387
391
|
export async function sendChat(
|
|
388
392
|
sessionId: string,
|
|
389
393
|
input: {
|
|
@@ -393,16 +397,20 @@ export async function sendChat(
|
|
|
393
397
|
queue?: boolean;
|
|
394
398
|
// Compact a stale chat first (lib/chat/stale).
|
|
395
399
|
compact?: CompactFirst;
|
|
400
|
+
// The client's own id for it, so a resend after a reconnect is a no-op.
|
|
401
|
+
id?: string;
|
|
396
402
|
} & SentBy
|
|
397
403
|
): Promise<void> {
|
|
398
404
|
const text = input.text.trim();
|
|
399
405
|
if (!text && !input.images?.length) return;
|
|
406
|
+
if (input.id && taken(sessionId, input.id)) return;
|
|
400
407
|
refuseAway(sessionId);
|
|
408
|
+
const id = input.id ?? `user-${Date.now()}-${randomUUID().slice(0, 5)}`;
|
|
401
409
|
// Its worktree is still being set up, its task hasn't launched (held by
|
|
402
410
|
// Pause, say), or it's moving or landing: the message waits its turn.
|
|
403
411
|
if (settingUp(sessionId) || launchPending(sessionId) || chatHold(sessionId)) {
|
|
404
412
|
enqueue(sessionId, {
|
|
405
|
-
id
|
|
413
|
+
id,
|
|
406
414
|
text,
|
|
407
415
|
images: input.images,
|
|
408
416
|
...sentBy(input),
|
|
@@ -411,7 +419,8 @@ export async function sendChat(
|
|
|
411
419
|
return;
|
|
412
420
|
}
|
|
413
421
|
const live = await ensureLive(sessionId);
|
|
414
|
-
|
|
422
|
+
// Sent again while the worker was starting.
|
|
423
|
+
if (input.id && taken(sessionId, id)) return;
|
|
415
424
|
if (queuedIfHeld(sessionId, { ...input, id, text })) return;
|
|
416
425
|
live.worker.command({
|
|
417
426
|
type: "send",
|
package/lib/chat/socket.ts
CHANGED
|
@@ -145,7 +145,18 @@ function onChatMessage(
|
|
|
145
145
|
void Promise.resolve(p).catch(fail);
|
|
146
146
|
if (msg.type === "send") {
|
|
147
147
|
const send = clientSend(msg);
|
|
148
|
-
|
|
148
|
+
const id = send.id;
|
|
149
|
+
// Taken in now: a new worker can take seconds to start.
|
|
150
|
+
if (id) reply({ type: "accepted", id });
|
|
151
|
+
void sendChat(sessionId, { ...send, queue: true }).catch((err) => {
|
|
152
|
+
if (id)
|
|
153
|
+
reply({
|
|
154
|
+
type: "send_failed",
|
|
155
|
+
id,
|
|
156
|
+
message: err instanceof Error ? err.message : String(err),
|
|
157
|
+
});
|
|
158
|
+
fail(err);
|
|
159
|
+
});
|
|
149
160
|
// "Session 4" is named after its first message.
|
|
150
161
|
run(titleChatFromMessage(sessionId, send.text));
|
|
151
162
|
} else if (msg.type === "queue_edit" && typeof msg.text === "string")
|