canopy-ui 0.12.2 → 0.14.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 +209 -0
- package/src/chat/ChatPanel.tsx +77 -41
- package/src/chat/MessageItem.tsx +32 -2
- 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 +138 -37
- package/src/chat/SendBox.tsx +70 -109
- package/src/chat/TypingRows.test.tsx +33 -0
- package/src/chat/TypingRows.tsx +25 -0
- package/src/chat/agui.fixture.json +78 -23
- package/src/chat/agui.ts +6 -1
- package/src/chat/drafts.test.ts +43 -65
- package/src/chat/drafts.ts +41 -19
- package/src/chat/identity.ts +37 -0
- package/src/chat/index.ts +10 -3
- package/src/chat/protocol.ts +97 -5
- package/src/chat/sessionReducer.test.ts +168 -0
- package/src/chat/sessionReducer.ts +71 -20
- package/src/chat/useSessionSocket.http.test.tsx +50 -1
- package/src/chat/useSessionSocket.ts +208 -20
- package/src/chat/useSessionSocket.typingVisibility.test.tsx +280 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { PeerDraft } from "./protocol";
|
|
2
|
+
|
|
3
|
+
/** Everyone else's box, live — the multiplayer half of the composer. One row
|
|
4
|
+
* per person, newest edit last, right above your own box. */
|
|
5
|
+
export function TypingRows({ peers }: { peers: PeerDraft[] }) {
|
|
6
|
+
if (peers.length === 0) return null;
|
|
7
|
+
return (
|
|
8
|
+
<ul className="space-y-1 border-t border-border bg-background px-3 py-2" aria-live="polite">
|
|
9
|
+
{peers.map((p) =>
|
|
10
|
+
p.body === "" ? (
|
|
11
|
+
<li key={p.author.id} data-testid="typing-row" className="flex min-w-0 gap-2 text-xs">
|
|
12
|
+
<span className="shrink-0 font-medium text-foreground">{p.author.name}</span>
|
|
13
|
+
<span className="shrink-0 text-muted-foreground">is typing…</span>
|
|
14
|
+
</li>
|
|
15
|
+
) : (
|
|
16
|
+
<li key={p.author.id} data-testid="typing-row" className="flex min-w-0 gap-2 text-xs">
|
|
17
|
+
<span className="shrink-0 font-medium text-foreground">{p.author.name}</span>
|
|
18
|
+
<span className="shrink-0 text-muted-foreground">is typing:</span>
|
|
19
|
+
<span className="min-w-0 truncate italic text-foreground-secondary">{p.body}</span>
|
|
20
|
+
</li>
|
|
21
|
+
),
|
|
22
|
+
)}
|
|
23
|
+
</ul>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -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,80 +1,18 @@
|
|
|
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,
|
|
8
|
+
readStoredTypingVisibility,
|
|
12
9
|
shouldSyncDraftLive,
|
|
13
10
|
writeStoredDraft,
|
|
11
|
+
writeStoredTypingVisibility,
|
|
14
12
|
} from "./drafts"
|
|
15
13
|
|
|
16
14
|
const NOW = 1_700_000_000_000
|
|
17
15
|
|
|
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
16
|
describe("shouldSyncDraftLive", () => {
|
|
79
17
|
it("does not mirror keystrokes when you are alone", () => {
|
|
80
18
|
// The single-player case: a per-keystroke draft.update costs a round trip,
|
|
@@ -208,3 +146,43 @@ describe("clearStoredDraft", () => {
|
|
|
208
146
|
expect(readStoredDraft(s, KEY, NOW)).toBeNull()
|
|
209
147
|
})
|
|
210
148
|
})
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// Typing-visibility persistence — per browser, not per session.
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
describe("readStoredTypingVisibility / writeStoredTypingVisibility", () => {
|
|
155
|
+
it("defaults to null when nothing is stored", () => {
|
|
156
|
+
expect(readStoredTypingVisibility(fakeStorage())).toBeNull()
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it("round-trips a written mode", () => {
|
|
160
|
+
const s = fakeStorage()
|
|
161
|
+
writeStoredTypingVisibility(s, "typing")
|
|
162
|
+
expect(readStoredTypingVisibility(s)).toBe("typing")
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it("rejects a value that isn't one of the three modes", () => {
|
|
166
|
+
const s = fakeStorage({ "canopy.chat.typingVisibility": "loud" })
|
|
167
|
+
expect(readStoredTypingVisibility(s)).toBeNull()
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it("is inert without a storage", () => {
|
|
171
|
+
expect(readStoredTypingVisibility(null)).toBeNull()
|
|
172
|
+
expect(() => writeStoredTypingVisibility(null, "hidden")).not.toThrow()
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it("survives a storage that throws", () => {
|
|
176
|
+
const hostile = {
|
|
177
|
+
getItem: () => {
|
|
178
|
+
throw new Error("SecurityError")
|
|
179
|
+
},
|
|
180
|
+
setItem: () => {
|
|
181
|
+
throw new Error("SecurityError")
|
|
182
|
+
},
|
|
183
|
+
removeItem: () => {},
|
|
184
|
+
}
|
|
185
|
+
expect(readStoredTypingVisibility(hostile)).toBeNull()
|
|
186
|
+
expect(() => writeStoredTypingVisibility(hostile, "live")).not.toThrow()
|
|
187
|
+
})
|
|
188
|
+
})
|
package/src/chat/drafts.ts
CHANGED
|
@@ -1,11 +1,4 @@
|
|
|
1
|
-
import type {
|
|
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
|
-
}
|
|
1
|
+
import type { TypingVisibility } from "./protocol";
|
|
9
2
|
|
|
10
3
|
/**
|
|
11
4
|
* Whether keystrokes need to be mirrored to the server AS YOU TYPE.
|
|
@@ -25,17 +18,6 @@ export function shouldSyncDraftLive(presenceUserIds: readonly number[]): boolean
|
|
|
25
18
|
return presenceUserIds.length > 1;
|
|
26
19
|
}
|
|
27
20
|
|
|
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
21
|
// ---------------------------------------------------------------------------
|
|
40
22
|
// Composer persistence — surviving a navigation the server never hears about.
|
|
41
23
|
//
|
|
@@ -169,3 +151,43 @@ function remove(storage: DraftStorage, key: string): void {
|
|
|
169
151
|
// same as above — best effort
|
|
170
152
|
}
|
|
171
153
|
}
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// Typing-visibility persistence — per BROWSER, not per session: it is a
|
|
157
|
+
// standing choice about how you type, not something that resets when you
|
|
158
|
+
// switch chats. One key, not namespaced by `persistKey` like a draft body.
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
// Exported so a listener can recognise a `storage` event for this key coming
|
|
162
|
+
// from another tab (see useSessionSocket's cross-tab sync).
|
|
163
|
+
export const TYPING_VISIBILITY_STORAGE_KEY = "canopy.chat.typingVisibility";
|
|
164
|
+
|
|
165
|
+
const VALID_VISIBILITIES: readonly TypingVisibility[] = ["live", "typing", "hidden"];
|
|
166
|
+
|
|
167
|
+
/** The persisted mode, or null when there is nothing stored or it is
|
|
168
|
+
* unreadable/invalid — same try/catch-safe shape as `readStoredDraft`. */
|
|
169
|
+
export function readStoredTypingVisibility(storage: DraftStorage | null): TypingVisibility | null {
|
|
170
|
+
if (!storage) return null;
|
|
171
|
+
let raw: string | null = null;
|
|
172
|
+
try {
|
|
173
|
+
raw = storage.getItem(TYPING_VISIBILITY_STORAGE_KEY);
|
|
174
|
+
} catch {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
return (VALID_VISIBILITIES as readonly string[]).includes(raw ?? "")
|
|
178
|
+
? (raw as TypingVisibility)
|
|
179
|
+
: null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function writeStoredTypingVisibility(
|
|
183
|
+
storage: DraftStorage | null,
|
|
184
|
+
visibility: TypingVisibility,
|
|
185
|
+
): void {
|
|
186
|
+
if (!storage) return;
|
|
187
|
+
try {
|
|
188
|
+
storage.setItem(TYPING_VISIBILITY_STORAGE_KEY, visibility);
|
|
189
|
+
} catch {
|
|
190
|
+
// Quota exceeded, or storage disabled mid-session — best effort, same as
|
|
191
|
+
// `writeStoredDraft`.
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -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,13 +7,17 @@
|
|
|
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,
|
|
16
19
|
TurnStatus,
|
|
20
|
+
TypingVisibility,
|
|
17
21
|
WsAction,
|
|
18
22
|
WsEvent,
|
|
19
23
|
} from "./protocol";
|
|
@@ -29,6 +33,7 @@ export { prependHistory } from "./history";
|
|
|
29
33
|
// Hooks
|
|
30
34
|
export {
|
|
31
35
|
useSessionSocket,
|
|
36
|
+
newClientId,
|
|
32
37
|
type UseSessionSocketOptions,
|
|
33
38
|
type UseSessionSocketResult,
|
|
34
39
|
} from "./useSessionSocket";
|
|
@@ -45,9 +50,6 @@ export {
|
|
|
45
50
|
type TurnNotice,
|
|
46
51
|
} from "./turnStatus";
|
|
47
52
|
|
|
48
|
-
// Draft idle helpers
|
|
49
|
-
export { IDLE_THRESHOLD_MS, isDraftIdle, msUntilDraftIdle } from "./drafts";
|
|
50
|
-
|
|
51
53
|
// Composer persistence (survives unmount / tab close)
|
|
52
54
|
export {
|
|
53
55
|
DRAFT_STORAGE_TTL_MS,
|
|
@@ -56,6 +58,8 @@ export {
|
|
|
56
58
|
draftStorageKey,
|
|
57
59
|
readStoredDraft,
|
|
58
60
|
writeStoredDraft,
|
|
61
|
+
readStoredTypingVisibility,
|
|
62
|
+
writeStoredTypingVisibility,
|
|
59
63
|
type DraftStorage,
|
|
60
64
|
} from "./drafts";
|
|
61
65
|
|
|
@@ -77,6 +81,9 @@ export { MessageItem, type RenderMarkdown } from "./MessageItem";
|
|
|
77
81
|
export { ToolCallPair } from "./ToolCallPair";
|
|
78
82
|
export { SendBox, type PendingAttachment } from "./SendBox";
|
|
79
83
|
export { PresenceChips } from "./PresenceChips";
|
|
84
|
+
export { TypingRows } from "./TypingRows";
|
|
85
|
+
export { QueuedRows } from "./QueuedRows";
|
|
86
|
+
export { isMine } from "./identity";
|
|
80
87
|
export { ConnectionStatus } from "./ConnectionStatus";
|
|
81
88
|
export {
|
|
82
89
|
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,8 +46,17 @@ 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
|
|
|
54
|
+
/** How much of an in-progress message its author lets OTHERS see: the words
|
|
55
|
+
* live as typed (`live`, today's behaviour), just the fact of typing
|
|
56
|
+
* (`typing`), or nothing at all until it's sent (`hidden`). Chosen per
|
|
57
|
+
* person, per browser (persisted in localStorage) — see useSessionSocket. */
|
|
58
|
+
export type TypingVisibility = "live" | "typing" | "hidden";
|
|
59
|
+
|
|
43
60
|
export interface Draft {
|
|
44
61
|
/** String PK (canopy sends `str(draft.pk)`). */
|
|
45
62
|
id: string;
|
|
@@ -49,6 +66,42 @@ export interface Draft {
|
|
|
49
66
|
version: number;
|
|
50
67
|
last_editor: number;
|
|
51
68
|
last_edit_at: string;
|
|
69
|
+
/** The draft's OWNER — distinct from `last_editor`, which is whoever most
|
|
70
|
+
* recently typed in it. Since a `draft.updated` frame now only ever
|
|
71
|
+
* reaches its author's own sockets, this is the reliable field to compare
|
|
72
|
+
* against `current_user_id` for echo-suppression; `last_editor` is kept
|
|
73
|
+
* for servers that don't send it yet. */
|
|
74
|
+
author_id?: number;
|
|
75
|
+
/** This author's chosen mode. Absent on an older server. */
|
|
76
|
+
visibility?: TypingVisibility;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A peer's live, uncommitted draft — everyone else's box, broadcast as they
|
|
80
|
+
* type. Keyed by author, never by draft id: a client only ever needs "what
|
|
81
|
+
* is this person typing right now", and an empty `body` means they sent,
|
|
82
|
+
* discarded, or cleared it — OR its author is in `typing`/`hidden` mode,
|
|
83
|
+
* where the server withholds the words but still says whether they're
|
|
84
|
+
* typing via `typing`. */
|
|
85
|
+
export interface PeerDraft {
|
|
86
|
+
author: { id: number; name: string };
|
|
87
|
+
body: string;
|
|
88
|
+
at: string | null;
|
|
89
|
+
/** Whether this person is actively typing right now — the one fact the
|
|
90
|
+
* server still tells peers when `body` is withheld. Absent from an older
|
|
91
|
+
* server, where a non-empty `body` was the only signal. */
|
|
92
|
+
typing?: boolean;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A send that landed on the server but hasn't reached the agent's turn yet
|
|
96
|
+
* — the server-derived queue, replacing whatever a client used to infer
|
|
97
|
+
* locally from its own optimistic sends. */
|
|
98
|
+
export interface QueuedMessage {
|
|
99
|
+
turn_id: string;
|
|
100
|
+
client_id: string;
|
|
101
|
+
author: MessageAuthor | null;
|
|
102
|
+
text: string;
|
|
103
|
+
sent_at: string;
|
|
104
|
+
state: "queued" | "delivering";
|
|
52
105
|
}
|
|
53
106
|
|
|
54
107
|
export interface Participant {
|
|
@@ -197,10 +250,23 @@ export interface SessionState {
|
|
|
197
250
|
* changed. Null/undefined when nothing has been asked on this session yet —
|
|
198
251
|
* which is distinct from "finished", and must stay distinct. */
|
|
199
252
|
turn_status?: TurnStatus | null;
|
|
253
|
+
/** The CALLER's own draft — null for a viewer, who has nothing to draft. */
|
|
200
254
|
active_draft: Draft | null;
|
|
255
|
+
/** Everyone else's live drafts, server-derived. Absent on an older server. */
|
|
256
|
+
peer_drafts?: PeerDraft[];
|
|
257
|
+
/** Sends that landed but haven't reached the agent's turn yet, server-
|
|
258
|
+
* derived and replaced wholesale on every frame — like `turn_status`, a
|
|
259
|
+
* client that just connected has no correct prior to merge a delta into.
|
|
260
|
+
* Absent on an older server. */
|
|
261
|
+
queued?: QueuedMessage[];
|
|
201
262
|
participants: Participant[];
|
|
202
263
|
presence_user_ids: number[];
|
|
203
|
-
|
|
264
|
+
/** The connecting user — null for a contact (a widget visitor), who has no
|
|
265
|
+
* user id. */
|
|
266
|
+
current_user_id: number | null;
|
|
267
|
+
/** The connecting CONTACT, when the viewer is one; null for a member.
|
|
268
|
+
* Absent on an older server. */
|
|
269
|
+
current_contact_id?: number | null;
|
|
204
270
|
}
|
|
205
271
|
|
|
206
272
|
// ---------------------------------------------------------------------------
|
|
@@ -211,6 +277,14 @@ export type WsAction =
|
|
|
211
277
|
| { action: "chat.send"; data: Record<string, never> }
|
|
212
278
|
| { action: "chat.stop"; data: { message_id: string } }
|
|
213
279
|
| { action: "draft.update"; data: { version: number; body: string } }
|
|
280
|
+
// The mode is its OWN idempotent frame, not a field on the version-guarded
|
|
281
|
+
// `draft.update` above — applied unconditionally server-side (no version
|
|
282
|
+
// check), so it can never race a keystroke and never itself causes a
|
|
283
|
+
// `draft_version_mismatch`. A `draft.update` carrying a stray `visibility`
|
|
284
|
+
// (an in-flight older 0.14 client) is simply ignored server-side.
|
|
285
|
+
| { action: "draft.set_visibility"; data: { visibility: TypingVisibility } }
|
|
286
|
+
/** @deprecated accepted and ignored by the server since 0.13 — every editor
|
|
287
|
+
* gets their own draft now, so there is no lock left to take over. */
|
|
214
288
|
| { action: "draft.take_over"; data: Record<string, never> }
|
|
215
289
|
| { action: "draft.discard"; data: Record<string, never> }
|
|
216
290
|
| { action: "presence.heartbeat"; data: Record<string, never> };
|
|
@@ -238,8 +312,17 @@ export type WsEvent =
|
|
|
238
312
|
// offline box, unroutable) are ones activity has no way to spell.
|
|
239
313
|
| { event: "session.turn_status"; data: { status: TurnStatus | null } }
|
|
240
314
|
// 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
|
-
|
|
315
|
+
// so this is the only way it reaches the browser before a reload. `author`
|
|
316
|
+
// rides here for the same reason it rides the queued list (spec 2026-09-26):
|
|
317
|
+
// a transcript-sourced session's durable row carries no user beyond the
|
|
318
|
+
// runner's own login, so this is canopy's only chance to say who typed it.
|
|
319
|
+
// `client_id` is likewise the only way an HTTP send's OPTIMISTIC row (built
|
|
320
|
+
// locally by `noteLocalSend`, never a real server frame) can carry the same
|
|
321
|
+
// id its REST body sent — without it `QueuedRows.hideClientIds` cannot match
|
|
322
|
+
// this row to the server's queued-turn projection, and a contact's or
|
|
323
|
+
// widget's own send rendered twice: once as their bubble, once as a "queued"
|
|
324
|
+
// placeholder for the same send.
|
|
325
|
+
| { event: "chat.user_message"; data: { message_id: string; turn_index: number; plaintext: string; author?: MessageAuthor | null; client_id?: string } }
|
|
243
326
|
| { event: "chat.delta"; data: { message_id: string; text: string } }
|
|
244
327
|
// `turn_index` is the row's transcript ordinal — the same key the persisted
|
|
245
328
|
// Message carries, so a live tool row sorts into exactly the position it will
|
|
@@ -250,7 +333,13 @@ export type WsEvent =
|
|
|
250
333
|
| { event: "chat.stream_error"; data: { message_id: string; detail: string } }
|
|
251
334
|
| { event: "chat.stream_cancelled"; data: { message_id: string | null; partial_len: number } }
|
|
252
335
|
| { event: "draft.updated"; data: Draft }
|
|
253
|
-
|
|
336
|
+
// Someone else's box, live — keyed by author, not draft id, because a peer's
|
|
337
|
+
// draft is never addressable by this client and never needs to be: only
|
|
338
|
+
// "what are they typing right now" does. An empty `body` means they sent,
|
|
339
|
+
// discarded, or cleared it. Replaces `draft.lock_changed`, which existed
|
|
340
|
+
// only because a session had one shared next-draft to fight over; now
|
|
341
|
+
// everyone has their own, so there is no lock left to report.
|
|
342
|
+
| { event: "draft.typing"; data: PeerDraft }
|
|
254
343
|
// `client_id` is the sender's receipt for a send that carried one (see
|
|
255
344
|
// useSessionSocket.sendChat); absent from older servers.
|
|
256
345
|
| { event: "draft.committed"; data: { draft_id: string; user_message_id: string; client_id?: string } }
|
|
@@ -270,4 +359,7 @@ export type WsEvent =
|
|
|
270
359
|
display_name?: string;
|
|
271
360
|
};
|
|
272
361
|
}
|
|
273
|
-
| { event: "presence.left"; data: { user_id: number } }
|
|
362
|
+
| { event: "presence.left"; data: { user_id: number } }
|
|
363
|
+
// The whole queue, wholesale, like `session.turn_status` — a client that
|
|
364
|
+
// just connected has no correct prior to merge a delta into.
|
|
365
|
+
| { event: "session.queued"; data: { queued: QueuedMessage[] } };
|