canopy-ui 0.3.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.
@@ -0,0 +1,162 @@
1
+ import type { Message } from "./protocol";
2
+
3
+ /**
4
+ * A renderable row in the chat: either a single message (user / assistant /
5
+ * standalone tool, etc.) or a paired tool_use + tool_result.
6
+ *
7
+ * Pairing rule: a ``tool_use`` row is paired with the FIRST subsequent
8
+ * ``tool_result`` whose ``content.tool_use_id`` matches the ``tool_use``'s
9
+ * ``content.id``. If no matching result exists yet (the turn is still
10
+ * streaming), the row is rendered as a tool-pair with ``result === null``
11
+ * — UI shows that as the "pending" state.
12
+ *
13
+ * Unpaired ``tool_result`` rows (no preceding ``tool_use`` with a matching
14
+ * id — shouldn't happen in practice, but defend) fall through as standalone
15
+ * messages so we never silently drop content.
16
+ */
17
+ export type ChatRow =
18
+ | { kind: "message"; message: Message; key: string }
19
+ | { kind: "tool_pair"; use: Message; result: Message | null; key: string };
20
+
21
+ function toolUseId(message: Message): string | null {
22
+ const content = message.content as Record<string, unknown> | undefined;
23
+ const id = content?.id;
24
+ return typeof id === "string" ? id : null;
25
+ }
26
+
27
+ function toolResultId(message: Message): string | null {
28
+ const content = message.content as Record<string, unknown> | undefined;
29
+ const id = content?.tool_use_id;
30
+ return typeof id === "string" ? id : null;
31
+ }
32
+
33
+ export function pairToolMessages(messages: Message[]): ChatRow[] {
34
+ const rows: ChatRow[] = [];
35
+ // Map of tool_use_id → index into rows[] for that pair. Lets a tool_result
36
+ // arriving later in the stream slot itself into the existing pair row.
37
+ const pendingByToolId = new Map<string, number>();
38
+
39
+ for (const m of messages) {
40
+ if (m.role === "tool_use") {
41
+ const id = toolUseId(m);
42
+ const row: ChatRow = {
43
+ kind: "tool_pair",
44
+ use: m,
45
+ result: null,
46
+ key: `pair-${m.id}`,
47
+ };
48
+ rows.push(row);
49
+ if (id !== null) {
50
+ pendingByToolId.set(id, rows.length - 1);
51
+ }
52
+ continue;
53
+ }
54
+ if (m.role === "tool_result") {
55
+ const id = toolResultId(m);
56
+ if (id !== null) {
57
+ const idx = pendingByToolId.get(id);
58
+ if (idx !== undefined) {
59
+ const row = rows[idx];
60
+ if (row.kind === "tool_pair") {
61
+ rows[idx] = { ...row, result: m };
62
+ pendingByToolId.delete(id);
63
+ continue;
64
+ }
65
+ }
66
+ }
67
+ // Fallthrough: no preceding tool_use match — show standalone so
68
+ // content isn't silently dropped.
69
+ rows.push({ kind: "message", message: m, key: `msg-${m.id}` });
70
+ continue;
71
+ }
72
+ rows.push({ kind: "message", message: m, key: `msg-${m.id}` });
73
+ }
74
+ return rows;
75
+ }
76
+
77
+ export interface ToolCallStatus {
78
+ kind: "success" | "error" | "pending";
79
+ label: string;
80
+ }
81
+
82
+ /** Derive a status badge for a paired tool row. */
83
+ export function deriveToolStatus(
84
+ use: Message,
85
+ result: Message | null,
86
+ ): ToolCallStatus {
87
+ if (result === null) {
88
+ return { kind: "pending", label: "running…" };
89
+ }
90
+ if (result.status === "error" || use.status === "error") {
91
+ return { kind: "error", label: "error" };
92
+ }
93
+ // Some MCP servers signal failure in the result content rather than
94
+ // setting an HTTP-style status — sniff for the common "Error" / "error"
95
+ // prefixes so the badge reflects reality without server changes.
96
+ const head = (result.plaintext || "").trim().slice(0, 80).toLowerCase();
97
+ if (head.startsWith("error") || head.startsWith("traceback")) {
98
+ return { kind: "error", label: "error" };
99
+ }
100
+ return { kind: "success", label: "ok" };
101
+ }
102
+
103
+ /** One-line summary for the collapsed tool-call header. */
104
+ export function toolPreview(use: Message, result: Message | null): string {
105
+ const name = String(
106
+ (use.content as { name?: unknown } | undefined)?.name ?? "tool",
107
+ );
108
+ // Bash → command text; everything else → first 80 chars of result body.
109
+ const input = (use.content as { input?: Record<string, unknown> } | undefined)
110
+ ?.input;
111
+ if (name === "Bash" && input && typeof input.command === "string") {
112
+ return input.command.trim().split("\n")[0]?.slice(0, 100) ?? "";
113
+ }
114
+ if (name === "Write" && input && typeof input.file_path === "string") {
115
+ return input.file_path;
116
+ }
117
+ if (name === "Read" && input && typeof input.file_path === "string") {
118
+ return input.file_path;
119
+ }
120
+ if (name === "Edit" && input && typeof input.file_path === "string") {
121
+ return input.file_path;
122
+ }
123
+ if (name === "TodoWrite") {
124
+ const todos = (input as { todos?: unknown[] } | undefined)?.todos;
125
+ if (Array.isArray(todos)) {
126
+ return `${todos.length} todo${todos.length === 1 ? "" : "s"}`;
127
+ }
128
+ }
129
+ // Skill / Agent dispatches — show what's being dispatched.
130
+ if (name === "Skill" && input && typeof input.skill === "string") {
131
+ return input.skill;
132
+ }
133
+ if (name === "Agent") {
134
+ const desc = (input as { description?: string } | undefined)?.description;
135
+ if (typeof desc === "string" && desc) return desc;
136
+ const sub = (input as { subagent_type?: string } | undefined)
137
+ ?.subagent_type;
138
+ if (typeof sub === "string" && sub) return sub;
139
+ }
140
+ // MCP tools: the post-`__` segment is the most informative part.
141
+ if (name.startsWith("mcp__")) {
142
+ const parts = name.split("__");
143
+ const tail = parts[parts.length - 1] ?? name;
144
+ return tail;
145
+ }
146
+ // Default: a peek at the result body so the user can scan the call
147
+ // outcome without expanding every row.
148
+ return (result?.plaintext || "").split("\n")[0]?.slice(0, 100) ?? "";
149
+ }
150
+
151
+ /** Short display label for the tool name in the collapsed header. */
152
+ export function toolDisplayName(use: Message): string {
153
+ const name = String(
154
+ (use.content as { name?: unknown } | undefined)?.name ?? "tool",
155
+ );
156
+ if (name.startsWith("mcp__")) {
157
+ // mcp__plugin_ace_ace-gdrive__drive_create_file → "drive_create_file"
158
+ const parts = name.split("__");
159
+ return parts[parts.length - 1] ?? name;
160
+ }
161
+ return name;
162
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * protocol.ts — the canonical chat WebSocket protocol (WsAction / WsEvent) and
3
+ * the session/message/draft/participant shapes the socket carries.
4
+ *
5
+ * Ported from ace-web's `api/types.ws.ts`. The wire contract is IDENTICAL to
6
+ * ace's EXCEPT that **all message/draft ids are strings** (canopy sends string
7
+ * PKs) — see `apps/chat/serializers.py` + `apps/chat/consumers.py`.
8
+ *
9
+ * This module is dependency-free (types only) so a vitest run doesn't pull any
10
+ * DOM/runtime deps.
11
+ */
12
+
13
+ // ---------------------------------------------------------------------------
14
+ // Core enum aliases
15
+ // ---------------------------------------------------------------------------
16
+
17
+ export type MessageStatus = "pending" | "streaming" | "complete" | "error";
18
+ export type MessageRole =
19
+ | "user"
20
+ | "assistant"
21
+ | "system"
22
+ | "tool_use"
23
+ | "tool_result";
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Session + message shapes (the `session.state` snapshot payload)
27
+ // ---------------------------------------------------------------------------
28
+
29
+ export interface Message {
30
+ /** String PK (canopy sends `str(msg.pk)`), or a synthetic stream id. */
31
+ id: string;
32
+ turn_index: number;
33
+ role: MessageRole;
34
+ content: Record<string, unknown>;
35
+ plaintext: string;
36
+ status: MessageStatus;
37
+ error_detail: string | null;
38
+ started_at: string | null;
39
+ completed_at: string | null;
40
+ created_at: string;
41
+ }
42
+
43
+ export interface Draft {
44
+ /** String PK (canopy sends `str(draft.pk)`). */
45
+ id: string;
46
+ slot: "next" | "queued";
47
+ status: "open" | "sent" | "discarded";
48
+ body: string;
49
+ version: number;
50
+ last_editor: number;
51
+ last_edit_at: string;
52
+ }
53
+
54
+ export interface Participant {
55
+ user_id: number;
56
+ email: string;
57
+ display_name: string;
58
+ role: "owner" | "editor" | "viewer";
59
+ joined_at: string | null;
60
+ last_seen_at: string | null;
61
+ }
62
+
63
+ export interface SessionState {
64
+ messages: Message[];
65
+ active_draft: Draft | null;
66
+ participants: Participant[];
67
+ presence_user_ids: number[];
68
+ current_user_id: number;
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // WebSocket protocol
73
+ // ---------------------------------------------------------------------------
74
+
75
+ export type WsAction =
76
+ | { action: "chat.send"; data: Record<string, never> }
77
+ | { action: "chat.stop"; data: { message_id: string } }
78
+ | { action: "draft.update"; data: { version: number; body: string } }
79
+ | { action: "draft.take_over"; data: Record<string, never> }
80
+ | { action: "draft.discard"; data: Record<string, never> }
81
+ | { action: "presence.heartbeat"; data: Record<string, never> };
82
+
83
+ export type WsEvent =
84
+ | { event: "session.state"; data: SessionState }
85
+ | { event: "session.error"; data: { code: string; message: string; detail?: unknown } }
86
+ | { event: "session.title_updated"; data: { title: string } }
87
+ | { event: "chat.stream_start"; data: { message_id: string; turn_index: number } }
88
+ | { event: "chat.delta"; data: { message_id: string; text: string } }
89
+ | { event: "chat.tool_use"; data: { parent_message_id: string | null; tool_message_id: string; block: Record<string, unknown> } }
90
+ | { event: "chat.tool_result"; data: { parent_message_id: string | null; tool_message_id: string; block: Record<string, unknown> } }
91
+ | { event: "chat.stream_complete"; data: { message_id: string; plaintext: string } }
92
+ | { event: "chat.stream_error"; data: { message_id: string; detail: string } }
93
+ | { event: "chat.stream_cancelled"; data: { message_id: string | null; partial_len: number } }
94
+ | { event: "draft.updated"; data: Draft }
95
+ | { event: "draft.lock_changed"; data: { draft_id: string; holder_user_id: number | null; expires_at: number | null } }
96
+ | { event: "draft.committed"; data: { draft_id: string; user_message_id: string } }
97
+ | { event: "draft.discarded"; data: { draft_id: string } }
98
+ | { event: "presence.joined"; data: { user_id: number; email?: string; display_name?: string } }
99
+ | { event: "presence.left"; data: { user_id: number } };
@@ -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
+ })