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,208 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import type { Message } from "./protocol";
|
|
4
|
+
import {
|
|
5
|
+
deriveToolStatus,
|
|
6
|
+
pairToolMessages,
|
|
7
|
+
toolDisplayName,
|
|
8
|
+
toolPreview,
|
|
9
|
+
} from "./pairToolMessages";
|
|
10
|
+
|
|
11
|
+
let seq = 0;
|
|
12
|
+
function msg(
|
|
13
|
+
partial: Partial<Message> & { id: string; role: Message["role"] },
|
|
14
|
+
): Message {
|
|
15
|
+
return {
|
|
16
|
+
turn_index: seq++,
|
|
17
|
+
content: {},
|
|
18
|
+
plaintext: "",
|
|
19
|
+
status: "complete",
|
|
20
|
+
error_detail: null,
|
|
21
|
+
started_at: null,
|
|
22
|
+
completed_at: null,
|
|
23
|
+
created_at: "2026-05-13T00:00:00Z",
|
|
24
|
+
...partial,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("pairToolMessages", () => {
|
|
29
|
+
it("pairs consecutive tool_use + tool_result by id/tool_use_id", () => {
|
|
30
|
+
const rows = pairToolMessages([
|
|
31
|
+
msg({ id: "1", role: "user", plaintext: "hi" }),
|
|
32
|
+
msg({
|
|
33
|
+
id: "2",
|
|
34
|
+
role: "tool_use",
|
|
35
|
+
content: { id: "t1", name: "Bash", input: { command: "ls" } },
|
|
36
|
+
}),
|
|
37
|
+
msg({
|
|
38
|
+
id: "3",
|
|
39
|
+
role: "tool_result",
|
|
40
|
+
content: { tool_use_id: "t1" },
|
|
41
|
+
plaintext: "out",
|
|
42
|
+
}),
|
|
43
|
+
msg({ id: "4", role: "assistant", plaintext: "done" }),
|
|
44
|
+
]);
|
|
45
|
+
expect(rows).toHaveLength(3);
|
|
46
|
+
expect(rows[0].kind).toBe("message");
|
|
47
|
+
expect(rows[1].kind).toBe("tool_pair");
|
|
48
|
+
if (rows[1].kind === "tool_pair") {
|
|
49
|
+
expect(rows[1].use.id).toBe("2");
|
|
50
|
+
expect(rows[1].result?.id).toBe("3");
|
|
51
|
+
}
|
|
52
|
+
expect(rows[2].kind).toBe("message");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("pairs tool_use with a later non-adjacent tool_result", () => {
|
|
56
|
+
const rows = pairToolMessages([
|
|
57
|
+
msg({ id: "1", role: "tool_use", content: { id: "a", name: "X" } }),
|
|
58
|
+
msg({ id: "2", role: "tool_use", content: { id: "b", name: "Y" } }),
|
|
59
|
+
msg({ id: "3", role: "tool_result", content: { tool_use_id: "a" } }),
|
|
60
|
+
msg({ id: "4", role: "tool_result", content: { tool_use_id: "b" } }),
|
|
61
|
+
]);
|
|
62
|
+
expect(rows).toHaveLength(2);
|
|
63
|
+
expect(rows[0].kind).toBe("tool_pair");
|
|
64
|
+
expect(rows[1].kind).toBe("tool_pair");
|
|
65
|
+
if (rows[0].kind === "tool_pair" && rows[1].kind === "tool_pair") {
|
|
66
|
+
expect(rows[0].use.id).toBe("1");
|
|
67
|
+
expect(rows[0].result?.id).toBe("3");
|
|
68
|
+
expect(rows[1].use.id).toBe("2");
|
|
69
|
+
expect(rows[1].result?.id).toBe("4");
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("leaves an in-flight tool_use with null result so UI can show pending", () => {
|
|
74
|
+
const rows = pairToolMessages([
|
|
75
|
+
msg({ id: "1", role: "tool_use", content: { id: "t1", name: "Bash" } }),
|
|
76
|
+
]);
|
|
77
|
+
expect(rows).toHaveLength(1);
|
|
78
|
+
expect(rows[0].kind).toBe("tool_pair");
|
|
79
|
+
if (rows[0].kind === "tool_pair") {
|
|
80
|
+
expect(rows[0].result).toBeNull();
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("falls back to standalone when a tool_result has no matching use", () => {
|
|
85
|
+
const rows = pairToolMessages([
|
|
86
|
+
msg({ id: "1", role: "tool_result", content: { tool_use_id: "ghost" } }),
|
|
87
|
+
]);
|
|
88
|
+
expect(rows).toHaveLength(1);
|
|
89
|
+
expect(rows[0].kind).toBe("message");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("doesn't lose tool calls in long sessions", () => {
|
|
93
|
+
const msgs: Message[] = [];
|
|
94
|
+
for (let i = 0; i < 44; i++) {
|
|
95
|
+
msgs.push(
|
|
96
|
+
msg({
|
|
97
|
+
id: `${i * 2 + 1}`,
|
|
98
|
+
role: "tool_use",
|
|
99
|
+
content: { id: `t${i}`, name: "Bash" },
|
|
100
|
+
}),
|
|
101
|
+
);
|
|
102
|
+
if (i < 42) {
|
|
103
|
+
msgs.push(
|
|
104
|
+
msg({
|
|
105
|
+
id: `${i * 2 + 2}`,
|
|
106
|
+
role: "tool_result",
|
|
107
|
+
content: { tool_use_id: `t${i}` },
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const rows = pairToolMessages(msgs);
|
|
113
|
+
expect(rows).toHaveLength(44);
|
|
114
|
+
expect(rows.filter((r) => r.kind === "tool_pair")).toHaveLength(44);
|
|
115
|
+
// First 42 paired, last 2 pending.
|
|
116
|
+
const pending = rows.filter(
|
|
117
|
+
(r) => r.kind === "tool_pair" && r.result === null,
|
|
118
|
+
);
|
|
119
|
+
expect(pending).toHaveLength(2);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("deriveToolStatus", () => {
|
|
124
|
+
it("returns pending when result is missing", () => {
|
|
125
|
+
const use = msg({ id: "1", role: "tool_use" });
|
|
126
|
+
expect(deriveToolStatus(use, null).kind).toBe("pending");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("returns error when result is in error status", () => {
|
|
130
|
+
const use = msg({ id: "1", role: "tool_use" });
|
|
131
|
+
const result = msg({ id: "2", role: "tool_result", status: "error" });
|
|
132
|
+
expect(deriveToolStatus(use, result).kind).toBe("error");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("sniffs error-like result content", () => {
|
|
136
|
+
const use = msg({ id: "1", role: "tool_use" });
|
|
137
|
+
const result = msg({
|
|
138
|
+
id: "2",
|
|
139
|
+
role: "tool_result",
|
|
140
|
+
plaintext: "Error: file not found",
|
|
141
|
+
});
|
|
142
|
+
expect(deriveToolStatus(use, result).kind).toBe("error");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("returns success on a clean result", () => {
|
|
146
|
+
const use = msg({ id: "1", role: "tool_use" });
|
|
147
|
+
const result = msg({ id: "2", role: "tool_result", plaintext: "ok" });
|
|
148
|
+
expect(deriveToolStatus(use, result).kind).toBe("success");
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("toolPreview / toolDisplayName", () => {
|
|
153
|
+
it("shows the first line of a Bash command", () => {
|
|
154
|
+
const use = msg({
|
|
155
|
+
id: "1",
|
|
156
|
+
role: "tool_use",
|
|
157
|
+
content: {
|
|
158
|
+
name: "Bash",
|
|
159
|
+
input: { command: "ls -la /tmp\n# more after newline" },
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
expect(toolPreview(use, null)).toBe("ls -la /tmp");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("shows file_path for Read/Write/Edit", () => {
|
|
166
|
+
for (const name of ["Read", "Write", "Edit"]) {
|
|
167
|
+
const use = msg({
|
|
168
|
+
id: "1",
|
|
169
|
+
role: "tool_use",
|
|
170
|
+
content: { name, input: { file_path: "/a/b.txt" } },
|
|
171
|
+
});
|
|
172
|
+
expect(toolPreview(use, null)).toBe("/a/b.txt");
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("shows the todo count for TodoWrite", () => {
|
|
177
|
+
const use = msg({
|
|
178
|
+
id: "1",
|
|
179
|
+
role: "tool_use",
|
|
180
|
+
content: {
|
|
181
|
+
name: "TodoWrite",
|
|
182
|
+
input: { todos: [{}, {}, {}] },
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
expect(toolPreview(use, null)).toBe("3 todos");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("shows the skill name for Skill", () => {
|
|
189
|
+
const use = msg({
|
|
190
|
+
id: "1",
|
|
191
|
+
role: "tool_use",
|
|
192
|
+
content: { name: "Skill", input: { skill: "ace:run" } },
|
|
193
|
+
});
|
|
194
|
+
expect(toolPreview(use, null)).toBe("ace:run");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("strips the mcp prefix for MCP tools", () => {
|
|
198
|
+
const use = msg({
|
|
199
|
+
id: "1",
|
|
200
|
+
role: "tool_use",
|
|
201
|
+
content: {
|
|
202
|
+
name: "mcp__plugin_ace_ace-gdrive__drive_create_file",
|
|
203
|
+
input: {},
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
expect(toolDisplayName(use)).toBe("drive_create_file");
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -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 } };
|