canopy-ui 0.3.0 → 0.6.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,238 @@
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, id-first with an order-based fallback: a ``tool_use`` row is
8
+ * paired with the FIRST subsequent ``tool_result`` whose ``content.tool_use_id``
9
+ * matches the ``tool_use``'s ``content.id``, when both carry one. Correlation
10
+ * ids are what make this unambiguous — with **parallel** tool calls (routine
11
+ * for Claude) or two calls sharing a name, a flat tool_start/tool_end stream
12
+ * has no other way to tell which result belongs to which call.
13
+ *
14
+ * Not every producer stamps ids yet: events already in the ledger predate
15
+ * this field, and some runners (see `packages/canopy_runner`) don't emit it
16
+ * until updated separately. When a ``tool_result`` carries no id at all, it
17
+ * falls back to the FIRST still-open ``tool_use`` that *also* has no id
18
+ * (oldest-first, FIFO) — today's pre-correlation pairing heuristic. That
19
+ * fallback queue is tracked separately from the id map, so an id-tagged
20
+ * stream and a legacy no-id stream can be interleaved (e.g. older turns in
21
+ * the same session predating the id, followed by newer ones that have it)
22
+ * without cross-pairing into each other.
23
+ *
24
+ * Unpaired ``tool_result`` rows (an id that matches no pending ``tool_use``,
25
+ * or no id and nothing left in the no-id fallback queue — shouldn't happen
26
+ * in practice, but defend) fall through as standalone messages so we never
27
+ * silently drop content.
28
+ */
29
+ export type ChatRow =
30
+ | { kind: "message"; message: Message; key: string }
31
+ | { kind: "tool_pair"; use: Message; result: Message | null; key: string };
32
+
33
+ function toolUseId(message: Message): string | null {
34
+ const content = message.content as Record<string, unknown> | undefined;
35
+ const id = content?.id;
36
+ return typeof id === "string" && id !== "" ? id : null;
37
+ }
38
+
39
+ function toolResultId(message: Message): string | null {
40
+ const content = message.content as Record<string, unknown> | undefined;
41
+ const id = content?.tool_use_id;
42
+ return typeof id === "string" && id !== "" ? id : null;
43
+ }
44
+
45
+ /**
46
+ * A pending live row (`status: "pending"` from PreToolUse, no ordinal) is a
47
+ * PLACEHOLDER. It is meant to be replaced when its result arrives — but if that
48
+ * never lands (the turn ended, the runner stopped streaming, a hook was dropped),
49
+ * it strands a row rendering "running…" forever, which reads as an agent stuck
50
+ * mid-call. Observed live 2026-07-27: one orphan row per turn, and it vanished on
51
+ * reload because the durable transcript never contained it.
52
+ *
53
+ * A pending row is therefore dropped once ANY later row exists — later activity
54
+ * is proof that whatever it was waiting on is no longer in flight. It survives
55
+ * only while it is genuinely the newest thing in the session, which is exactly
56
+ * when "running…" is true.
57
+ */
58
+ function dropStaleLiveRows(messages: Message[]): Message[] {
59
+ const lastIndex = messages.length - 1;
60
+ return messages.filter((m, i) => {
61
+ if (m.role !== "tool_use") return true;
62
+ const content = m.content as Record<string, unknown> | undefined;
63
+ if (content?.status !== "pending") return true;
64
+ const id = typeof content.id === "string" ? content.id : null;
65
+ // Its result arrived — the pair will render normally, keep it.
66
+ if (
67
+ id &&
68
+ messages.some(
69
+ (o) =>
70
+ o.role === "tool_result" &&
71
+ (o.content as Record<string, unknown> | undefined)?.tool_use_id === id,
72
+ )
73
+ ) {
74
+ return true;
75
+ }
76
+ // Still the newest row: genuinely in flight.
77
+ return i === lastIndex;
78
+ });
79
+ }
80
+
81
+ export function pairToolMessages(messages: Message[]): ChatRow[] {
82
+ messages = dropStaleLiveRows(messages);
83
+ const rows: ChatRow[] = [];
84
+ // Map of tool_use_id → index into rows[] for that pair. Lets a tool_result
85
+ // arriving later in the stream slot itself into the existing pair row.
86
+ const pendingByToolId = new Map<string, number>();
87
+ // FIFO fallback queue of row indices for tool_use rows with NO id — the
88
+ // pre-correlation pairing heuristic, kept alive for producers/ledger rows
89
+ // that predate tool_use_id.
90
+ const pendingNoId: number[] = [];
91
+
92
+ for (const m of messages) {
93
+ if (m.role === "tool_use") {
94
+ const id = toolUseId(m);
95
+ const row: ChatRow = {
96
+ kind: "tool_pair",
97
+ use: m,
98
+ result: null,
99
+ key: `pair-${m.id}`,
100
+ };
101
+ rows.push(row);
102
+ const idx = rows.length - 1;
103
+ if (id !== null) {
104
+ pendingByToolId.set(id, idx);
105
+ } else {
106
+ pendingNoId.push(idx);
107
+ }
108
+ continue;
109
+ }
110
+ if (m.role === "tool_result") {
111
+ const id = toolResultId(m);
112
+ if (id !== null) {
113
+ const idx = pendingByToolId.get(id);
114
+ if (idx !== undefined) {
115
+ const row = rows[idx];
116
+ if (row.kind === "tool_pair") {
117
+ rows[idx] = { ...row, result: m };
118
+ pendingByToolId.delete(id);
119
+ continue;
120
+ }
121
+ }
122
+ // An id that matches no pending id-tagged use — a genuine ghost,
123
+ // never absorbed by the no-id fallback queue (that queue is for
124
+ // results that carry no id of their own, not for stray ids).
125
+ rows.push({ kind: "message", message: m, key: `msg-${m.id}` });
126
+ continue;
127
+ }
128
+ // No id on the result at all — fall back to the oldest still-open
129
+ // no-id tool_use (order-based pairing, same as before correlation
130
+ // ids existed).
131
+ let paired = false;
132
+ while (pendingNoId.length > 0) {
133
+ const idx = pendingNoId.shift() as number;
134
+ const row = rows[idx];
135
+ if (row.kind === "tool_pair" && row.result === null) {
136
+ rows[idx] = { ...row, result: m };
137
+ paired = true;
138
+ break;
139
+ }
140
+ }
141
+ if (!paired) {
142
+ // Nothing left in the fallback queue to pair with — standalone so
143
+ // content isn't silently dropped.
144
+ rows.push({ kind: "message", message: m, key: `msg-${m.id}` });
145
+ }
146
+ continue;
147
+ }
148
+ rows.push({ kind: "message", message: m, key: `msg-${m.id}` });
149
+ }
150
+ return rows;
151
+ }
152
+
153
+ export interface ToolCallStatus {
154
+ kind: "success" | "error" | "pending";
155
+ label: string;
156
+ }
157
+
158
+ /** Derive a status badge for a paired tool row. */
159
+ export function deriveToolStatus(
160
+ use: Message,
161
+ result: Message | null,
162
+ ): ToolCallStatus {
163
+ if (result === null) {
164
+ return { kind: "pending", label: "running…" };
165
+ }
166
+ if (result.status === "error" || use.status === "error") {
167
+ return { kind: "error", label: "error" };
168
+ }
169
+ // Some MCP servers signal failure in the result content rather than
170
+ // setting an HTTP-style status — sniff for the common "Error" / "error"
171
+ // prefixes so the badge reflects reality without server changes.
172
+ const head = (result.plaintext || "").trim().slice(0, 80).toLowerCase();
173
+ if (head.startsWith("error") || head.startsWith("traceback")) {
174
+ return { kind: "error", label: "error" };
175
+ }
176
+ return { kind: "success", label: "ok" };
177
+ }
178
+
179
+ /** One-line summary for the collapsed tool-call header. */
180
+ export function toolPreview(use: Message, result: Message | null): string {
181
+ const name = String(
182
+ (use.content as { name?: unknown } | undefined)?.name ?? "tool",
183
+ );
184
+ // Bash → command text; everything else → first 80 chars of result body.
185
+ const input = (use.content as { input?: Record<string, unknown> } | undefined)
186
+ ?.input;
187
+ if (name === "Bash" && input && typeof input.command === "string") {
188
+ return input.command.trim().split("\n")[0]?.slice(0, 100) ?? "";
189
+ }
190
+ if (name === "Write" && input && typeof input.file_path === "string") {
191
+ return input.file_path;
192
+ }
193
+ if (name === "Read" && input && typeof input.file_path === "string") {
194
+ return input.file_path;
195
+ }
196
+ if (name === "Edit" && input && typeof input.file_path === "string") {
197
+ return input.file_path;
198
+ }
199
+ if (name === "TodoWrite") {
200
+ const todos = (input as { todos?: unknown[] } | undefined)?.todos;
201
+ if (Array.isArray(todos)) {
202
+ return `${todos.length} todo${todos.length === 1 ? "" : "s"}`;
203
+ }
204
+ }
205
+ // Skill / Agent dispatches — show what's being dispatched.
206
+ if (name === "Skill" && input && typeof input.skill === "string") {
207
+ return input.skill;
208
+ }
209
+ if (name === "Agent") {
210
+ const desc = (input as { description?: string } | undefined)?.description;
211
+ if (typeof desc === "string" && desc) return desc;
212
+ const sub = (input as { subagent_type?: string } | undefined)
213
+ ?.subagent_type;
214
+ if (typeof sub === "string" && sub) return sub;
215
+ }
216
+ // MCP tools: the post-`__` segment is the most informative part.
217
+ if (name.startsWith("mcp__")) {
218
+ const parts = name.split("__");
219
+ const tail = parts[parts.length - 1] ?? name;
220
+ return tail;
221
+ }
222
+ // Default: a peek at the result body so the user can scan the call
223
+ // outcome without expanding every row.
224
+ return (result?.plaintext || "").split("\n")[0]?.slice(0, 100) ?? "";
225
+ }
226
+
227
+ /** Short display label for the tool name in the collapsed header. */
228
+ export function toolDisplayName(use: Message): string {
229
+ const name = String(
230
+ (use.content as { name?: unknown } | undefined)?.name ?? "tool",
231
+ );
232
+ if (name.startsWith("mcp__")) {
233
+ // mcp__plugin_ace_ace-gdrive__drive_create_file → "drive_create_file"
234
+ const parts = name.split("__");
235
+ return parts[parts.length - 1] ?? name;
236
+ }
237
+ return name;
238
+ }
@@ -0,0 +1,112 @@
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
+ /** Live agent activity, from the runner's turn-boundary hooks. Undefined when
66
+ * no hook has reported yet — the caller then falls back to the server's
67
+ * coarser `running` flag. */
68
+ activity?: "working" | "idle";
69
+ active_draft: Draft | null;
70
+ participants: Participant[];
71
+ presence_user_ids: number[];
72
+ current_user_id: number;
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // WebSocket protocol
77
+ // ---------------------------------------------------------------------------
78
+
79
+ export type WsAction =
80
+ | { action: "chat.send"; data: Record<string, never> }
81
+ | { action: "chat.stop"; data: { message_id: string } }
82
+ | { action: "draft.update"; data: { version: number; body: string } }
83
+ | { action: "draft.take_over"; data: Record<string, never> }
84
+ | { action: "draft.discard"; data: Record<string, never> }
85
+ | { action: "presence.heartbeat"; data: Record<string, never> };
86
+
87
+ export type WsEvent =
88
+ | { event: "session.state"; data: SessionState }
89
+ | { event: "session.error"; data: { code: string; message: string; detail?: unknown } }
90
+ | { event: "session.title_updated"; data: { title: string } }
91
+ | { event: "chat.stream_start"; data: { message_id: string; turn_index: number } }
92
+ // The agent started or finished a turn. Distinct from tool events: it fires
93
+ // while Claude is THINKING, before any content exists to show.
94
+ | { event: "session.activity"; data: { state: "working" | "idle" } }
95
+ // A human typed into emdash rather than into this page. No client echoed it,
96
+ // so this is the only way it reaches the browser before a reload.
97
+ | { event: "chat.user_message"; data: { message_id: string; turn_index: number; plaintext: string } }
98
+ | { event: "chat.delta"; data: { message_id: string; text: string } }
99
+ // `turn_index` is the row's transcript ordinal — the same key the persisted
100
+ // Message carries, so a live tool row sorts into exactly the position it will
101
+ // occupy after a reload. Optional: an older server omits it.
102
+ | { event: "chat.tool_use"; data: { parent_message_id: string | null; tool_message_id: string; turn_index?: number; block: Record<string, unknown> } }
103
+ | { event: "chat.tool_result"; data: { parent_message_id: string | null; tool_message_id: string; turn_index?: number; block: Record<string, unknown> } }
104
+ | { event: "chat.stream_complete"; data: { message_id: string; plaintext: string } }
105
+ | { event: "chat.stream_error"; data: { message_id: string; detail: string } }
106
+ | { event: "chat.stream_cancelled"; data: { message_id: string | null; partial_len: number } }
107
+ | { event: "draft.updated"; data: Draft }
108
+ | { event: "draft.lock_changed"; data: { draft_id: string; holder_user_id: number | null; expires_at: number | null } }
109
+ | { event: "draft.committed"; data: { draft_id: string; user_message_id: string } }
110
+ | { event: "draft.discarded"; data: { draft_id: string } }
111
+ | { event: "presence.joined"; data: { user_id: number; email?: string; display_name?: string } }
112
+ | { event: "presence.left"; data: { user_id: number } };