canopy-ui 0.12.1 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/chat/ChatPanel.multiplayer.test.tsx +202 -0
- package/src/chat/ChatPanel.tsx +71 -41
- package/src/chat/MessageItem.tsx +38 -3
- package/src/chat/MessageList.tsx +9 -0
- package/src/chat/PresenceChips.tsx +5 -35
- package/src/chat/QueuedRows.tsx +42 -0
- package/src/chat/SendBox.test.tsx +111 -37
- package/src/chat/SendBox.tsx +32 -108
- package/src/chat/TypingRows.tsx +18 -0
- package/src/chat/agui.fixture.json +78 -23
- package/src/chat/agui.ts +6 -1
- package/src/chat/drafts.test.ts +1 -65
- package/src/chat/drafts.ts +0 -20
- package/src/chat/identity.ts +37 -0
- package/src/chat/index.ts +7 -3
- package/src/chat/protocol.ts +77 -5
- package/src/chat/sessionReducer.test.ts +121 -0
- package/src/chat/sessionReducer.ts +61 -20
- package/src/chat/useSessionSocket.http.test.tsx +50 -1
- package/src/chat/useSessionSocket.ts +87 -16
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { Draft, Message, SessionState, WsEvent } from "./protocol";
|
|
1
|
+
import type { Draft, Message, MessageAuthor, SessionState, WsEvent } from "./protocol";
|
|
2
|
+
import { isUnconfirmed, sameAuthor } from "./identity";
|
|
2
3
|
|
|
3
4
|
// Pure reducer for SessionState — extracted from useSessionSocket so it
|
|
4
5
|
// can be unit-tested without WebSocket plumbing. Side-effect events
|
|
@@ -39,10 +40,10 @@ const UNBLOCKING_FRAMES = new Set([
|
|
|
39
40
|
*/
|
|
40
41
|
export const REDUCER_EVENTS: ReadonlySet<string> = new Set([
|
|
41
42
|
"session.state", "session.activity", "session.stop", "session.menu",
|
|
42
|
-
"session.turn_status", "session.error", "session.title_updated",
|
|
43
|
+
"session.turn_status", "session.error", "session.title_updated", "session.queued",
|
|
43
44
|
"chat.stream_start", "chat.user_message", "chat.delta", "chat.stream_complete",
|
|
44
45
|
"chat.stream_error", "chat.stream_cancelled", "chat.tool_use", "chat.tool_result",
|
|
45
|
-
"draft.updated", "draft.
|
|
46
|
+
"draft.updated", "draft.typing", "draft.committed", "draft.discarded",
|
|
46
47
|
"presence.joined", "presence.left",
|
|
47
48
|
]);
|
|
48
49
|
|
|
@@ -151,11 +152,31 @@ export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState
|
|
|
151
152
|
// Deliberately narrow: same role, same text, and only against the tail, so
|
|
152
153
|
// a genuine repeat of a short message ("yes") sent much later still lands
|
|
153
154
|
// as its own row.
|
|
155
|
+
//
|
|
156
|
+
// And only against a row that can still be THIS line (final review I5):
|
|
157
|
+
// two people answering "yes" are two lines, so the fallback never lets a
|
|
158
|
+
// different author's frame adopt a row. A row is a candidate while it is
|
|
159
|
+
// unconfirmed (an optimistic or receipted send still waiting for the line
|
|
160
|
+
// the agent read) or when it already names the same person — the ledger
|
|
161
|
+
// path's authored row meeting its transcript echo. An unauthored optimistic
|
|
162
|
+
// row is the viewer's own, so it is compared as the viewer.
|
|
154
163
|
const RECENT_USER_ROWS = 6;
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
164
|
+
const incomingAuthor = frame.data.author ?? null;
|
|
165
|
+
const me: MessageAuthor | null =
|
|
166
|
+
prev.current_user_id != null
|
|
167
|
+
? { name: "", user_id: prev.current_user_id }
|
|
168
|
+
: prev.current_contact_id != null
|
|
169
|
+
? { name: "", contact_id: prev.current_contact_id }
|
|
170
|
+
: null;
|
|
171
|
+
const sameText = (m: Message) => {
|
|
172
|
+
if (
|
|
173
|
+
m.role !== "user" ||
|
|
174
|
+
m.plaintext.trim() === "" ||
|
|
175
|
+
m.plaintext.trim() !== frame.data.plaintext.trim()
|
|
176
|
+
) return false;
|
|
177
|
+
if (isUnconfirmed(m)) return sameAuthor(m.author ?? me, incomingAuthor);
|
|
178
|
+
return m.author != null && incomingAuthor != null && sameAuthor(m.author, incomingAuthor);
|
|
179
|
+
};
|
|
159
180
|
const recentUsers = prev.messages.filter((m) => m.role === "user").slice(-RECENT_USER_ROWS);
|
|
160
181
|
const existing =
|
|
161
182
|
prev.messages.find(
|
|
@@ -174,6 +195,12 @@ export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState
|
|
|
174
195
|
// is the durable one, so the row sorts where a reload puts it.
|
|
175
196
|
turn_index: frame.data.turn_index,
|
|
176
197
|
plaintext: frame.data.plaintext,
|
|
198
|
+
// A transcript-sourced session's durable row carries no user
|
|
199
|
+
// beyond the runner's own login, so this frame is the only
|
|
200
|
+
// chance to say who actually typed it — keep it if we already
|
|
201
|
+
// had it (an optimistic send knows its own author) and the
|
|
202
|
+
// frame is silent, but let the frame win otherwise.
|
|
203
|
+
author: frame.data.author ?? m.author ?? null,
|
|
177
204
|
// The agent read it, so a pending or unconfirmed row is sent.
|
|
178
205
|
...(m.role === "user" ? { status: "complete" as const, error_detail: null } : {}),
|
|
179
206
|
}
|
|
@@ -186,13 +213,19 @@ export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState
|
|
|
186
213
|
id: frame.data.message_id,
|
|
187
214
|
turn_index: frame.data.turn_index,
|
|
188
215
|
role: "user",
|
|
189
|
-
|
|
216
|
+
// `client_id` (when the caller has one — an HTTP send) lets
|
|
217
|
+
// `QueuedRows.hideClientIds` recognise the server's queued-turn
|
|
218
|
+
// projection as THIS row rather than a duplicate.
|
|
219
|
+
content: frame.data.client_id
|
|
220
|
+
? { text: frame.data.plaintext, client_id: frame.data.client_id }
|
|
221
|
+
: { text: frame.data.plaintext },
|
|
190
222
|
plaintext: frame.data.plaintext,
|
|
191
223
|
status: "complete",
|
|
192
224
|
error_detail: null,
|
|
193
225
|
started_at: null,
|
|
194
226
|
completed_at: null,
|
|
195
227
|
created_at: nowIso,
|
|
228
|
+
author: frame.data.author ?? null,
|
|
196
229
|
};
|
|
197
230
|
return { ...prev, messages: [...prev.messages, user] };
|
|
198
231
|
}
|
|
@@ -326,9 +359,13 @@ export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState
|
|
|
326
359
|
// If we're the current editor, keep our local body — the server
|
|
327
360
|
// echo is stale relative to keystrokes that happened since the
|
|
328
361
|
// debounced send. Only accept metadata (version, last_editor, etc).
|
|
362
|
+
// This frame now only ever carries MY OWN draft (everyone else's live
|
|
363
|
+
// typing arrives as `draft.typing` instead), so `author_id` — the
|
|
364
|
+
// draft's actual owner — is the reliable field to compare; fall back to
|
|
365
|
+
// `last_editor` for a server that doesn't send it yet.
|
|
329
366
|
if (
|
|
330
367
|
prev.active_draft &&
|
|
331
|
-
incoming.last_editor === prev.current_user_id
|
|
368
|
+
(incoming.author_id ?? incoming.last_editor) === prev.current_user_id
|
|
332
369
|
) {
|
|
333
370
|
return {
|
|
334
371
|
...prev,
|
|
@@ -343,17 +380,17 @@ export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState
|
|
|
343
380
|
return { ...prev, active_draft: incoming };
|
|
344
381
|
}
|
|
345
382
|
|
|
346
|
-
case "draft.
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
return prev;
|
|
383
|
+
case "draft.typing": {
|
|
384
|
+
// Someone else's box, live. Keyed by author; an empty body means they
|
|
385
|
+
// sent, discarded or cleared it.
|
|
386
|
+
const rest = (prev.peer_drafts ?? []).filter((d) => d.author.id !== frame.data.author.id);
|
|
387
|
+
return { ...prev, peer_drafts: frame.data.body ? [...rest, frame.data] : rest };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
case "session.queued":
|
|
391
|
+
// Wholesale, like session.turn_status: a client that just connected has
|
|
392
|
+
// no correct prior to merge a delta into.
|
|
393
|
+
return { ...prev, queued: frame.data.queued };
|
|
357
394
|
|
|
358
395
|
case "draft.committed": {
|
|
359
396
|
// The sender already shows its line (a pending row stamped with this
|
|
@@ -439,6 +476,10 @@ export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState
|
|
|
439
476
|
presence_user_ids: prev.presence_user_ids.filter(
|
|
440
477
|
(id) => id !== frame.data.user_id,
|
|
441
478
|
),
|
|
479
|
+
// A closed tab is the retirement path for its typing row too — without
|
|
480
|
+
// this a peer who left mid-keystroke leaves a stale "is typing" behind
|
|
481
|
+
// with nothing left to clear it.
|
|
482
|
+
peer_drafts: (prev.peer_drafts ?? []).filter((d) => d.author.id !== frame.data.user_id),
|
|
442
483
|
};
|
|
443
484
|
|
|
444
485
|
case "session.error": {
|
|
@@ -44,7 +44,7 @@ describe('sending over HTTP', () => {
|
|
|
44
44
|
act(() => hook.result.current.updateDraft('hello ace'))
|
|
45
45
|
expect(hook.result.current.state.active_draft?.body).toBe('hello ace')
|
|
46
46
|
await act(async () => hook.result.current.sendChat())
|
|
47
|
-
expect(sendOverHttp).toHaveBeenCalledWith('hello ace')
|
|
47
|
+
expect(sendOverHttp).toHaveBeenCalledWith('hello ace', expect.any(String))
|
|
48
48
|
expect(FakeSocket.sent.filter((f) => f.includes('chat.send') || f.includes('draft.update'))).toEqual([])
|
|
49
49
|
})
|
|
50
50
|
|
|
@@ -57,6 +57,23 @@ describe('sending over HTTP', () => {
|
|
|
57
57
|
expect(hook.result.current.state.active_draft).toBeNull()
|
|
58
58
|
})
|
|
59
59
|
|
|
60
|
+
it('stamps the SAME client_id on the optimistic row and the HTTP body', async () => {
|
|
61
|
+
// The server's queued-turn projection carries back whatever client_id the
|
|
62
|
+
// POST sent (apps/canopy_sessions/services.py::queued_messages parses it
|
|
63
|
+
// off the turn's idempotency_key). Without a matching id on this row's
|
|
64
|
+
// `content`, `QueuedRows.hideClientIds` cannot recognise the two as one
|
|
65
|
+
// send, and a contact's (or the widget's) own send rendered twice — once
|
|
66
|
+
// as their bubble, once as an unowned "queued" placeholder, since neither
|
|
67
|
+
// path gives a contact an `author.user_id` to compare against.
|
|
68
|
+
const { hook, sendOverHttp } = contact()
|
|
69
|
+
act(() => hook.result.current.updateDraft('please help'))
|
|
70
|
+
await act(async () => hook.result.current.sendChat())
|
|
71
|
+
const sentClientId = sendOverHttp.mock.calls[0][1] as string
|
|
72
|
+
expect(typeof sentClientId).toBe('string')
|
|
73
|
+
expect(sentClientId.length).toBeGreaterThan(0)
|
|
74
|
+
expect(hook.result.current.state.messages.at(-1)?.content?.client_id).toBe(sentClientId)
|
|
75
|
+
})
|
|
76
|
+
|
|
60
77
|
it('a failed send puts the words back and says why', async () => {
|
|
61
78
|
const { hook } = contact(vi.fn().mockRejectedValue(new Error('canopy said 429')))
|
|
62
79
|
act(() => hook.result.current.updateDraft('keep me'))
|
|
@@ -69,6 +86,38 @@ describe('sending over HTTP', () => {
|
|
|
69
86
|
expect(hook.result.current.awaitingReply).toBe(false)
|
|
70
87
|
})
|
|
71
88
|
|
|
89
|
+
it('does not clobber newer typing with a stale failed send', async () => {
|
|
90
|
+
// 2026-09-26 review: contact sends "hello" (draft clears to null), starts
|
|
91
|
+
// typing "world" while the POST is still in flight, "hello"'s request
|
|
92
|
+
// fails — the restore must not overwrite "world" with "hello". The old
|
|
93
|
+
// `updateLocalDraft(body)` closed over "hello" at send time and always
|
|
94
|
+
// won; the fix (`restoreFailedLocalDraft`) only restores when nothing
|
|
95
|
+
// newer has been typed since (the draft is still null/empty).
|
|
96
|
+
let rejectSend!: (err: Error) => void
|
|
97
|
+
const pending: Promise<unknown> = new Promise((_resolve, reject) => {
|
|
98
|
+
rejectSend = reject
|
|
99
|
+
})
|
|
100
|
+
const sendOverHttp = vi.fn().mockReturnValue(pending)
|
|
101
|
+
const { hook } = contact(sendOverHttp)
|
|
102
|
+
|
|
103
|
+
act(() => hook.result.current.updateDraft('hello'))
|
|
104
|
+
act(() => {
|
|
105
|
+
hook.result.current.sendChat()
|
|
106
|
+
})
|
|
107
|
+
expect(hook.result.current.state.active_draft).toBeNull()
|
|
108
|
+
|
|
109
|
+
// Typed BEFORE the request resolves — this must win.
|
|
110
|
+
act(() => hook.result.current.updateDraft('world'))
|
|
111
|
+
expect(hook.result.current.state.active_draft?.body).toBe('world')
|
|
112
|
+
|
|
113
|
+
await act(async () => {
|
|
114
|
+
rejectSend(new Error('network blip'))
|
|
115
|
+
await pending.catch(() => undefined)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
expect(hook.result.current.state.active_draft?.body).toBe('world')
|
|
119
|
+
})
|
|
120
|
+
|
|
72
121
|
it('an empty draft sends nothing', async () => {
|
|
73
122
|
const { hook, sendOverHttp } = contact()
|
|
74
123
|
act(() => hook.result.current.updateDraft(' '))
|
|
@@ -23,17 +23,37 @@ const SEND_RECEIPT_TIMEOUT_MS = 4_000;
|
|
|
23
23
|
const NOT_SENT =
|
|
24
24
|
"Not sent: the connection dropped before canopy got it. Your words are kept here, so copy them and send again.";
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
/**
|
|
27
|
+
* A client-generated nonce for a send's `client_id` — exported so a host
|
|
28
|
+
* driving its own HTTP send (the widget's opening message, before a socket
|
|
29
|
+
* exists to send over; `ChatPage`'s `/share` command) can mint the SAME id
|
|
30
|
+
* for both the REST body and the matching `noteLocalSend` call, which is what
|
|
31
|
+
* lets `QueuedRows.hideClientIds` recognise the two as one send rather than
|
|
32
|
+
* rendering it twice.
|
|
33
|
+
*/
|
|
34
|
+
export function newClientId(): string {
|
|
35
|
+
// An idempotency nonce, not a secret — but it rides a request, so it comes
|
|
36
|
+
// from the platform CSPRNG, never Math.random (CodeQL js/insecure-randomness).
|
|
37
|
+
// randomUUID needs a secure context; getRandomValues does not.
|
|
38
|
+
const c = (globalThis as {
|
|
39
|
+
crypto?: { randomUUID?: () => string; getRandomValues?: (a: Uint8Array) => Uint8Array };
|
|
40
|
+
}).crypto;
|
|
41
|
+
if (c?.randomUUID) return c.randomUUID();
|
|
42
|
+
if (c?.getRandomValues) {
|
|
43
|
+
const bytes = c.getRandomValues(new Uint8Array(16));
|
|
44
|
+
return `c${Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("")}`;
|
|
45
|
+
}
|
|
46
|
+
return `c${Date.now().toString(36)}${(clientIdCounter++).toString(36)}`;
|
|
29
47
|
}
|
|
30
48
|
|
|
49
|
+
let clientIdCounter = 0;
|
|
50
|
+
|
|
31
51
|
const INITIAL_STATE: SessionState = {
|
|
32
52
|
messages: [],
|
|
33
53
|
active_draft: null,
|
|
34
54
|
participants: [],
|
|
35
55
|
presence_user_ids: [],
|
|
36
|
-
current_user_id:
|
|
56
|
+
current_user_id: null,
|
|
37
57
|
};
|
|
38
58
|
|
|
39
59
|
export interface UseSessionSocketOptions {
|
|
@@ -84,8 +104,15 @@ export interface UseSessionSocketOptions {
|
|
|
84
104
|
* Everything else is unchanged — the same `state`, the same pending row, the
|
|
85
105
|
* same "waiting for a reply" — so one chat UI serves a user and a contact
|
|
86
106
|
* without a second send path in every host. Omit for a user (the default).
|
|
107
|
+
*
|
|
108
|
+
* Must pass `clientId` through as the send's `client_id` (both `/api/canopy-
|
|
109
|
+
* sessions/{id}/send` and `/api/contact/sessions/{id}/send` accept it) — it
|
|
110
|
+
* is the SAME id `sendChatOverHttp` stamps on the optimistic row, and without
|
|
111
|
+
* it the server's queued-turn projection has no way to recognise that row as
|
|
112
|
+
* the one it already showed, so the sender's own send rendered a second time
|
|
113
|
+
* as an unowned "queued" placeholder.
|
|
87
114
|
*/
|
|
88
|
-
sendOverHttp?: (text: string) => Promise<unknown>;
|
|
115
|
+
sendOverHttp?: (text: string, clientId: string) => Promise<unknown>;
|
|
89
116
|
/**
|
|
90
117
|
* The HTTP route for a USER's send, used only when a socket send gets no
|
|
91
118
|
* receipt (see `SEND_RECEIPT_TIMEOUT_MS`). Must pass `clientId` through as the
|
|
@@ -128,6 +155,10 @@ export interface UseSessionSocketResult {
|
|
|
128
155
|
sendChat: () => void;
|
|
129
156
|
stopChat: (messageId: string | null) => void;
|
|
130
157
|
updateDraft: (body: string) => void;
|
|
158
|
+
/** @deprecated ignored since 0.13 — everyone has their own draft now, so
|
|
159
|
+
* there is no lock left to take over. Kept as a no-op so a host still
|
|
160
|
+
* passing it through (e.g. ace-web's `onTakeOver={socket.takeOverDraft}`)
|
|
161
|
+
* keeps compiling across the upgrade. */
|
|
131
162
|
takeOverDraft: () => void;
|
|
132
163
|
discardDraft: () => void;
|
|
133
164
|
prependMessages: (older: Message[]) => void;
|
|
@@ -144,8 +175,15 @@ export interface UseSessionSocketResult {
|
|
|
144
175
|
* by `sendChat` alone, and the user's own line is not a server row until
|
|
145
176
|
* the agent's transcript ships it back. So you typed, pressed send, and got
|
|
146
177
|
* an empty panel — for as long as the reply took, and forever if its runner
|
|
147
|
-
* was offline.
|
|
148
|
-
|
|
178
|
+
* was offline.
|
|
179
|
+
*
|
|
180
|
+
* Pass the SAME `clientId` the matching REST body sent (`sendChatOverHttp`
|
|
181
|
+
* does this internally; a host driving its own HTTP send — the widget's
|
|
182
|
+
* first message, `ChatPage`'s `/share` command — must generate one and pass
|
|
183
|
+
* it to both), so the optimistic row's `content.client_id` matches the
|
|
184
|
+
* server's queued-turn projection and `QueuedRows.hideClientIds` can tell
|
|
185
|
+
* they're the same send rather than rendering it twice. */
|
|
186
|
+
noteLocalSend: (text: string, clientId?: string) => void;
|
|
149
187
|
lastError: string | null;
|
|
150
188
|
}
|
|
151
189
|
|
|
@@ -477,9 +515,10 @@ export function useSessionSocket({
|
|
|
477
515
|
[send],
|
|
478
516
|
);
|
|
479
517
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
518
|
+
// @deprecated no-op since 0.13 — the server accepts and ignores
|
|
519
|
+
// `draft.take_over` too (see WsAction), so this is kept only so a host
|
|
520
|
+
// wired to the old prop doesn't need an upgrade-day edit.
|
|
521
|
+
const takeOverDraft = useCallback(() => undefined, []);
|
|
483
522
|
|
|
484
523
|
const discardDraft = useCallback(() => {
|
|
485
524
|
send({ action: "draft.discard", data: {} });
|
|
@@ -521,7 +560,29 @@ export function useSessionSocket({
|
|
|
521
560
|
});
|
|
522
561
|
}, []);
|
|
523
562
|
|
|
524
|
-
|
|
563
|
+
// A failed send's restore, NOT the same as `updateLocalDraft`: this is a
|
|
564
|
+
// stale write racing whatever the person typed while the request was in
|
|
565
|
+
// flight, so it must never overwrite it. A functional update reads the
|
|
566
|
+
// CURRENT draft rather than one closed over at send time — sendChatOverHttp
|
|
567
|
+
// cleared the draft to null before the request even went out, so "the local
|
|
568
|
+
// draft is still null or empty" means nothing newer has been typed since,
|
|
569
|
+
// and only then is it safe to put the failed body back. If the person has
|
|
570
|
+
// already started something else, that stays exactly as they left it; the
|
|
571
|
+
// failed send is simply lost rather than clobbering live typing (2026-09-26
|
|
572
|
+
// review: "contact sends hello, starts typing world, hello's failure
|
|
573
|
+
// overwrote world").
|
|
574
|
+
const restoreFailedLocalDraft = useCallback((body: string) => {
|
|
575
|
+
setLocalDraft((prev) =>
|
|
576
|
+
prev == null || prev.body === ""
|
|
577
|
+
? {
|
|
578
|
+
id: "local", slot: "next", status: "open", body, version: 0,
|
|
579
|
+
last_editor: 0, last_edit_at: new Date().toISOString(),
|
|
580
|
+
}
|
|
581
|
+
: prev,
|
|
582
|
+
);
|
|
583
|
+
}, []);
|
|
584
|
+
|
|
585
|
+
const noteLocalSend = useCallback((text: string, clientId?: string) => {
|
|
525
586
|
setAwaitingReply(true);
|
|
526
587
|
const body = text.trim();
|
|
527
588
|
if (!body) return;
|
|
@@ -539,6 +600,7 @@ export function useSessionSocket({
|
|
|
539
600
|
message_id: `local:${Date.now()}`,
|
|
540
601
|
turn_index: prev.messages.reduce((acc, m) => Math.max(acc, m.turn_index), 0) + 1,
|
|
541
602
|
plaintext: body,
|
|
603
|
+
client_id: clientId,
|
|
542
604
|
},
|
|
543
605
|
}),
|
|
544
606
|
);
|
|
@@ -547,15 +609,24 @@ export function useSessionSocket({
|
|
|
547
609
|
const sendChatOverHttp = useCallback(() => {
|
|
548
610
|
const body = (localDraft?.body ?? "").trim();
|
|
549
611
|
if (!body || !sendOverHttp) return;
|
|
550
|
-
|
|
612
|
+
// ONE id for both halves of this send — the optimistic row and the REST
|
|
613
|
+
// body — so the server's queued-turn projection and this row compare
|
|
614
|
+
// equal (`QueuedRows.hideClientIds`) instead of rendering the same send
|
|
615
|
+
// twice, once as the sender's own bubble and once as an unowned "queued"
|
|
616
|
+
// placeholder (a contact's `author.user_id` is never set, so without a
|
|
617
|
+
// shared id that placeholder didn't even read as "mine").
|
|
618
|
+
const clientId = newClientId();
|
|
619
|
+
noteLocalSend(body, clientId);
|
|
551
620
|
setLocalDraft(null);
|
|
552
|
-
sendOverHttp(body).catch((err: unknown) => {
|
|
621
|
+
sendOverHttp(body, clientId).catch((err: unknown) => {
|
|
553
622
|
setAwaitingReply(false);
|
|
554
623
|
setLastError(err instanceof Error ? err.message : "the message could not be sent");
|
|
555
|
-
// Put the words back, so a failed send never costs what was typed
|
|
556
|
-
|
|
624
|
+
// Put the words back, so a failed send never costs what was typed —
|
|
625
|
+
// but only if nothing NEWER has been typed since (see
|
|
626
|
+
// `restoreFailedLocalDraft`).
|
|
627
|
+
restoreFailedLocalDraft(body);
|
|
557
628
|
});
|
|
558
|
-
}, [localDraft, sendOverHttp, noteLocalSend,
|
|
629
|
+
}, [localDraft, sendOverHttp, noteLocalSend, restoreFailedLocalDraft]);
|
|
559
630
|
|
|
560
631
|
const exposedState = useMemo(
|
|
561
632
|
() => (sendOverHttp ? { ...state, active_draft: localDraft } : state),
|