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
|
@@ -62,7 +62,11 @@
|
|
|
62
62
|
"data": {
|
|
63
63
|
"message_id": "u1",
|
|
64
64
|
"turn_index": 3,
|
|
65
|
-
"plaintext": "hi there"
|
|
65
|
+
"plaintext": "hi there",
|
|
66
|
+
"author": {
|
|
67
|
+
"name": "Jon",
|
|
68
|
+
"user_id": 7
|
|
69
|
+
}
|
|
66
70
|
}
|
|
67
71
|
},
|
|
68
72
|
"agui": [
|
|
@@ -70,7 +74,11 @@
|
|
|
70
74
|
"type": "TEXT_MESSAGE_CHUNK",
|
|
71
75
|
"metadata": {
|
|
72
76
|
"canopy": {
|
|
73
|
-
"turn_index": 3
|
|
77
|
+
"turn_index": 3,
|
|
78
|
+
"author": {
|
|
79
|
+
"name": "Jon",
|
|
80
|
+
"user_id": 7
|
|
81
|
+
}
|
|
74
82
|
}
|
|
75
83
|
},
|
|
76
84
|
"messageId": "u1",
|
|
@@ -198,6 +206,33 @@
|
|
|
198
206
|
}
|
|
199
207
|
]
|
|
200
208
|
},
|
|
209
|
+
{
|
|
210
|
+
"canopy": {
|
|
211
|
+
"event": "draft.typing",
|
|
212
|
+
"data": {
|
|
213
|
+
"author": {
|
|
214
|
+
"id": 7,
|
|
215
|
+
"name": "Beth"
|
|
216
|
+
},
|
|
217
|
+
"body": "wip",
|
|
218
|
+
"at": "2026-09-26T12:00:00+00:00"
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
"agui": [
|
|
222
|
+
{
|
|
223
|
+
"type": "CUSTOM",
|
|
224
|
+
"name": "canopy.draft.typing",
|
|
225
|
+
"value": {
|
|
226
|
+
"author": {
|
|
227
|
+
"id": 7,
|
|
228
|
+
"name": "Beth"
|
|
229
|
+
},
|
|
230
|
+
"body": "wip",
|
|
231
|
+
"at": "2026-09-26T12:00:00+00:00"
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
]
|
|
235
|
+
},
|
|
201
236
|
{
|
|
202
237
|
"canopy": {
|
|
203
238
|
"event": "presence.joined",
|
|
@@ -441,6 +476,47 @@
|
|
|
441
476
|
}
|
|
442
477
|
]
|
|
443
478
|
},
|
|
479
|
+
{
|
|
480
|
+
"canopy": {
|
|
481
|
+
"event": "session.queued",
|
|
482
|
+
"data": {
|
|
483
|
+
"queued": [
|
|
484
|
+
{
|
|
485
|
+
"turn_id": "8f2e",
|
|
486
|
+
"client_id": "c9",
|
|
487
|
+
"author": {
|
|
488
|
+
"name": "Jon",
|
|
489
|
+
"user_id": 7
|
|
490
|
+
},
|
|
491
|
+
"text": "from jj",
|
|
492
|
+
"sent_at": "2026-09-26T12:00:00+00:00",
|
|
493
|
+
"state": "queued"
|
|
494
|
+
}
|
|
495
|
+
]
|
|
496
|
+
}
|
|
497
|
+
},
|
|
498
|
+
"agui": [
|
|
499
|
+
{
|
|
500
|
+
"type": "CUSTOM",
|
|
501
|
+
"name": "canopy.session.queued",
|
|
502
|
+
"value": {
|
|
503
|
+
"queued": [
|
|
504
|
+
{
|
|
505
|
+
"turn_id": "8f2e",
|
|
506
|
+
"client_id": "c9",
|
|
507
|
+
"author": {
|
|
508
|
+
"name": "Jon",
|
|
509
|
+
"user_id": 7
|
|
510
|
+
},
|
|
511
|
+
"text": "from jj",
|
|
512
|
+
"sent_at": "2026-09-26T12:00:00+00:00",
|
|
513
|
+
"state": "queued"
|
|
514
|
+
}
|
|
515
|
+
]
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
]
|
|
519
|
+
},
|
|
444
520
|
{
|
|
445
521
|
"canopy": {
|
|
446
522
|
"event": "session.stop",
|
|
@@ -614,27 +690,6 @@
|
|
|
614
690
|
}
|
|
615
691
|
]
|
|
616
692
|
},
|
|
617
|
-
{
|
|
618
|
-
"canopy": {
|
|
619
|
-
"event": "draft.lock_changed",
|
|
620
|
-
"data": {
|
|
621
|
-
"draft_id": "d1",
|
|
622
|
-
"holder_user_id": 7,
|
|
623
|
-
"expires_at": "2026-09-18T12:01:00+00:00"
|
|
624
|
-
}
|
|
625
|
-
},
|
|
626
|
-
"agui": [
|
|
627
|
-
{
|
|
628
|
-
"type": "CUSTOM",
|
|
629
|
-
"name": "canopy.draft.lock_changed",
|
|
630
|
-
"value": {
|
|
631
|
-
"draft_id": "d1",
|
|
632
|
-
"holder_user_id": 7,
|
|
633
|
-
"expires_at": "2026-09-18T12:01:00+00:00"
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
]
|
|
637
|
-
},
|
|
638
693
|
{
|
|
639
694
|
"canopy": {
|
|
640
695
|
"event": "page.invalidate",
|
package/src/chat/agui.ts
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
* standard.
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
|
-
import type { SessionMenu, WsEvent } from "./protocol";
|
|
30
|
+
import type { MessageAuthor, SessionMenu, WsEvent } from "./protocol";
|
|
31
31
|
|
|
32
32
|
/** Where canopy's own fields ride. Mirrors `agui.METADATA_KEY`. */
|
|
33
33
|
export const METADATA_KEY = "canopy";
|
|
@@ -117,6 +117,11 @@ export function fromAgui(frame: AguiFrame): WsEvent[] {
|
|
|
117
117
|
message_id: str(frame.messageId),
|
|
118
118
|
turn_index: num(m.turn_index),
|
|
119
119
|
plaintext: str(frame.delta),
|
|
120
|
+
// Who typed it — a transcript-sourced session's durable row carries
|
|
121
|
+
// no user beyond the runner's own login, so this metadata is
|
|
122
|
+
// canopy's only chance to say (spec 2026-09-26). Absent when the
|
|
123
|
+
// server sent none (an agent/scheduled/email turn's own send).
|
|
124
|
+
author: m.author as MessageAuthor | undefined,
|
|
120
125
|
},
|
|
121
126
|
},
|
|
122
127
|
];
|
package/src/chat/drafts.test.ts
CHANGED
|
@@ -1,13 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
2
|
|
|
3
|
-
import type { Draft } from "./protocol"
|
|
4
3
|
import {
|
|
5
4
|
DRAFT_STORAGE_TTL_MS,
|
|
6
|
-
IDLE_THRESHOLD_MS,
|
|
7
5
|
clearStoredDraft,
|
|
8
6
|
draftStorageKey,
|
|
9
|
-
isDraftIdle,
|
|
10
|
-
msUntilDraftIdle,
|
|
11
7
|
readStoredDraft,
|
|
12
8
|
shouldSyncDraftLive,
|
|
13
9
|
writeStoredDraft,
|
|
@@ -15,66 +11,6 @@ import {
|
|
|
15
11
|
|
|
16
12
|
const NOW = 1_700_000_000_000
|
|
17
13
|
|
|
18
|
-
function draftEditedAt(msAgo: number): Draft {
|
|
19
|
-
return {
|
|
20
|
-
id: "d1",
|
|
21
|
-
slot: "next",
|
|
22
|
-
status: "open",
|
|
23
|
-
body: "",
|
|
24
|
-
version: 0,
|
|
25
|
-
last_editor: 1,
|
|
26
|
-
last_edit_at: new Date(NOW - msAgo).toISOString(),
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
afterEach(() => {
|
|
31
|
-
vi.useRealTimers()
|
|
32
|
-
})
|
|
33
|
-
|
|
34
|
-
describe("isDraftIdle", () => {
|
|
35
|
-
it("treats a null/undefined draft as idle", () => {
|
|
36
|
-
expect(isDraftIdle(null)).toBe(true)
|
|
37
|
-
expect(isDraftIdle(undefined)).toBe(true)
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
it("treats a draft with no last_edit_at as idle", () => {
|
|
41
|
-
const d = { ...draftEditedAt(0), last_edit_at: "" }
|
|
42
|
-
expect(isDraftIdle(d)).toBe(true)
|
|
43
|
-
})
|
|
44
|
-
|
|
45
|
-
it("is NOT idle immediately after an edit", () => {
|
|
46
|
-
vi.useFakeTimers()
|
|
47
|
-
vi.setSystemTime(NOW)
|
|
48
|
-
// edited 500ms ago — well within the 2s threshold
|
|
49
|
-
expect(isDraftIdle(draftEditedAt(500))).toBe(false)
|
|
50
|
-
})
|
|
51
|
-
|
|
52
|
-
it("IS idle once more than the threshold has elapsed", () => {
|
|
53
|
-
vi.useFakeTimers()
|
|
54
|
-
vi.setSystemTime(NOW)
|
|
55
|
-
expect(isDraftIdle(draftEditedAt(IDLE_THRESHOLD_MS + 1))).toBe(true)
|
|
56
|
-
})
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
describe("msUntilDraftIdle", () => {
|
|
60
|
-
it("returns 0 for a null draft", () => {
|
|
61
|
-
expect(msUntilDraftIdle(null)).toBe(0)
|
|
62
|
-
})
|
|
63
|
-
|
|
64
|
-
it("returns the remaining time before the idle transition", () => {
|
|
65
|
-
vi.useFakeTimers()
|
|
66
|
-
vi.setSystemTime(NOW)
|
|
67
|
-
// edited 500ms ago → 1500ms remain
|
|
68
|
-
expect(msUntilDraftIdle(draftEditedAt(500))).toBe(1500)
|
|
69
|
-
})
|
|
70
|
-
|
|
71
|
-
it("clamps to 0 once past the threshold", () => {
|
|
72
|
-
vi.useFakeTimers()
|
|
73
|
-
vi.setSystemTime(NOW)
|
|
74
|
-
expect(msUntilDraftIdle(draftEditedAt(IDLE_THRESHOLD_MS + 5000))).toBe(0)
|
|
75
|
-
})
|
|
76
|
-
})
|
|
77
|
-
|
|
78
14
|
describe("shouldSyncDraftLive", () => {
|
|
79
15
|
it("does not mirror keystrokes when you are alone", () => {
|
|
80
16
|
// The single-player case: a per-keystroke draft.update costs a round trip,
|
package/src/chat/drafts.ts
CHANGED
|
@@ -1,12 +1,3 @@
|
|
|
1
|
-
import type { Draft } from "./protocol";
|
|
2
|
-
|
|
3
|
-
export const IDLE_THRESHOLD_MS = 2_000;
|
|
4
|
-
|
|
5
|
-
export function isDraftIdle(draft: Draft | null | undefined): boolean {
|
|
6
|
-
if (!draft?.last_edit_at) return true;
|
|
7
|
-
return Date.now() - new Date(draft.last_edit_at).getTime() > IDLE_THRESHOLD_MS;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
1
|
/**
|
|
11
2
|
* Whether keystrokes need to be mirrored to the server AS YOU TYPE.
|
|
12
3
|
*
|
|
@@ -25,17 +16,6 @@ export function shouldSyncDraftLive(presenceUserIds: readonly number[]): boolean
|
|
|
25
16
|
return presenceUserIds.length > 1;
|
|
26
17
|
}
|
|
27
18
|
|
|
28
|
-
/**
|
|
29
|
-
* Milliseconds until the draft lock becomes idle. Returns 0 if already
|
|
30
|
-
* idle. Use this to schedule a timer that forces a re-render at the
|
|
31
|
-
* idle transition point.
|
|
32
|
-
*/
|
|
33
|
-
export function msUntilDraftIdle(draft: Draft | null | undefined): number {
|
|
34
|
-
if (!draft?.last_edit_at) return 0;
|
|
35
|
-
const elapsed = Date.now() - new Date(draft.last_edit_at).getTime();
|
|
36
|
-
return Math.max(0, IDLE_THRESHOLD_MS - elapsed);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
19
|
// ---------------------------------------------------------------------------
|
|
40
20
|
// Composer persistence — surviving a navigation the server never hears about.
|
|
41
21
|
//
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Message, MessageAuthor } from "./protocol";
|
|
2
|
+
|
|
3
|
+
/** Is this line the viewer's own? A viewer is a canopy user (`currentUserId`)
|
|
4
|
+
* OR a contact — a widget visitor, who has no user id and is named by the
|
|
5
|
+
* snapshot's `current_contact_id` instead. Comparing user ids alone made every
|
|
6
|
+
* line a contact wrote render as someone else's, under their own name. */
|
|
7
|
+
export function isMine(
|
|
8
|
+
author: MessageAuthor | null | undefined,
|
|
9
|
+
currentUserId: number | null | undefined,
|
|
10
|
+
currentContactId: number | null | undefined,
|
|
11
|
+
): boolean {
|
|
12
|
+
if (!author) return false;
|
|
13
|
+
return (
|
|
14
|
+
(author.user_id != null && author.user_id === currentUserId) ||
|
|
15
|
+
(author.contact_id != null && author.contact_id === currentContactId)
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The same person, or no way to tell (either side unauthored). */
|
|
20
|
+
export function sameAuthor(
|
|
21
|
+
a: MessageAuthor | null | undefined,
|
|
22
|
+
b: MessageAuthor | null | undefined,
|
|
23
|
+
): boolean {
|
|
24
|
+
if (!a || !b) return true;
|
|
25
|
+
if (a.user_id != null || b.user_id != null) return a.user_id === b.user_id;
|
|
26
|
+
return a.contact_id === b.contact_id;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A user row still waiting for the durable line it will become: an optimistic
|
|
30
|
+
* send (`local:`), a receipted transcript-sourced send (`transient:` — the
|
|
31
|
+
* server wrote no row, the transcript will), or one still pending/failed. */
|
|
32
|
+
export function isUnconfirmed(m: Message): boolean {
|
|
33
|
+
return (
|
|
34
|
+
m.status === "pending" || m.status === "error" ||
|
|
35
|
+
m.id.startsWith("local:") || m.id.startsWith("transient:")
|
|
36
|
+
);
|
|
37
|
+
}
|
package/src/chat/index.ts
CHANGED
|
@@ -7,9 +7,12 @@
|
|
|
7
7
|
// Protocol types
|
|
8
8
|
export type {
|
|
9
9
|
Message,
|
|
10
|
+
MessageAuthor,
|
|
10
11
|
MessageRole,
|
|
11
12
|
MessageStatus,
|
|
12
13
|
Draft,
|
|
14
|
+
PeerDraft,
|
|
15
|
+
QueuedMessage,
|
|
13
16
|
Participant,
|
|
14
17
|
SessionMenu,
|
|
15
18
|
SessionState,
|
|
@@ -29,6 +32,7 @@ export { prependHistory } from "./history";
|
|
|
29
32
|
// Hooks
|
|
30
33
|
export {
|
|
31
34
|
useSessionSocket,
|
|
35
|
+
newClientId,
|
|
32
36
|
type UseSessionSocketOptions,
|
|
33
37
|
type UseSessionSocketResult,
|
|
34
38
|
} from "./useSessionSocket";
|
|
@@ -45,9 +49,6 @@ export {
|
|
|
45
49
|
type TurnNotice,
|
|
46
50
|
} from "./turnStatus";
|
|
47
51
|
|
|
48
|
-
// Draft idle helpers
|
|
49
|
-
export { IDLE_THRESHOLD_MS, isDraftIdle, msUntilDraftIdle } from "./drafts";
|
|
50
|
-
|
|
51
52
|
// Composer persistence (survives unmount / tab close)
|
|
52
53
|
export {
|
|
53
54
|
DRAFT_STORAGE_TTL_MS,
|
|
@@ -77,6 +78,9 @@ export { MessageItem, type RenderMarkdown } from "./MessageItem";
|
|
|
77
78
|
export { ToolCallPair } from "./ToolCallPair";
|
|
78
79
|
export { SendBox, type PendingAttachment } from "./SendBox";
|
|
79
80
|
export { PresenceChips } from "./PresenceChips";
|
|
81
|
+
export { TypingRows } from "./TypingRows";
|
|
82
|
+
export { QueuedRows } from "./QueuedRows";
|
|
83
|
+
export { isMine } from "./identity";
|
|
80
84
|
export { ConnectionStatus } from "./ConnectionStatus";
|
|
81
85
|
export {
|
|
82
86
|
PlacementBanner,
|
package/src/chat/protocol.ts
CHANGED
|
@@ -26,6 +26,14 @@ export type MessageRole =
|
|
|
26
26
|
// Session + message shapes (the `session.state` snapshot payload)
|
|
27
27
|
// ---------------------------------------------------------------------------
|
|
28
28
|
|
|
29
|
+
/** Who sent a human line — a canopy user or a contact, never both.
|
|
30
|
+
* Mirrors `apps/canopy_sessions/authorship.py::parse`'s return shape. */
|
|
31
|
+
export interface MessageAuthor {
|
|
32
|
+
name: string;
|
|
33
|
+
user_id?: number;
|
|
34
|
+
contact_id?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
29
37
|
export interface Message {
|
|
30
38
|
/** String PK (canopy sends `str(msg.pk)`), or a synthetic stream id. */
|
|
31
39
|
id: string;
|
|
@@ -38,6 +46,9 @@ export interface Message {
|
|
|
38
46
|
started_at: string | null;
|
|
39
47
|
completed_at: string | null;
|
|
40
48
|
created_at: string;
|
|
49
|
+
/** Who typed this line — a canopy user or a contact. Absent/null on rows
|
|
50
|
+
* from before authorship was tracked, or on the agent's own messages. */
|
|
51
|
+
author?: MessageAuthor | null;
|
|
41
52
|
}
|
|
42
53
|
|
|
43
54
|
export interface Draft {
|
|
@@ -49,6 +60,34 @@ export interface Draft {
|
|
|
49
60
|
version: number;
|
|
50
61
|
last_editor: number;
|
|
51
62
|
last_edit_at: string;
|
|
63
|
+
/** The draft's OWNER — distinct from `last_editor`, which is whoever most
|
|
64
|
+
* recently typed in it. Since a `draft.updated` frame now only ever
|
|
65
|
+
* reaches its author's own sockets, this is the reliable field to compare
|
|
66
|
+
* against `current_user_id` for echo-suppression; `last_editor` is kept
|
|
67
|
+
* for servers that don't send it yet. */
|
|
68
|
+
author_id?: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A peer's live, uncommitted draft — everyone else's box, broadcast as they
|
|
72
|
+
* type. Keyed by author, never by draft id: a client only ever needs "what
|
|
73
|
+
* is this person typing right now", and an empty `body` means they sent,
|
|
74
|
+
* discarded, or cleared it. */
|
|
75
|
+
export interface PeerDraft {
|
|
76
|
+
author: { id: number; name: string };
|
|
77
|
+
body: string;
|
|
78
|
+
at: string | null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** A send that landed on the server but hasn't reached the agent's turn yet
|
|
82
|
+
* — the server-derived queue, replacing whatever a client used to infer
|
|
83
|
+
* locally from its own optimistic sends. */
|
|
84
|
+
export interface QueuedMessage {
|
|
85
|
+
turn_id: string;
|
|
86
|
+
client_id: string;
|
|
87
|
+
author: MessageAuthor | null;
|
|
88
|
+
text: string;
|
|
89
|
+
sent_at: string;
|
|
90
|
+
state: "queued" | "delivering";
|
|
52
91
|
}
|
|
53
92
|
|
|
54
93
|
export interface Participant {
|
|
@@ -197,10 +236,23 @@ export interface SessionState {
|
|
|
197
236
|
* changed. Null/undefined when nothing has been asked on this session yet —
|
|
198
237
|
* which is distinct from "finished", and must stay distinct. */
|
|
199
238
|
turn_status?: TurnStatus | null;
|
|
239
|
+
/** The CALLER's own draft — null for a viewer, who has nothing to draft. */
|
|
200
240
|
active_draft: Draft | null;
|
|
241
|
+
/** Everyone else's live drafts, server-derived. Absent on an older server. */
|
|
242
|
+
peer_drafts?: PeerDraft[];
|
|
243
|
+
/** Sends that landed but haven't reached the agent's turn yet, server-
|
|
244
|
+
* derived and replaced wholesale on every frame — like `turn_status`, a
|
|
245
|
+
* client that just connected has no correct prior to merge a delta into.
|
|
246
|
+
* Absent on an older server. */
|
|
247
|
+
queued?: QueuedMessage[];
|
|
201
248
|
participants: Participant[];
|
|
202
249
|
presence_user_ids: number[];
|
|
203
|
-
|
|
250
|
+
/** The connecting user — null for a contact (a widget visitor), who has no
|
|
251
|
+
* user id. */
|
|
252
|
+
current_user_id: number | null;
|
|
253
|
+
/** The connecting CONTACT, when the viewer is one; null for a member.
|
|
254
|
+
* Absent on an older server. */
|
|
255
|
+
current_contact_id?: number | null;
|
|
204
256
|
}
|
|
205
257
|
|
|
206
258
|
// ---------------------------------------------------------------------------
|
|
@@ -211,6 +263,8 @@ export type WsAction =
|
|
|
211
263
|
| { action: "chat.send"; data: Record<string, never> }
|
|
212
264
|
| { action: "chat.stop"; data: { message_id: string } }
|
|
213
265
|
| { action: "draft.update"; data: { version: number; body: string } }
|
|
266
|
+
/** @deprecated accepted and ignored by the server since 0.13 — every editor
|
|
267
|
+
* gets their own draft now, so there is no lock left to take over. */
|
|
214
268
|
| { action: "draft.take_over"; data: Record<string, never> }
|
|
215
269
|
| { action: "draft.discard"; data: Record<string, never> }
|
|
216
270
|
| { action: "presence.heartbeat"; data: Record<string, never> };
|
|
@@ -238,8 +292,17 @@ export type WsEvent =
|
|
|
238
292
|
// offline box, unroutable) are ones activity has no way to spell.
|
|
239
293
|
| { event: "session.turn_status"; data: { status: TurnStatus | null } }
|
|
240
294
|
// A human typed into emdash rather than into this page. No client echoed it,
|
|
241
|
-
// so this is the only way it reaches the browser before a reload.
|
|
242
|
-
|
|
295
|
+
// so this is the only way it reaches the browser before a reload. `author`
|
|
296
|
+
// rides here for the same reason it rides the queued list (spec 2026-09-26):
|
|
297
|
+
// a transcript-sourced session's durable row carries no user beyond the
|
|
298
|
+
// runner's own login, so this is canopy's only chance to say who typed it.
|
|
299
|
+
// `client_id` is likewise the only way an HTTP send's OPTIMISTIC row (built
|
|
300
|
+
// locally by `noteLocalSend`, never a real server frame) can carry the same
|
|
301
|
+
// id its REST body sent — without it `QueuedRows.hideClientIds` cannot match
|
|
302
|
+
// this row to the server's queued-turn projection, and a contact's or
|
|
303
|
+
// widget's own send rendered twice: once as their bubble, once as a "queued"
|
|
304
|
+
// placeholder for the same send.
|
|
305
|
+
| { event: "chat.user_message"; data: { message_id: string; turn_index: number; plaintext: string; author?: MessageAuthor | null; client_id?: string } }
|
|
243
306
|
| { event: "chat.delta"; data: { message_id: string; text: string } }
|
|
244
307
|
// `turn_index` is the row's transcript ordinal — the same key the persisted
|
|
245
308
|
// Message carries, so a live tool row sorts into exactly the position it will
|
|
@@ -250,7 +313,13 @@ export type WsEvent =
|
|
|
250
313
|
| { event: "chat.stream_error"; data: { message_id: string; detail: string } }
|
|
251
314
|
| { event: "chat.stream_cancelled"; data: { message_id: string | null; partial_len: number } }
|
|
252
315
|
| { event: "draft.updated"; data: Draft }
|
|
253
|
-
|
|
316
|
+
// Someone else's box, live — keyed by author, not draft id, because a peer's
|
|
317
|
+
// draft is never addressable by this client and never needs to be: only
|
|
318
|
+
// "what are they typing right now" does. An empty `body` means they sent,
|
|
319
|
+
// discarded, or cleared it. Replaces `draft.lock_changed`, which existed
|
|
320
|
+
// only because a session had one shared next-draft to fight over; now
|
|
321
|
+
// everyone has their own, so there is no lock left to report.
|
|
322
|
+
| { event: "draft.typing"; data: PeerDraft }
|
|
254
323
|
// `client_id` is the sender's receipt for a send that carried one (see
|
|
255
324
|
// useSessionSocket.sendChat); absent from older servers.
|
|
256
325
|
| { event: "draft.committed"; data: { draft_id: string; user_message_id: string; client_id?: string } }
|
|
@@ -270,4 +339,7 @@ export type WsEvent =
|
|
|
270
339
|
display_name?: string;
|
|
271
340
|
};
|
|
272
341
|
}
|
|
273
|
-
| { event: "presence.left"; data: { user_id: number } }
|
|
342
|
+
| { event: "presence.left"; data: { user_id: number } }
|
|
343
|
+
// The whole queue, wholesale, like `session.turn_status` — a client that
|
|
344
|
+
// just connected has no correct prior to merge a delta into.
|
|
345
|
+
| { event: "session.queued"; data: { queued: QueuedMessage[] } };
|
|
@@ -727,3 +727,124 @@ describe("session.stop — whether the stop actually landed", () => {
|
|
|
727
727
|
expect(out.stopState).toBeUndefined();
|
|
728
728
|
});
|
|
729
729
|
});
|
|
730
|
+
|
|
731
|
+
describe("per-person drafts", () => {
|
|
732
|
+
it("draft.typing upserts a peer by author and an empty body removes it", () => {
|
|
733
|
+
let s = sessionReducer(makeState(), {
|
|
734
|
+
event: "draft.typing",
|
|
735
|
+
data: { author: { id: 2, name: "Bo" }, body: "hi", at: null },
|
|
736
|
+
} as WsEvent);
|
|
737
|
+
s = sessionReducer(s, {
|
|
738
|
+
event: "draft.typing",
|
|
739
|
+
data: { author: { id: 2, name: "Bo" }, body: "hi there", at: null },
|
|
740
|
+
} as WsEvent);
|
|
741
|
+
expect(s.peer_drafts).toEqual([{ author: { id: 2, name: "Bo" }, body: "hi there", at: null }]);
|
|
742
|
+
s = sessionReducer(s, {
|
|
743
|
+
event: "draft.typing",
|
|
744
|
+
data: { author: { id: 2, name: "Bo" }, body: "", at: null },
|
|
745
|
+
} as WsEvent);
|
|
746
|
+
expect(s.peer_drafts).toEqual([]);
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
it("presence.left drops that person's typing row", () => {
|
|
750
|
+
let s = sessionReducer(makeState(), {
|
|
751
|
+
event: "draft.typing",
|
|
752
|
+
data: { author: { id: 2, name: "Bo" }, body: "x", at: null },
|
|
753
|
+
} as WsEvent);
|
|
754
|
+
s = sessionReducer(s, { event: "presence.left", data: { user_id: 2 } } as WsEvent);
|
|
755
|
+
expect(s.peer_drafts).toEqual([]);
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
it("session.queued replaces the whole list", () => {
|
|
759
|
+
const q = {
|
|
760
|
+
turn_id: "t1",
|
|
761
|
+
client_id: "c1",
|
|
762
|
+
author: { name: "Bo", user_id: 2 },
|
|
763
|
+
text: "yo",
|
|
764
|
+
sent_at: "2026-09-26T00:00:00Z",
|
|
765
|
+
state: "queued" as const,
|
|
766
|
+
};
|
|
767
|
+
let s = sessionReducer(makeState(), {
|
|
768
|
+
event: "session.queued",
|
|
769
|
+
data: { queued: [q] },
|
|
770
|
+
} as WsEvent);
|
|
771
|
+
expect(s.queued).toEqual([q]);
|
|
772
|
+
s = sessionReducer(s, { event: "session.queued", data: { queued: [] } } as WsEvent);
|
|
773
|
+
expect(s.queued).toEqual([]);
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
it("chat.user_message keeps the author", () => {
|
|
777
|
+
const s = sessionReducer(makeState(), {
|
|
778
|
+
event: "chat.user_message",
|
|
779
|
+
data: { message_id: "m1", turn_index: 5, plaintext: "hi", author: { name: "Bo", user_id: 2 } },
|
|
780
|
+
} as WsEvent);
|
|
781
|
+
expect(s.messages.at(-1)?.author).toEqual({ name: "Bo", user_id: 2 });
|
|
782
|
+
});
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
describe("sessionReducer — two people saying the same thing (final review I5)", () => {
|
|
786
|
+
const yes = (over: Partial<Message>) =>
|
|
787
|
+
makeMessage({ role: "user", plaintext: "yes", status: "complete", ...over })
|
|
788
|
+
|
|
789
|
+
it("two confirmed 'yes' rows from different authors stay two", () => {
|
|
790
|
+
const seeded = makeState({
|
|
791
|
+
messages: [yes({ id: "a1", turn_index: 10, author: { name: "Alice", user_id: 1 } })],
|
|
792
|
+
})
|
|
793
|
+
const next = sessionReducer(seeded, {
|
|
794
|
+
event: "chat.user_message",
|
|
795
|
+
data: { message_id: "b1", turn_index: 20, plaintext: "yes", author: { name: "Bo", user_id: 2 } },
|
|
796
|
+
} as WsEvent)
|
|
797
|
+
expect(next.messages).toHaveLength(2)
|
|
798
|
+
expect(next.messages.map((m) => m.author?.name)).toEqual(["Alice", "Bo"])
|
|
799
|
+
})
|
|
800
|
+
|
|
801
|
+
it("a confirmed unauthored row is not adopted by someone else's line", () => {
|
|
802
|
+
const seeded = makeState({ messages: [yes({ id: "a1", turn_index: 10 })] })
|
|
803
|
+
const next = sessionReducer(seeded, {
|
|
804
|
+
event: "chat.user_message",
|
|
805
|
+
data: { message_id: "b1", turn_index: 20, plaintext: "yes", author: { name: "Bo", user_id: 2 } },
|
|
806
|
+
} as WsEvent)
|
|
807
|
+
expect(next.messages).toHaveLength(2)
|
|
808
|
+
})
|
|
809
|
+
|
|
810
|
+
it("an optimistic pending 'yes' still reconciles with its echo", () => {
|
|
811
|
+
const seeded = makeState({
|
|
812
|
+
current_user_id: 1,
|
|
813
|
+
messages: [yes({ id: "local:c1", turn_index: 3, status: "pending", content: { client_id: "c1" } })],
|
|
814
|
+
})
|
|
815
|
+
const next = sessionReducer(seeded, {
|
|
816
|
+
event: "chat.user_message",
|
|
817
|
+
data: { message_id: "901", turn_index: 144448, plaintext: "yes", author: { name: "Alice", user_id: 1 } },
|
|
818
|
+
} as WsEvent)
|
|
819
|
+
expect(next.messages).toHaveLength(1)
|
|
820
|
+
expect(next.messages[0].id).toBe("901")
|
|
821
|
+
expect(next.messages[0].status).toBe("complete")
|
|
822
|
+
})
|
|
823
|
+
|
|
824
|
+
it("a receipted send (transient id) still reconciles with its transcript echo", () => {
|
|
825
|
+
// A transcript-sourced send writes no durable row; its receipt carries a
|
|
826
|
+
// `transient:` id, so the row is still waiting for the line the agent read.
|
|
827
|
+
const seeded = makeState({
|
|
828
|
+
current_user_id: 1,
|
|
829
|
+
messages: [yes({ id: "transient:abc", turn_index: 3, content: { client_id: "c1" } })],
|
|
830
|
+
})
|
|
831
|
+
const next = sessionReducer(seeded, {
|
|
832
|
+
event: "chat.user_message",
|
|
833
|
+
data: { message_id: "901", turn_index: 144448, plaintext: "yes", author: { name: "Alice", user_id: 1 } },
|
|
834
|
+
} as WsEvent)
|
|
835
|
+
expect(next.messages).toHaveLength(1)
|
|
836
|
+
})
|
|
837
|
+
|
|
838
|
+
it("my pending 'yes' is not taken by a teammate's 'yes'", () => {
|
|
839
|
+
const seeded = makeState({
|
|
840
|
+
current_user_id: 1,
|
|
841
|
+
messages: [yes({ id: "local:c1", turn_index: 3, status: "pending", content: { client_id: "c1" } })],
|
|
842
|
+
})
|
|
843
|
+
const next = sessionReducer(seeded, {
|
|
844
|
+
event: "chat.user_message",
|
|
845
|
+
data: { message_id: "b1", turn_index: 20, plaintext: "yes", author: { name: "Bo", user_id: 2 } },
|
|
846
|
+
} as WsEvent)
|
|
847
|
+
expect(next.messages).toHaveLength(2)
|
|
848
|
+
expect(next.messages[0].id).toBe("local:c1")
|
|
849
|
+
})
|
|
850
|
+
})
|