canopy-ui 0.11.0 → 0.12.1
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/package.json +1 -1
- package/src/chat/MessageItem.tsx +11 -0
- package/src/chat/protocol.ts +3 -1
- package/src/chat/sessionReducer.events.test.ts +17 -0
- package/src/chat/sessionReducer.ts +120 -2
- package/src/chat/useSessionSocket.http.test.tsx +1 -0
- package/src/chat/useSessionSocket.receipt.test.tsx +161 -0
- package/src/chat/useSessionSocket.ts +89 -34
- package/src/ui/dropdown-menu.test.tsx +26 -0
- package/src/ui/dropdown-menu.tsx +15 -3
- package/src/ui/tooltip.tsx +1 -1
package/package.json
CHANGED
package/src/chat/MessageItem.tsx
CHANGED
|
@@ -136,6 +136,17 @@ export function MessageItem({
|
|
|
136
136
|
{isError && message.role === "assistant" && (
|
|
137
137
|
<ErrorFooter detail={message.error_detail} hasPartial={Boolean(text)} />
|
|
138
138
|
)}
|
|
139
|
+
{/* Your own line, before canopy has confirmed it. Shown at once so a send
|
|
140
|
+
never looks like it vanished; flagged plainly if it did not go. */}
|
|
141
|
+
{message.role === "user" && isPending && (
|
|
142
|
+
<div className="mt-1 text-[11px] opacity-70" role="status">Sending…</div>
|
|
143
|
+
)}
|
|
144
|
+
{message.role === "user" && isError && (
|
|
145
|
+
<div className="mt-1 flex items-start gap-1 text-[11px] font-medium" role="alert">
|
|
146
|
+
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0" aria-hidden="true" />
|
|
147
|
+
<span>{message.error_detail || "Not sent."}</span>
|
|
148
|
+
</div>
|
|
149
|
+
)}
|
|
139
150
|
</div>
|
|
140
151
|
);
|
|
141
152
|
}
|
package/src/chat/protocol.ts
CHANGED
|
@@ -251,7 +251,9 @@ export type WsEvent =
|
|
|
251
251
|
| { event: "chat.stream_cancelled"; data: { message_id: string | null; partial_len: number } }
|
|
252
252
|
| { event: "draft.updated"; data: Draft }
|
|
253
253
|
| { event: "draft.lock_changed"; data: { draft_id: string; holder_user_id: number | null; expires_at: number | null } }
|
|
254
|
-
|
|
254
|
+
// `client_id` is the sender's receipt for a send that carried one (see
|
|
255
|
+
// useSessionSocket.sendChat); absent from older servers.
|
|
256
|
+
| { event: "draft.committed"; data: { draft_id: string; user_message_id: string; client_id?: string } }
|
|
255
257
|
| { event: "draft.discarded"; data: { draft_id: string } }
|
|
256
258
|
// `participant` carries WHO joined, so a client can add them to its
|
|
257
259
|
// participant list. Without it a first-time joiner has an id and no name,
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { REDUCER_EVENTS } from "./sessionReducer";
|
|
6
|
+
|
|
7
|
+
/** The hook passes the reducer only the frames in REDUCER_EVENTS. A case the
|
|
8
|
+
* list lacks is dead code that looks alive, which is how `draft.committed`,
|
|
9
|
+
* `presence.*` and `session.turn_status` went unhandled from 2026-09-13 to
|
|
10
|
+
* 2026-09-23 without a single failing test. */
|
|
11
|
+
describe("REDUCER_EVENTS", () => {
|
|
12
|
+
it("names exactly the frames the reducer's switch handles", () => {
|
|
13
|
+
const src = readFileSync(fileURLToPath(new URL("./sessionReducer.ts", import.meta.url)), "utf8");
|
|
14
|
+
const cases = new Set([...src.matchAll(/case "([a-z_.]+)":/g)].map((m) => m[1]));
|
|
15
|
+
expect([...cases].sort()).toEqual([...REDUCER_EVENTS].sort());
|
|
16
|
+
});
|
|
17
|
+
});
|
|
@@ -27,6 +27,25 @@ const UNBLOCKING_FRAMES = new Set([
|
|
|
27
27
|
"chat.tool_result",
|
|
28
28
|
]);
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Every frame `sessionReducer` handles. The socket hook hands the reducer ONLY
|
|
32
|
+
* these, and gives anything else to `onUnknownEvent`, so a frame missing from
|
|
33
|
+
* here is a frame the reducer never sees. That happened: from #775
|
|
34
|
+
* (2026-09-13) the hook kept its own hand-written list, which lacked every
|
|
35
|
+
* `draft.*` and `presence.*` frame and `session.turn_status`. Your own line did
|
|
36
|
+
* not appear when you sent it (`draft.committed`), and the co-edited draft,
|
|
37
|
+
* the presence roster and the live turn status all went quiet, with no error.
|
|
38
|
+
* `sessionReducer.events.test.ts` fails if this and the switch below differ.
|
|
39
|
+
*/
|
|
40
|
+
export const REDUCER_EVENTS: ReadonlySet<string> = new Set([
|
|
41
|
+
"session.state", "session.activity", "session.stop", "session.menu",
|
|
42
|
+
"session.turn_status", "session.error", "session.title_updated",
|
|
43
|
+
"chat.stream_start", "chat.user_message", "chat.delta", "chat.stream_complete",
|
|
44
|
+
"chat.stream_error", "chat.stream_cancelled", "chat.tool_use", "chat.tool_result",
|
|
45
|
+
"draft.updated", "draft.lock_changed", "draft.committed", "draft.discarded",
|
|
46
|
+
"presence.joined", "presence.left",
|
|
47
|
+
]);
|
|
48
|
+
|
|
30
49
|
export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState {
|
|
31
50
|
if (frame.event === "chat.stream_start" || frame.event === "draft.committed") {
|
|
32
51
|
// A new turn is starting, so the previous turn's stop outcome is history —
|
|
@@ -41,8 +60,10 @@ export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState
|
|
|
41
60
|
prev = { ...prev, activity: "working", menu: undefined };
|
|
42
61
|
}
|
|
43
62
|
switch (frame.event) {
|
|
44
|
-
case "session.state":
|
|
45
|
-
|
|
63
|
+
case "session.state": {
|
|
64
|
+
const messages = keepUnechoedSends(prev.messages, frame.data.messages);
|
|
65
|
+
return messages === frame.data.messages ? frame.data : { ...frame.data, messages };
|
|
66
|
+
}
|
|
46
67
|
|
|
47
68
|
case "chat.stream_start": {
|
|
48
69
|
// Upsert: if the assistant message already exists (rare — a runner that
|
|
@@ -153,6 +174,8 @@ export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState
|
|
|
153
174
|
// is the durable one, so the row sorts where a reload puts it.
|
|
154
175
|
turn_index: frame.data.turn_index,
|
|
155
176
|
plaintext: frame.data.plaintext,
|
|
177
|
+
// The agent read it, so a pending or unconfirmed row is sent.
|
|
178
|
+
...(m.role === "user" ? { status: "complete" as const, error_detail: null } : {}),
|
|
156
179
|
}
|
|
157
180
|
: m,
|
|
158
181
|
),
|
|
@@ -333,6 +356,15 @@ export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState
|
|
|
333
356
|
return prev;
|
|
334
357
|
|
|
335
358
|
case "draft.committed": {
|
|
359
|
+
// The sender already shows its line (a pending row stamped with this
|
|
360
|
+
// client_id, see `withPendingSend`). Confirm it rather than add a second.
|
|
361
|
+
const cid = frame.data.client_id;
|
|
362
|
+
if (cid && prev.messages.some((m) => m.content?.client_id === cid)) {
|
|
363
|
+
return {
|
|
364
|
+
...markSendDelivered(prev, cid, frame.data.user_message_id),
|
|
365
|
+
active_draft: prev.active_draft ? { ...prev.active_draft, body: "" } : prev.active_draft,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
336
368
|
// Insert the optimistic USER message from the draft body that's about
|
|
337
369
|
// to be cleared. The assistant reply is NOT inserted here — canopy's
|
|
338
370
|
// draft.committed carries no assistant id; `chat.stream_start` upserts
|
|
@@ -446,3 +478,89 @@ export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState
|
|
|
446
478
|
return prev;
|
|
447
479
|
}
|
|
448
480
|
}
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
// ---------------------------------------------------------------------------
|
|
484
|
+
// A send the SENDER is waiting to hear back about.
|
|
485
|
+
//
|
|
486
|
+
// Your line goes on screen the moment you press send, as "pending", stamped
|
|
487
|
+
// with the send's client_id. It becomes "complete" on the server's receipt
|
|
488
|
+
// (`draft.committed` carrying that id), or when an HTTP resend succeeds, or when
|
|
489
|
+
// the transcript echoes it back. If none of those happen it becomes "error",
|
|
490
|
+
// and the words stay on screen. Before this the composer cleared on send and
|
|
491
|
+
// nothing showed the line until the agent read it, so a send lost on a dead
|
|
492
|
+
// socket simply vanished (2026-09-23).
|
|
493
|
+
// ---------------------------------------------------------------------------
|
|
494
|
+
|
|
495
|
+
export function withPendingSend(
|
|
496
|
+
prev: SessionState,
|
|
497
|
+
{ clientId, text }: { clientId: string; text: string },
|
|
498
|
+
): SessionState {
|
|
499
|
+
const nowIso = new Date().toISOString();
|
|
500
|
+
const row: Message = {
|
|
501
|
+
id: `local:${clientId}`,
|
|
502
|
+
turn_index: prev.messages.reduce((acc, m) => Math.max(acc, m.turn_index), 0) + 1,
|
|
503
|
+
role: "user",
|
|
504
|
+
content: { text, client_id: clientId },
|
|
505
|
+
plaintext: text,
|
|
506
|
+
status: "pending",
|
|
507
|
+
error_detail: null,
|
|
508
|
+
started_at: null,
|
|
509
|
+
completed_at: null,
|
|
510
|
+
created_at: nowIso,
|
|
511
|
+
};
|
|
512
|
+
return { ...prev, messages: [...prev.messages, row] };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function updateSend(prev: SessionState, clientId: string, patch: Partial<Message>): SessionState {
|
|
516
|
+
let changed = false;
|
|
517
|
+
const messages = prev.messages.map((m) => {
|
|
518
|
+
if (m.content?.client_id !== clientId) return m;
|
|
519
|
+
changed = true;
|
|
520
|
+
return { ...m, ...patch };
|
|
521
|
+
});
|
|
522
|
+
return changed ? { ...prev, messages } : prev;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
export function markSendDelivered(prev: SessionState, clientId: string, messageId?: string): SessionState {
|
|
526
|
+
return updateSend(prev, clientId, {
|
|
527
|
+
status: "complete",
|
|
528
|
+
error_detail: null,
|
|
529
|
+
completed_at: new Date().toISOString(),
|
|
530
|
+
...(messageId ? { id: messageId } : {}),
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
export function markSendFailed(prev: SessionState, clientId: string, detail: string): SessionState {
|
|
535
|
+
// Only a row still waiting can fail: a late failure must not overturn a
|
|
536
|
+
// receipt or a transcript echo that already proved the send landed.
|
|
537
|
+
const row = prev.messages.find((m) => m.content?.client_id === clientId);
|
|
538
|
+
if (!row || row.status !== "pending") return prev;
|
|
539
|
+
return updateSend(prev, clientId, { status: "error", error_detail: detail });
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** Your own sends the snapshot does not hold yet, carried across it.
|
|
543
|
+
*
|
|
544
|
+
* A snapshot REPLACES the message list, and in a transcript-sourced session
|
|
545
|
+
* your line is not a server row until the agent has read it. That can be
|
|
546
|
+
* minutes if a turn is still running, since a session runs one turn at a
|
|
547
|
+
* time. So every reconnect in that window (a phone resuming, a flaky network)
|
|
548
|
+
* took the line off screen, and it came back only when the agent read it. It
|
|
549
|
+
* looked lost. A row is kept only while the snapshot lacks it: once the
|
|
550
|
+
* transcript echo is in the snapshot (same words among its recent user rows),
|
|
551
|
+
* the snapshot's copy wins.
|
|
552
|
+
*/
|
|
553
|
+
function keepUnechoedSends(previous: Message[], snapshot: Message[]): Message[] {
|
|
554
|
+
const mine = previous.filter(
|
|
555
|
+
(m) => m.role === "user" && typeof m.content?.client_id === "string" &&
|
|
556
|
+
!snapshot.some((s) => s.id === m.id),
|
|
557
|
+
);
|
|
558
|
+
if (mine.length === 0) return snapshot;
|
|
559
|
+
const recent = new Set(
|
|
560
|
+
snapshot.filter((m) => m.role === "user").slice(-12).map((m) => m.plaintext.trim()),
|
|
561
|
+
);
|
|
562
|
+
const carried = mine.filter((m) => !recent.has(m.plaintext.trim()));
|
|
563
|
+
if (carried.length === 0) return snapshot;
|
|
564
|
+
const top = snapshot.reduce((acc, m) => Math.max(acc, m.turn_index), 0);
|
|
565
|
+
return [...snapshot, ...carried.map((m, i) => ({ ...m, turn_index: top + 1 + i }))];
|
|
566
|
+
}
|
|
@@ -78,6 +78,7 @@ describe('sending over HTTP', () => {
|
|
|
78
78
|
|
|
79
79
|
it('without it, a user sends over the socket exactly as before', async () => {
|
|
80
80
|
const hook = renderHook(() => useSessionSocket({ sessionId: 's1', wsUrl: () => 'wss://h/x' }))
|
|
81
|
+
act(() => hook.result.current.updateDraft('hello'))
|
|
81
82
|
await act(async () => hook.result.current.sendChat())
|
|
82
83
|
expect(FakeSocket.sent.some((f) => f.includes('chat.send'))).toBe(true)
|
|
83
84
|
})
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act, renderHook } from '@testing-library/react'
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import { useSessionSocket } from './useSessionSocket'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A send must never vanish. A phone resuming from the background can hold a
|
|
9
|
+
* socket that reads OPEN but delivers nothing, and before this the composer
|
|
10
|
+
* cleared, no line appeared, and the prompt was simply gone (2026-09-23).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
class FakeSocket {
|
|
14
|
+
static OPEN = 1
|
|
15
|
+
static last: FakeSocket | null = null
|
|
16
|
+
sent: string[] = []
|
|
17
|
+
onopen: (() => void) | null = null
|
|
18
|
+
onmessage: ((e: { data: string }) => void) | null = null
|
|
19
|
+
onclose: (() => void) | null = null
|
|
20
|
+
onerror: (() => void) | null = null
|
|
21
|
+
readyState = 1
|
|
22
|
+
constructor(public url: string) {
|
|
23
|
+
FakeSocket.last = this
|
|
24
|
+
}
|
|
25
|
+
send(data: string) {
|
|
26
|
+
this.sent.push(data)
|
|
27
|
+
}
|
|
28
|
+
close() {}
|
|
29
|
+
receive(frame: unknown) {
|
|
30
|
+
this.onmessage?.({ data: JSON.stringify(frame) })
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const DRAFT = { id: 'd1', slot: 'next', status: 'open', body: '', version: 1, last_editor: 0, last_edit_at: '' }
|
|
35
|
+
const SNAPSHOT = {
|
|
36
|
+
event: 'session.state',
|
|
37
|
+
data: { messages: [], active_draft: DRAFT, participants: [], presence_user_ids: [], current_user_id: 1 },
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
vi.useFakeTimers()
|
|
42
|
+
vi.stubGlobal('WebSocket', FakeSocket)
|
|
43
|
+
})
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
vi.useRealTimers()
|
|
46
|
+
vi.unstubAllGlobals()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
// Stable, as a real caller's is: a new function every render re-keys `connect`
|
|
50
|
+
// and opens a fresh socket, which would hide what the first one was sent.
|
|
51
|
+
const wsUrl = () => 'wss://h/x'
|
|
52
|
+
|
|
53
|
+
function user(resendOverHttp?: (text: string, clientId: string) => Promise<unknown>) {
|
|
54
|
+
const hook = renderHook(() =>
|
|
55
|
+
useSessionSocket({ sessionId: 's1', wsUrl, resendOverHttp }),
|
|
56
|
+
)
|
|
57
|
+
act(() => {
|
|
58
|
+
FakeSocket.last!.onopen?.()
|
|
59
|
+
FakeSocket.last!.receive(SNAPSHOT)
|
|
60
|
+
})
|
|
61
|
+
return hook
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function sentSend() {
|
|
65
|
+
const frame = FakeSocket.last!.sent.map((f) => JSON.parse(f)).find((f) => f.action === 'chat.send')
|
|
66
|
+
return frame?.data as { client_id: string; text: string }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe('a send carries its own text and waits for a receipt', () => {
|
|
70
|
+
it('shows the line at once as pending, and sends the text with a client_id', () => {
|
|
71
|
+
const hook = user()
|
|
72
|
+
act(() => hook.result.current.updateDraft('deploy it'))
|
|
73
|
+
act(() => hook.result.current.sendChat())
|
|
74
|
+
const row = hook.result.current.state.messages.at(-1)!
|
|
75
|
+
expect(row.plaintext).toBe('deploy it')
|
|
76
|
+
expect(row.status).toBe('pending')
|
|
77
|
+
expect(sentSend().text).toBe('deploy it')
|
|
78
|
+
expect(sentSend().client_id).toBeTruthy()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('the receipt confirms the SAME row rather than adding a second', () => {
|
|
82
|
+
const hook = user()
|
|
83
|
+
act(() => hook.result.current.updateDraft('deploy it'))
|
|
84
|
+
act(() => hook.result.current.sendChat())
|
|
85
|
+
act(() => FakeSocket.last!.receive({
|
|
86
|
+
event: 'draft.committed',
|
|
87
|
+
data: { draft_id: 'd1', user_message_id: 'transient:abc', client_id: sentSend().client_id },
|
|
88
|
+
}))
|
|
89
|
+
const users = hook.result.current.state.messages.filter((m) => m.role === 'user')
|
|
90
|
+
expect(users).toHaveLength(1)
|
|
91
|
+
expect(users[0].status).toBe('complete')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('no receipt: resent over HTTP under the SAME client_id, so it is one turn', async () => {
|
|
95
|
+
const resend = vi.fn().mockResolvedValue({})
|
|
96
|
+
const hook = user(resend)
|
|
97
|
+
act(() => hook.result.current.updateDraft('deploy it'))
|
|
98
|
+
act(() => hook.result.current.sendChat())
|
|
99
|
+
await act(async () => {
|
|
100
|
+
vi.advanceTimersByTime(5_000)
|
|
101
|
+
await Promise.resolve()
|
|
102
|
+
})
|
|
103
|
+
expect(resend).toHaveBeenCalledWith('deploy it', sentSend().client_id)
|
|
104
|
+
expect(hook.result.current.state.messages.at(-1)?.status).toBe('complete')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('a socket that closes before the receipt resends at once', async () => {
|
|
108
|
+
const resend = vi.fn().mockResolvedValue({})
|
|
109
|
+
const hook = user(resend)
|
|
110
|
+
act(() => hook.result.current.updateDraft('deploy it'))
|
|
111
|
+
act(() => hook.result.current.sendChat())
|
|
112
|
+
await act(async () => {
|
|
113
|
+
FakeSocket.last!.onclose?.()
|
|
114
|
+
await Promise.resolve()
|
|
115
|
+
})
|
|
116
|
+
expect(resend).toHaveBeenCalledTimes(1)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('if the resend fails too, the words stay on screen marked not sent', async () => {
|
|
120
|
+
const hook = user(vi.fn().mockRejectedValue(new Error('offline')))
|
|
121
|
+
act(() => hook.result.current.updateDraft('keep me'))
|
|
122
|
+
act(() => hook.result.current.sendChat())
|
|
123
|
+
await act(async () => {
|
|
124
|
+
vi.advanceTimersByTime(5_000)
|
|
125
|
+
await Promise.resolve()
|
|
126
|
+
await Promise.resolve()
|
|
127
|
+
})
|
|
128
|
+
const row = hook.result.current.state.messages.at(-1)!
|
|
129
|
+
expect(row.plaintext).toBe('keep me')
|
|
130
|
+
expect(row.status).toBe('error')
|
|
131
|
+
expect(row.error_detail).toMatch(/Not sent/)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('a reconnect snapshot that does not hold the line yet keeps it on screen', () => {
|
|
135
|
+
const hook = user()
|
|
136
|
+
act(() => hook.result.current.updateDraft('queued behind a running turn'))
|
|
137
|
+
act(() => hook.result.current.sendChat())
|
|
138
|
+
act(() => FakeSocket.last!.receive({
|
|
139
|
+
event: 'draft.committed',
|
|
140
|
+
data: { draft_id: 'd1', user_message_id: 'transient:abc', client_id: sentSend().client_id },
|
|
141
|
+
}))
|
|
142
|
+
// Reconnect: the snapshot has no row for it yet (the agent has not read it).
|
|
143
|
+
act(() => FakeSocket.last!.receive(SNAPSHOT))
|
|
144
|
+
expect(hook.result.current.state.messages.map((m) => m.plaintext)).toEqual([
|
|
145
|
+
'queued behind a running turn',
|
|
146
|
+
])
|
|
147
|
+
// And once the transcript has it, the snapshot's copy wins: one row.
|
|
148
|
+
act(() => FakeSocket.last!.receive({
|
|
149
|
+
...SNAPSHOT,
|
|
150
|
+
data: {
|
|
151
|
+
...SNAPSHOT.data,
|
|
152
|
+
messages: [{
|
|
153
|
+
id: 'm9', turn_index: 90001, role: 'user', content: {}, plaintext: 'queued behind a running turn',
|
|
154
|
+
status: 'complete', error_detail: null, started_at: null, completed_at: null, created_at: '',
|
|
155
|
+
}],
|
|
156
|
+
},
|
|
157
|
+
}))
|
|
158
|
+
expect(hook.result.current.state.messages).toHaveLength(1)
|
|
159
|
+
expect(hook.result.current.state.messages[0].id).toBe('m9')
|
|
160
|
+
})
|
|
161
|
+
})
|
|
@@ -4,11 +4,29 @@ import { fromAgui, resetAguiState } from "./agui";
|
|
|
4
4
|
import type { Draft, Message, SessionState, WsEvent } from "./protocol";
|
|
5
5
|
import { shouldSyncDraftLive } from "./drafts";
|
|
6
6
|
import { prependHistory } from "./history";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
REDUCER_EVENTS,
|
|
9
|
+
markSendDelivered,
|
|
10
|
+
markSendFailed,
|
|
11
|
+
sessionReducer,
|
|
12
|
+
withPendingSend,
|
|
13
|
+
} from "./sessionReducer";
|
|
8
14
|
|
|
9
15
|
const HEARTBEAT_INTERVAL_MS = 20_000;
|
|
10
16
|
const RECONNECT_DELAYS_MS = [1_000, 2_000, 5_000, 10_000];
|
|
11
17
|
const DRAFT_UPDATE_DEBOUNCE_MS = 150;
|
|
18
|
+
/** How long a socket send may go without the server's receipt before the page
|
|
19
|
+
* resends it over HTTP. Short on purpose: the receipt normally arrives within
|
|
20
|
+
* a round trip, and a missing one means the socket is dead (a phone resuming
|
|
21
|
+
* from the background can hold one that still reads OPEN). */
|
|
22
|
+
const SEND_RECEIPT_TIMEOUT_MS = 4_000;
|
|
23
|
+
const NOT_SENT =
|
|
24
|
+
"Not sent: the connection dropped before canopy got it. Your words are kept here, so copy them and send again.";
|
|
25
|
+
|
|
26
|
+
function newClientId(): string {
|
|
27
|
+
const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
|
|
28
|
+
return c?.randomUUID ? c.randomUUID() : `c${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`;
|
|
29
|
+
}
|
|
12
30
|
|
|
13
31
|
const INITIAL_STATE: SessionState = {
|
|
14
32
|
messages: [],
|
|
@@ -68,6 +86,14 @@ export interface UseSessionSocketOptions {
|
|
|
68
86
|
* without a second send path in every host. Omit for a user (the default).
|
|
69
87
|
*/
|
|
70
88
|
sendOverHttp?: (text: string) => Promise<unknown>;
|
|
89
|
+
/**
|
|
90
|
+
* The HTTP route for a USER's send, used only when a socket send gets no
|
|
91
|
+
* receipt (see `SEND_RECEIPT_TIMEOUT_MS`). Must pass `clientId` through as the
|
|
92
|
+
* send's `client_id`: the server derives the turn's idempotency key from it,
|
|
93
|
+
* so a socket frame that did arrive late and this resend make ONE turn.
|
|
94
|
+
* Without it a send on a dead socket is marked "not sent" instead.
|
|
95
|
+
*/
|
|
96
|
+
resendOverHttp?: (text: string, clientId: string) => Promise<unknown>;
|
|
71
97
|
}
|
|
72
98
|
|
|
73
99
|
/**
|
|
@@ -123,20 +149,10 @@ export interface UseSessionSocketResult {
|
|
|
123
149
|
lastError: string | null;
|
|
124
150
|
}
|
|
125
151
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
* container wondering why its feature never fires.
|
|
131
|
-
*
|
|
132
|
-
* Keep in step with sessionReducer's own switch.
|
|
133
|
-
*/
|
|
134
|
-
const KNOWN_EVENTS = new Set([
|
|
135
|
-
"chat.delta", "chat.stream_cancelled", "chat.stream_complete",
|
|
136
|
-
"chat.stream_error", "chat.stream_start", "chat.tool_result",
|
|
137
|
-
"chat.tool_use", "chat.user_message", "session.activity", "session.error",
|
|
138
|
-
"session.menu", "session.state", "session.stop", "session.title_updated",
|
|
139
|
-
]);
|
|
152
|
+
// Frames the reducer handles: its own list, so the two cannot drift (see
|
|
153
|
+
// `REDUCER_EVENTS`). Anything else goes to `onUnknownEvent` rather than being
|
|
154
|
+
// dropped.
|
|
155
|
+
const KNOWN_EVENTS = REDUCER_EVENTS;
|
|
140
156
|
|
|
141
157
|
export function useSessionSocket({
|
|
142
158
|
sessionId,
|
|
@@ -145,6 +161,7 @@ export function useSessionSocket({
|
|
|
145
161
|
onUnknownEvent,
|
|
146
162
|
protocol = "canopy",
|
|
147
163
|
sendOverHttp,
|
|
164
|
+
resendOverHttp,
|
|
148
165
|
}: UseSessionSocketOptions): UseSessionSocketResult {
|
|
149
166
|
// The local draft a read-only principal types into (see `sendOverHttp`).
|
|
150
167
|
const [localDraft, setLocalDraft] = useState<Draft | null>(null);
|
|
@@ -176,6 +193,10 @@ export function useSessionSocket({
|
|
|
176
193
|
// Control frames that must not be lost across a reconnect (currently
|
|
177
194
|
// only chat.stop). The WS-world analogue of an abortable chat transport.
|
|
178
195
|
const pendingFramesRef = useRef<{ action: string; data: unknown }[]>([]);
|
|
196
|
+
// Sends awaiting the server's receipt: client_id -> {text, timer}.
|
|
197
|
+
const unconfirmedRef = useRef(new Map<string, { text: string; timer: number | null }>());
|
|
198
|
+
const resendRef = useRef(resendOverHttp);
|
|
199
|
+
resendRef.current = resendOverHttp;
|
|
179
200
|
|
|
180
201
|
useEffect(() => {
|
|
181
202
|
stateRef.current = state;
|
|
@@ -193,7 +214,7 @@ export function useSessionSocket({
|
|
|
193
214
|
const ws = socketRef.current;
|
|
194
215
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
195
216
|
ws.send(JSON.stringify(frame));
|
|
196
|
-
return;
|
|
217
|
+
return true;
|
|
197
218
|
}
|
|
198
219
|
// Queue chat.stop so a stop clicked while the socket is reconnecting
|
|
199
220
|
// is delivered on next OPEN instead of silently dropped. Draft updates
|
|
@@ -202,6 +223,29 @@ export function useSessionSocket({
|
|
|
202
223
|
if (frame.action === "chat.stop") {
|
|
203
224
|
pendingFramesRef.current.push(frame);
|
|
204
225
|
}
|
|
226
|
+
return false;
|
|
227
|
+
}, []);
|
|
228
|
+
|
|
229
|
+
// A send that got no receipt: resend it over HTTP under the same client_id,
|
|
230
|
+
// or, with no HTTP route, say plainly that it did not go. Never silent.
|
|
231
|
+
const rescueSend = useCallback((clientId: string) => {
|
|
232
|
+
const entry = unconfirmedRef.current.get(clientId);
|
|
233
|
+
if (!entry) return; // the receipt got here first
|
|
234
|
+
if (entry.timer != null) window.clearTimeout(entry.timer);
|
|
235
|
+
unconfirmedRef.current.delete(clientId);
|
|
236
|
+
const resend = resendRef.current;
|
|
237
|
+
if (!resend) {
|
|
238
|
+
setAwaitingReply(false);
|
|
239
|
+
setState((prev) => markSendFailed(prev, clientId, NOT_SENT));
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
resend(entry.text, clientId).then(
|
|
243
|
+
() => setState((prev) => markSendDelivered(prev, clientId)),
|
|
244
|
+
() => {
|
|
245
|
+
setAwaitingReply(false);
|
|
246
|
+
setState((prev) => markSendFailed(prev, clientId, NOT_SENT));
|
|
247
|
+
},
|
|
248
|
+
);
|
|
205
249
|
}, []);
|
|
206
250
|
|
|
207
251
|
const applyEvent = useCallback((frame: WsEvent) => {
|
|
@@ -216,6 +260,12 @@ export function useSessionSocket({
|
|
|
216
260
|
) {
|
|
217
261
|
setAwaitingReply(false);
|
|
218
262
|
}
|
|
263
|
+
// The server's receipt for one of OUR sends: stop waiting on it.
|
|
264
|
+
if (frame.event === "draft.committed" && frame.data.client_id) {
|
|
265
|
+
const entry = unconfirmedRef.current.get(frame.data.client_id);
|
|
266
|
+
if (entry?.timer != null) window.clearTimeout(entry.timer);
|
|
267
|
+
unconfirmedRef.current.delete(frame.data.client_id);
|
|
268
|
+
}
|
|
219
269
|
// Side-effect events: handle BEFORE setState so React strict-mode's
|
|
220
270
|
// double-invocation of the updater doesn't double-fire the effect.
|
|
221
271
|
if (frame.event === "session.title_updated") {
|
|
@@ -311,6 +361,8 @@ export function useSessionSocket({
|
|
|
311
361
|
|
|
312
362
|
ws.onclose = () => {
|
|
313
363
|
setConnected(false);
|
|
364
|
+
// A send still waiting for its receipt will not get one on this socket.
|
|
365
|
+
for (const clientId of [...unconfirmedRef.current.keys()]) rescueSend(clientId);
|
|
314
366
|
if (heartbeatTimerRef.current != null) {
|
|
315
367
|
window.clearInterval(heartbeatTimerRef.current);
|
|
316
368
|
heartbeatTimerRef.current = null;
|
|
@@ -326,7 +378,7 @@ export function useSessionSocket({
|
|
|
326
378
|
ws.onerror = () => {
|
|
327
379
|
// onclose will fire next; nothing to do here.
|
|
328
380
|
};
|
|
329
|
-
}, [applyEvent, send, sessionId, wsUrl]);
|
|
381
|
+
}, [applyEvent, rescueSend, send, sessionId, wsUrl]);
|
|
330
382
|
|
|
331
383
|
useEffect(() => {
|
|
332
384
|
closedByUserRef.current = false;
|
|
@@ -345,29 +397,32 @@ export function useSessionSocket({
|
|
|
345
397
|
}, [connect]);
|
|
346
398
|
|
|
347
399
|
const sendChat = useCallback(() => {
|
|
348
|
-
//
|
|
349
|
-
// draft
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
//
|
|
400
|
+
// The text goes IN the send, with a client_id, rather than relying on an
|
|
401
|
+
// earlier draft.update having reached the server. That frame and this one
|
|
402
|
+
// could both vanish into a socket that reads OPEN but is dead (a phone
|
|
403
|
+
// resuming from the background), and the composer had already cleared, so
|
|
404
|
+
// the prompt was simply lost (2026-09-23). Now the line shows at once as
|
|
405
|
+
// pending, the server's receipt (draft.committed + client_id) confirms it,
|
|
406
|
+
// and without one it is resent over HTTP under the same id.
|
|
354
407
|
if (draftDebounceRef.current != null) {
|
|
355
408
|
window.clearTimeout(draftDebounceRef.current);
|
|
356
409
|
draftDebounceRef.current = null;
|
|
357
410
|
}
|
|
358
|
-
|
|
359
|
-
send({
|
|
360
|
-
action: "draft.update",
|
|
361
|
-
data: {
|
|
362
|
-
version: stateRef.current.active_draft.version,
|
|
363
|
-
body: pendingDraftBodyRef.current,
|
|
364
|
-
},
|
|
365
|
-
});
|
|
366
|
-
}
|
|
411
|
+
const body = (pendingDraftBodyRef.current ?? stateRef.current.active_draft?.body ?? "").trim();
|
|
367
412
|
pendingDraftBodyRef.current = null;
|
|
413
|
+
if (!body) return;
|
|
414
|
+
const clientId = newClientId();
|
|
368
415
|
setAwaitingReply(true);
|
|
369
|
-
|
|
370
|
-
|
|
416
|
+
setState((prev) => withPendingSend(prev, { clientId, text: body }));
|
|
417
|
+
unconfirmedRef.current.set(clientId, { text: body, timer: null });
|
|
418
|
+
const sent = send({ action: "chat.send", data: { client_id: clientId, text: body } });
|
|
419
|
+
if (!sent) {
|
|
420
|
+
rescueSend(clientId);
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
const entry = unconfirmedRef.current.get(clientId);
|
|
424
|
+
if (entry) entry.timer = window.setTimeout(() => rescueSend(clientId), SEND_RECEIPT_TIMEOUT_MS);
|
|
425
|
+
}, [send, rescueSend]);
|
|
371
426
|
|
|
372
427
|
const stopChat = useCallback(
|
|
373
428
|
(messageId: string | null) => {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { render } from "@testing-library/react"
|
|
3
|
+
import { describe, expect, it } from "vitest"
|
|
4
|
+
|
|
5
|
+
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./dropdown-menu"
|
|
6
|
+
|
|
7
|
+
/** The z-index belongs on the POSITIONER. Base UI's positioner is the placed
|
|
8
|
+
* element and forms its own stacking context, so a z-index on the popup alone
|
|
9
|
+
* let the chat's sticky `z-10` bar paint over the menu and swallow the taps
|
|
10
|
+
* meant for its items (2026-09-23). jsdom cannot compute stacking, so this
|
|
11
|
+
* pins the class that makes it right. */
|
|
12
|
+
describe("DropdownMenuContent", () => {
|
|
13
|
+
it("raises the positioner itself, not only the popup inside it", () => {
|
|
14
|
+
render(
|
|
15
|
+
<DropdownMenu open>
|
|
16
|
+
<DropdownMenuTrigger>open</DropdownMenuTrigger>
|
|
17
|
+
<DropdownMenuContent>
|
|
18
|
+
<DropdownMenuItem>Share to Slack…</DropdownMenuItem>
|
|
19
|
+
</DropdownMenuContent>
|
|
20
|
+
</DropdownMenu>,
|
|
21
|
+
)
|
|
22
|
+
const popup = document.querySelector('[data-slot="dropdown-menu-content"]')!
|
|
23
|
+
expect(popup).toBeTruthy()
|
|
24
|
+
expect(popup.parentElement!.className).toContain("z-50")
|
|
25
|
+
})
|
|
26
|
+
})
|
package/src/ui/dropdown-menu.tsx
CHANGED
|
@@ -21,14 +21,26 @@ function DropdownMenuTrigger({
|
|
|
21
21
|
)
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
// The z-index goes on the POSITIONER, not only the popup. Base UI's positioner
|
|
25
|
+
// is the element that is actually placed, and it forms its own stacking
|
|
26
|
+
// context, so a `z-50` on the popup inside it is ranked only against the
|
|
27
|
+
// positioner's own children. The menu then painted UNDER any positioned
|
|
28
|
+
// z-index on the page (the chat's sticky `z-10` tool-call bar) and those items
|
|
29
|
+
// could not be tapped (2026-09-23, the chat session menu on a phone).
|
|
30
|
+
const POSITIONER_Z = "z-50"
|
|
31
|
+
|
|
24
32
|
function DropdownMenuContent({
|
|
25
33
|
className,
|
|
26
34
|
sideOffset = 4,
|
|
35
|
+
align,
|
|
27
36
|
...props
|
|
28
|
-
}: React.ComponentProps<typeof Menu.Popup> & {
|
|
37
|
+
}: React.ComponentProps<typeof Menu.Popup> & {
|
|
38
|
+
sideOffset?: number
|
|
39
|
+
align?: "start" | "center" | "end"
|
|
40
|
+
}) {
|
|
29
41
|
return (
|
|
30
42
|
<Menu.Portal>
|
|
31
|
-
<Menu.Positioner sideOffset={sideOffset}>
|
|
43
|
+
<Menu.Positioner className={POSITIONER_Z} sideOffset={sideOffset} align={align}>
|
|
32
44
|
<Menu.Popup
|
|
33
45
|
data-slot="dropdown-menu-content"
|
|
34
46
|
className={cn(
|
|
@@ -191,7 +203,7 @@ function DropdownMenuSubContent({
|
|
|
191
203
|
}: React.ComponentProps<typeof Menu.Popup>) {
|
|
192
204
|
return (
|
|
193
205
|
<Menu.Portal>
|
|
194
|
-
<Menu.Positioner>
|
|
206
|
+
<Menu.Positioner className={POSITIONER_Z}>
|
|
195
207
|
<Menu.Popup
|
|
196
208
|
data-slot="dropdown-menu-sub-content"
|
|
197
209
|
className={cn(
|
package/src/ui/tooltip.tsx
CHANGED
|
@@ -37,7 +37,7 @@ function Tooltip({ label, children, delay = 300, side = "top", sideOffset = 6 }:
|
|
|
37
37
|
<TooltipPrimitive.Root>
|
|
38
38
|
<TooltipPrimitive.Trigger delay={delay} render={children} />
|
|
39
39
|
<TooltipPrimitive.Portal>
|
|
40
|
-
<TooltipPrimitive.Positioner side={side} sideOffset={sideOffset}>
|
|
40
|
+
<TooltipPrimitive.Positioner className="z-50" side={side} sideOffset={sideOffset}>
|
|
41
41
|
<TooltipPrimitive.Popup
|
|
42
42
|
className={cn(
|
|
43
43
|
"z-50 max-w-xs rounded-md border border-border bg-popover px-2 py-1 text-xs text-popover-foreground shadow-md",
|