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.
- package/package.json +6 -2
- package/src/chat/ChatPanel.tsx +155 -0
- package/src/chat/ConnectionStatus.tsx +30 -0
- package/src/chat/MessageItem.tsx +198 -0
- package/src/chat/MessageList.tsx +145 -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.test.tsx +359 -0
- package/src/chat/SendBox.tsx +306 -0
- package/src/chat/ToolCallPair.tsx +86 -0
- package/src/chat/drafts.test.ts +90 -0
- package/src/chat/drafts.ts +37 -0
- package/src/chat/groupToolRuns.test.ts +81 -0
- package/src/chat/groupToolRuns.ts +82 -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 +360 -0
- package/src/chat/pairToolMessages.ts +238 -0
- package/src/chat/protocol.ts +112 -0
- package/src/chat/sessionReducer.test.ts +541 -0
- package/src/chat/sessionReducer.ts +368 -0
- package/src/chat/useSessionSocket.ts +332 -0
- package/src/chat/useStickyBottom.ts +77 -0
- package/src/presence/PresenceBadge.test.tsx +58 -0
- package/src/presence/PresenceBadge.tsx +105 -0
- package/src/presence/avatar.test.ts +42 -0
- package/src/presence/avatar.ts +36 -0
- package/src/presence/index.ts +4 -0
- package/src/presence/pageKey.test.ts +53 -0
- package/src/presence/pageKey.ts +37 -0
- package/src/presence/usePresence.test.ts +130 -0
- package/src/presence/usePresence.ts +170 -0
|
@@ -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 } };
|