canopy-ui 0.2.0 → 0.4.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 +20 -1
- package/src/chat/ChatPanel.tsx +140 -0
- package/src/chat/ConnectionStatus.tsx +30 -0
- package/src/chat/MessageItem.tsx +198 -0
- package/src/chat/MessageList.tsx +95 -0
- package/src/chat/PlacementBanner.test.tsx +112 -0
- package/src/chat/PlacementBanner.tsx +92 -0
- package/src/chat/PresenceChips.tsx +52 -0
- package/src/chat/SendBox.tsx +144 -0
- package/src/chat/ToolCallPair.tsx +86 -0
- package/src/chat/drafts.test.ts +66 -0
- package/src/chat/drafts.ts +19 -0
- package/src/chat/history.test.ts +35 -0
- package/src/chat/history.ts +16 -0
- package/src/chat/index.ts +56 -0
- package/src/chat/pairToolMessages.test.ts +208 -0
- package/src/chat/pairToolMessages.ts +162 -0
- package/src/chat/protocol.ts +99 -0
- package/src/chat/sessionReducer.test.ts +284 -0
- package/src/chat/sessionReducer.ts +245 -0
- package/src/chat/useSessionSocket.ts +269 -0
- package/src/chat/useStickyBottom.ts +77 -0
- package/src/ui/AutoResizeTextarea.tsx +61 -0
- package/src/ui/dialog.tsx +131 -0
- package/src/ui/dropdown-menu.tsx +226 -0
- package/src/ui/index.ts +32 -0
- package/src/ui/sonner.tsx +23 -0
- package/src/ui/tooltip.tsx +55 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
|
|
3
|
+
import type { Draft, Message, SessionState, WsEvent } from "./protocol"
|
|
4
|
+
import { sessionReducer } from "./sessionReducer"
|
|
5
|
+
|
|
6
|
+
const baseDraft: Draft = {
|
|
7
|
+
id: "d1",
|
|
8
|
+
slot: "next",
|
|
9
|
+
status: "open",
|
|
10
|
+
body: "",
|
|
11
|
+
version: 0,
|
|
12
|
+
last_editor: 0,
|
|
13
|
+
last_edit_at: "",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function makeState(overrides: Partial<SessionState> = {}): SessionState {
|
|
17
|
+
return {
|
|
18
|
+
messages: [],
|
|
19
|
+
active_draft: null,
|
|
20
|
+
participants: [],
|
|
21
|
+
presence_user_ids: [],
|
|
22
|
+
current_user_id: 0,
|
|
23
|
+
...overrides,
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function makeMessage(overrides: Partial<Message> = {}): Message {
|
|
28
|
+
return {
|
|
29
|
+
id: "1",
|
|
30
|
+
turn_index: 1,
|
|
31
|
+
role: "assistant",
|
|
32
|
+
content: {},
|
|
33
|
+
plaintext: "",
|
|
34
|
+
status: "pending",
|
|
35
|
+
error_detail: null,
|
|
36
|
+
started_at: null,
|
|
37
|
+
completed_at: null,
|
|
38
|
+
created_at: new Date().toISOString(),
|
|
39
|
+
...overrides,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("sessionReducer — chat stream", () => {
|
|
44
|
+
it("session.state replaces the whole state", () => {
|
|
45
|
+
const prev = makeState({ messages: [makeMessage()] })
|
|
46
|
+
const replacement = makeState({ current_user_id: 42 })
|
|
47
|
+
const next = sessionReducer(prev, {
|
|
48
|
+
event: "session.state",
|
|
49
|
+
data: replacement,
|
|
50
|
+
} as WsEvent)
|
|
51
|
+
expect(next).toBe(replacement)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it("chat.stream_start flips a matching message to streaming", () => {
|
|
55
|
+
const m = makeMessage({ id: "7", status: "pending" })
|
|
56
|
+
const prev = makeState({ messages: [m] })
|
|
57
|
+
const next = sessionReducer(prev, {
|
|
58
|
+
event: "chat.stream_start",
|
|
59
|
+
data: { message_id: "7", turn_index: 3 },
|
|
60
|
+
} as WsEvent)
|
|
61
|
+
expect(next.messages).toHaveLength(1)
|
|
62
|
+
expect(next.messages[0].status).toBe("streaming")
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it("chat.stream_start for an unknown id CREATES the assistant message (upsert)", () => {
|
|
66
|
+
const m = makeMessage({ id: "7", role: "user", status: "complete" })
|
|
67
|
+
const prev = makeState({ messages: [m] })
|
|
68
|
+
const next = sessionReducer(prev, {
|
|
69
|
+
event: "chat.stream_start",
|
|
70
|
+
data: { message_id: "99", turn_index: 5 },
|
|
71
|
+
} as WsEvent)
|
|
72
|
+
expect(next.messages).toHaveLength(2)
|
|
73
|
+
expect(next.messages[1]).toMatchObject({
|
|
74
|
+
id: "99",
|
|
75
|
+
role: "assistant",
|
|
76
|
+
status: "streaming",
|
|
77
|
+
plaintext: "",
|
|
78
|
+
turn_index: 5,
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it("chat.delta appends text to plaintext", () => {
|
|
83
|
+
const m = makeMessage({ id: "7", plaintext: "Hello" })
|
|
84
|
+
const prev = makeState({ messages: [m] })
|
|
85
|
+
const next = sessionReducer(prev, {
|
|
86
|
+
event: "chat.delta",
|
|
87
|
+
data: { message_id: "7", text: " world" },
|
|
88
|
+
} as WsEvent)
|
|
89
|
+
expect(next.messages[0].plaintext).toBe("Hello world")
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it("chat.stream_complete replaces plaintext and marks complete", () => {
|
|
93
|
+
const m = makeMessage({ id: "7", plaintext: "stale partial", status: "streaming" })
|
|
94
|
+
const prev = makeState({ messages: [m] })
|
|
95
|
+
const next = sessionReducer(prev, {
|
|
96
|
+
event: "chat.stream_complete",
|
|
97
|
+
data: { message_id: "7", plaintext: "final answer" },
|
|
98
|
+
} as WsEvent)
|
|
99
|
+
expect(next.messages[0].plaintext).toBe("final answer")
|
|
100
|
+
expect(next.messages[0].status).toBe("complete")
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it("chat.stream_error sets error_detail and status=error", () => {
|
|
104
|
+
const m = makeMessage({ id: "7", status: "streaming" })
|
|
105
|
+
const prev = makeState({ messages: [m] })
|
|
106
|
+
const next = sessionReducer(prev, {
|
|
107
|
+
event: "chat.stream_error",
|
|
108
|
+
data: { message_id: "7", detail: "cancelled" },
|
|
109
|
+
} as WsEvent)
|
|
110
|
+
expect(next.messages[0].status).toBe("error")
|
|
111
|
+
expect(next.messages[0].error_detail).toBe("cancelled")
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it("chat.stream_cancelled stamps a partial-length detail", () => {
|
|
115
|
+
const m = makeMessage({ id: "7", status: "streaming" })
|
|
116
|
+
const prev = makeState({ messages: [m] })
|
|
117
|
+
const next = sessionReducer(prev, {
|
|
118
|
+
event: "chat.stream_cancelled",
|
|
119
|
+
data: { message_id: "7", partial_len: 142 },
|
|
120
|
+
} as WsEvent)
|
|
121
|
+
expect(next.messages[0].status).toBe("error")
|
|
122
|
+
expect(next.messages[0].error_detail).toMatch(/142/)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it("chat.tool_use is a no-op", () => {
|
|
126
|
+
const prev = makeState({ messages: [makeMessage()] })
|
|
127
|
+
const next = sessionReducer(prev, {
|
|
128
|
+
event: "chat.tool_use",
|
|
129
|
+
data: { parent_message_id: null, tool_message_id: "t1", block: {} },
|
|
130
|
+
} as WsEvent)
|
|
131
|
+
expect(next).toBe(prev)
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
describe("sessionReducer — drafts", () => {
|
|
136
|
+
it("draft.updated keeps local body when echo's last_editor matches current_user_id", () => {
|
|
137
|
+
// Echo-suppression: server echo arrives stale relative to the user's
|
|
138
|
+
// own keystrokes; reducer must keep the local body and only accept
|
|
139
|
+
// metadata. This is the most subtle branch in the file.
|
|
140
|
+
const prev = makeState({
|
|
141
|
+
current_user_id: 5,
|
|
142
|
+
active_draft: { ...baseDraft, body: "local typing", version: 3 },
|
|
143
|
+
})
|
|
144
|
+
const next = sessionReducer(prev, {
|
|
145
|
+
event: "draft.updated",
|
|
146
|
+
data: {
|
|
147
|
+
...baseDraft,
|
|
148
|
+
body: "stale server echo",
|
|
149
|
+
last_editor: 5,
|
|
150
|
+
version: 3,
|
|
151
|
+
} as Draft,
|
|
152
|
+
} as WsEvent)
|
|
153
|
+
expect(next.active_draft?.body).toBe("local typing")
|
|
154
|
+
expect(next.active_draft?.version).toBe(3)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it("draft.updated accepts the body when another user is editing", () => {
|
|
158
|
+
const prev = makeState({
|
|
159
|
+
current_user_id: 5,
|
|
160
|
+
active_draft: { ...baseDraft, body: "old", last_editor: 7 },
|
|
161
|
+
})
|
|
162
|
+
const next = sessionReducer(prev, {
|
|
163
|
+
event: "draft.updated",
|
|
164
|
+
data: {
|
|
165
|
+
...baseDraft,
|
|
166
|
+
body: "their text",
|
|
167
|
+
last_editor: 7,
|
|
168
|
+
version: 2,
|
|
169
|
+
} as Draft,
|
|
170
|
+
} as WsEvent)
|
|
171
|
+
expect(next.active_draft?.body).toBe("their text")
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it("draft.committed inserts the optimistic user message and clears the draft body", () => {
|
|
175
|
+
// canopy adaptation: NO assistant placeholder here (draft.committed has
|
|
176
|
+
// no assistant id) — only the user message is inserted; the assistant is
|
|
177
|
+
// upserted later on chat.stream_start.
|
|
178
|
+
const prev = makeState({
|
|
179
|
+
active_draft: { ...baseDraft, body: "the prompt" },
|
|
180
|
+
messages: [makeMessage({ id: "1", turn_index: 1 })],
|
|
181
|
+
})
|
|
182
|
+
const next = sessionReducer(prev, {
|
|
183
|
+
event: "draft.committed",
|
|
184
|
+
data: { user_message_id: "100", draft_id: "d1" },
|
|
185
|
+
} as WsEvent)
|
|
186
|
+
expect(next.messages).toHaveLength(2)
|
|
187
|
+
expect(next.messages[1]).toMatchObject({
|
|
188
|
+
id: "100",
|
|
189
|
+
role: "user",
|
|
190
|
+
plaintext: "the prompt",
|
|
191
|
+
turn_index: 2,
|
|
192
|
+
})
|
|
193
|
+
// active_draft.body cleared so Enter doesn't re-send the same turn.
|
|
194
|
+
expect(next.active_draft?.body).toBe("")
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it("draft.committed then chat.stream_start makes the assistant reply visible", () => {
|
|
198
|
+
// The load-bearing sequence: commit inserts the user msg, stream_start
|
|
199
|
+
// upserts the assistant row, delta/complete fill it in.
|
|
200
|
+
let s = makeState({ active_draft: { ...baseDraft, body: "hi" } })
|
|
201
|
+
s = sessionReducer(s, {
|
|
202
|
+
event: "draft.committed",
|
|
203
|
+
data: { user_message_id: "u1", draft_id: "d1" },
|
|
204
|
+
} as WsEvent)
|
|
205
|
+
s = sessionReducer(s, {
|
|
206
|
+
event: "chat.stream_start",
|
|
207
|
+
data: { message_id: "a1", turn_index: 2 },
|
|
208
|
+
} as WsEvent)
|
|
209
|
+
s = sessionReducer(s, {
|
|
210
|
+
event: "chat.stream_complete",
|
|
211
|
+
data: { message_id: "a1", plaintext: "hello there" },
|
|
212
|
+
} as WsEvent)
|
|
213
|
+
expect(s.messages.map((m) => m.role)).toEqual(["user", "assistant"])
|
|
214
|
+
expect(s.messages[1].plaintext).toBe("hello there")
|
|
215
|
+
expect(s.messages[1].status).toBe("complete")
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it("draft.discarded clears matching draft body", () => {
|
|
219
|
+
const prev = makeState({
|
|
220
|
+
active_draft: { ...baseDraft, id: "d1", body: "draft text" },
|
|
221
|
+
})
|
|
222
|
+
const next = sessionReducer(prev, {
|
|
223
|
+
event: "draft.discarded",
|
|
224
|
+
data: { draft_id: "d1" },
|
|
225
|
+
} as WsEvent)
|
|
226
|
+
expect(next.active_draft?.body).toBe("")
|
|
227
|
+
})
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
describe("sessionReducer — presence", () => {
|
|
231
|
+
it("presence.joined adds a user_id idempotently", () => {
|
|
232
|
+
const prev = makeState({ presence_user_ids: [1, 2] })
|
|
233
|
+
const next = sessionReducer(prev, {
|
|
234
|
+
event: "presence.joined",
|
|
235
|
+
data: { user_id: 3 },
|
|
236
|
+
} as WsEvent)
|
|
237
|
+
expect(next.presence_user_ids.sort()).toEqual([1, 2, 3])
|
|
238
|
+
|
|
239
|
+
// Second join is a no-op (Set semantics).
|
|
240
|
+
const after = sessionReducer(next, {
|
|
241
|
+
event: "presence.joined",
|
|
242
|
+
data: { user_id: 3 },
|
|
243
|
+
} as WsEvent)
|
|
244
|
+
expect(after.presence_user_ids.filter((id) => id === 3)).toHaveLength(1)
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it("presence.left filters out the user_id", () => {
|
|
248
|
+
const prev = makeState({ presence_user_ids: [1, 2, 3] })
|
|
249
|
+
const next = sessionReducer(prev, {
|
|
250
|
+
event: "presence.left",
|
|
251
|
+
data: { user_id: 2 },
|
|
252
|
+
} as WsEvent)
|
|
253
|
+
expect(next.presence_user_ids).toEqual([1, 3])
|
|
254
|
+
})
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
describe("sessionReducer — session.error draft_version_mismatch", () => {
|
|
258
|
+
it("rolls active_draft back to the server's reported version + body", () => {
|
|
259
|
+
const prev = makeState({
|
|
260
|
+
active_draft: { ...baseDraft, version: 9, body: "stale local" },
|
|
261
|
+
})
|
|
262
|
+
const next = sessionReducer(prev, {
|
|
263
|
+
event: "session.error",
|
|
264
|
+
data: {
|
|
265
|
+
message: "version mismatch",
|
|
266
|
+
code: "draft_version_mismatch",
|
|
267
|
+
detail: { current_version: 11, current_body: "server body" },
|
|
268
|
+
},
|
|
269
|
+
} as WsEvent)
|
|
270
|
+
expect(next.active_draft?.version).toBe(11)
|
|
271
|
+
expect(next.active_draft?.body).toBe("server body")
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it("non-version-mismatch errors are no-ops to state (side-effect handled by hook)", () => {
|
|
275
|
+
const prev = makeState({
|
|
276
|
+
active_draft: { ...baseDraft, body: "x" },
|
|
277
|
+
})
|
|
278
|
+
const next = sessionReducer(prev, {
|
|
279
|
+
event: "session.error",
|
|
280
|
+
data: { message: "something else", code: "other" },
|
|
281
|
+
} as WsEvent)
|
|
282
|
+
expect(next).toBe(prev)
|
|
283
|
+
})
|
|
284
|
+
})
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import type { Draft, Message, SessionState, WsEvent } from "./protocol";
|
|
2
|
+
|
|
3
|
+
// Pure reducer for SessionState — extracted from useSessionSocket so it
|
|
4
|
+
// can be unit-tested without WebSocket plumbing. Side-effect events
|
|
5
|
+
// (session.title_updated → optional injected callback; session.error →
|
|
6
|
+
// setLastError + clear draft debounce) stay in the hook itself.
|
|
7
|
+
//
|
|
8
|
+
// Keep this file dependency-free (no React) so a vitest run doesn't pull
|
|
9
|
+
// jsdom or RTL.
|
|
10
|
+
//
|
|
11
|
+
// canopy adaptation vs ace: message/draft ids are STRINGS, and
|
|
12
|
+
// `chat.stream_start` UPSERTS the assistant message — canopy's
|
|
13
|
+
// `draft.committed` carries only `user_message_id` (no assistant id to
|
|
14
|
+
// pre-insert), so the assistant row is created lazily when its first stream
|
|
15
|
+
// frame arrives.
|
|
16
|
+
export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState {
|
|
17
|
+
switch (frame.event) {
|
|
18
|
+
case "session.state":
|
|
19
|
+
return frame.data;
|
|
20
|
+
|
|
21
|
+
case "chat.stream_start": {
|
|
22
|
+
// Upsert: if the assistant message already exists (rare — a runner that
|
|
23
|
+
// pre-inserts it), flip it to streaming; otherwise create it. canopy's
|
|
24
|
+
// draft.committed cannot pre-send the assistant id, so this is the
|
|
25
|
+
// normal path for making the streamed reply visible.
|
|
26
|
+
const exists = prev.messages.some((m) => m.id === frame.data.message_id);
|
|
27
|
+
if (exists) {
|
|
28
|
+
return {
|
|
29
|
+
...prev,
|
|
30
|
+
messages: prev.messages.map((m) =>
|
|
31
|
+
m.id === frame.data.message_id
|
|
32
|
+
? { ...m, status: "streaming" as const }
|
|
33
|
+
: m,
|
|
34
|
+
),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
const nowIso = new Date().toISOString();
|
|
38
|
+
const assistant: Message = {
|
|
39
|
+
id: frame.data.message_id,
|
|
40
|
+
turn_index: frame.data.turn_index,
|
|
41
|
+
role: "assistant",
|
|
42
|
+
content: {},
|
|
43
|
+
plaintext: "",
|
|
44
|
+
status: "streaming",
|
|
45
|
+
error_detail: null,
|
|
46
|
+
started_at: nowIso,
|
|
47
|
+
completed_at: null,
|
|
48
|
+
created_at: nowIso,
|
|
49
|
+
};
|
|
50
|
+
return { ...prev, messages: [...prev.messages, assistant] };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
case "chat.delta":
|
|
54
|
+
return {
|
|
55
|
+
...prev,
|
|
56
|
+
messages: prev.messages.map((m) =>
|
|
57
|
+
m.id === frame.data.message_id
|
|
58
|
+
? { ...m, plaintext: m.plaintext + frame.data.text }
|
|
59
|
+
: m,
|
|
60
|
+
),
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
case "chat.stream_complete":
|
|
64
|
+
return {
|
|
65
|
+
...prev,
|
|
66
|
+
messages: prev.messages.map((m) =>
|
|
67
|
+
m.id === frame.data.message_id
|
|
68
|
+
? {
|
|
69
|
+
...m,
|
|
70
|
+
plaintext: frame.data.plaintext,
|
|
71
|
+
status: "complete" as const,
|
|
72
|
+
}
|
|
73
|
+
: m,
|
|
74
|
+
),
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
case "chat.stream_error":
|
|
78
|
+
// NOTE: backend emits chat.stream_error with detail="cancelled"
|
|
79
|
+
// for stop-driven cancellation; there's no separate
|
|
80
|
+
// chat.stream_cancelled event in practice. Distinguished by detail.
|
|
81
|
+
return {
|
|
82
|
+
...prev,
|
|
83
|
+
messages: prev.messages.map((m) =>
|
|
84
|
+
m.id === frame.data.message_id
|
|
85
|
+
? {
|
|
86
|
+
...m,
|
|
87
|
+
status: "error" as const,
|
|
88
|
+
error_detail: frame.data.detail,
|
|
89
|
+
}
|
|
90
|
+
: m,
|
|
91
|
+
),
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
case "chat.stream_cancelled":
|
|
95
|
+
return {
|
|
96
|
+
...prev,
|
|
97
|
+
messages: prev.messages.map((m) =>
|
|
98
|
+
m.id === frame.data.message_id
|
|
99
|
+
? {
|
|
100
|
+
...m,
|
|
101
|
+
status: "error" as const,
|
|
102
|
+
error_detail: `cancelled (partial: ${frame.data.partial_len} chars)`,
|
|
103
|
+
}
|
|
104
|
+
: m,
|
|
105
|
+
),
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
case "chat.tool_use":
|
|
109
|
+
case "chat.tool_result":
|
|
110
|
+
// Tool rows are their own Message rows on the server. A full
|
|
111
|
+
// refresh picks them up; for now, don't duplicate bookkeeping here.
|
|
112
|
+
return prev;
|
|
113
|
+
|
|
114
|
+
case "draft.updated": {
|
|
115
|
+
const incoming = frame.data as Draft;
|
|
116
|
+
// If we're the current editor, keep our local body — the server
|
|
117
|
+
// echo is stale relative to keystrokes that happened since the
|
|
118
|
+
// debounced send. Only accept metadata (version, last_editor, etc).
|
|
119
|
+
if (
|
|
120
|
+
prev.active_draft &&
|
|
121
|
+
incoming.last_editor === prev.current_user_id
|
|
122
|
+
) {
|
|
123
|
+
return {
|
|
124
|
+
...prev,
|
|
125
|
+
active_draft: {
|
|
126
|
+
...prev.active_draft,
|
|
127
|
+
version: incoming.version,
|
|
128
|
+
last_editor: incoming.last_editor,
|
|
129
|
+
last_edit_at: incoming.last_edit_at,
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
return { ...prev, active_draft: incoming };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
case "draft.lock_changed":
|
|
137
|
+
if (prev.active_draft && prev.active_draft.id === frame.data.draft_id) {
|
|
138
|
+
return {
|
|
139
|
+
...prev,
|
|
140
|
+
active_draft: {
|
|
141
|
+
...prev.active_draft,
|
|
142
|
+
last_editor: frame.data.holder_user_id ?? prev.active_draft.last_editor,
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
return prev;
|
|
147
|
+
|
|
148
|
+
case "draft.committed": {
|
|
149
|
+
// Insert the optimistic USER message from the draft body that's about
|
|
150
|
+
// to be cleared. The assistant reply is NOT inserted here — canopy's
|
|
151
|
+
// draft.committed carries no assistant id; `chat.stream_start` upserts
|
|
152
|
+
// that row when the reply begins.
|
|
153
|
+
//
|
|
154
|
+
// Also clear active_draft.body here. The server creates a new empty
|
|
155
|
+
// draft with last_editor=sender, so the follow-up draft.updated hits
|
|
156
|
+
// the "keep local body" branch above and would otherwise leave the
|
|
157
|
+
// just-sent text in the textarea — which lets Enter re-send the same
|
|
158
|
+
// turn repeatedly.
|
|
159
|
+
const prevDraftBody = prev.active_draft?.body ?? "";
|
|
160
|
+
const maxTurnIndex = prev.messages.reduce(
|
|
161
|
+
(acc, msg) => Math.max(acc, msg.turn_index),
|
|
162
|
+
0,
|
|
163
|
+
);
|
|
164
|
+
const nowIso = new Date().toISOString();
|
|
165
|
+
const userMessage: Message = {
|
|
166
|
+
id: frame.data.user_message_id,
|
|
167
|
+
turn_index: maxTurnIndex + 1,
|
|
168
|
+
role: "user",
|
|
169
|
+
content: { text: prevDraftBody },
|
|
170
|
+
plaintext: prevDraftBody,
|
|
171
|
+
status: "complete",
|
|
172
|
+
error_detail: null,
|
|
173
|
+
started_at: null,
|
|
174
|
+
completed_at: nowIso,
|
|
175
|
+
created_at: nowIso,
|
|
176
|
+
};
|
|
177
|
+
return {
|
|
178
|
+
...prev,
|
|
179
|
+
active_draft: prev.active_draft
|
|
180
|
+
? { ...prev.active_draft, body: "" }
|
|
181
|
+
: prev.active_draft,
|
|
182
|
+
messages: [...prev.messages, userMessage],
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
case "draft.discarded":
|
|
187
|
+
if (prev.active_draft && prev.active_draft.id === frame.data.draft_id) {
|
|
188
|
+
return {
|
|
189
|
+
...prev,
|
|
190
|
+
active_draft: { ...prev.active_draft, body: "" },
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
return prev;
|
|
194
|
+
|
|
195
|
+
case "presence.joined": {
|
|
196
|
+
const ids = new Set(prev.presence_user_ids);
|
|
197
|
+
ids.add(frame.data.user_id);
|
|
198
|
+
return { ...prev, presence_user_ids: [...ids] };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
case "presence.left":
|
|
202
|
+
return {
|
|
203
|
+
...prev,
|
|
204
|
+
presence_user_ids: prev.presence_user_ids.filter(
|
|
205
|
+
(id) => id !== frame.data.user_id,
|
|
206
|
+
),
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
case "session.error": {
|
|
210
|
+
// Side effects (setLastError, clear draft debounce) are handled
|
|
211
|
+
// by the hook; the reducer only knows about the version-mismatch
|
|
212
|
+
// recovery, which mutates active_draft.
|
|
213
|
+
if (
|
|
214
|
+
frame.data.code === "draft_version_mismatch" &&
|
|
215
|
+
frame.data.detail &&
|
|
216
|
+
typeof frame.data.detail === "object"
|
|
217
|
+
) {
|
|
218
|
+
const detail = frame.data.detail as {
|
|
219
|
+
current_version: number;
|
|
220
|
+
current_body: string;
|
|
221
|
+
};
|
|
222
|
+
return prev.active_draft
|
|
223
|
+
? {
|
|
224
|
+
...prev,
|
|
225
|
+
active_draft: {
|
|
226
|
+
...prev.active_draft,
|
|
227
|
+
version: detail.current_version,
|
|
228
|
+
body: detail.current_body,
|
|
229
|
+
},
|
|
230
|
+
}
|
|
231
|
+
: prev;
|
|
232
|
+
}
|
|
233
|
+
return prev;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
case "session.title_updated":
|
|
237
|
+
// Pure reducer leaves this alone — the hook calls its optional
|
|
238
|
+
// onTitleUpdated callback on receipt and short-circuits. Included
|
|
239
|
+
// here so an exhaustive switch type-checks.
|
|
240
|
+
return prev;
|
|
241
|
+
|
|
242
|
+
default:
|
|
243
|
+
return prev;
|
|
244
|
+
}
|
|
245
|
+
}
|