canopy-ui 0.17.0 → 0.18.1
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 +1 -1
- package/src/chat/MessageItem.test.tsx +34 -0
- package/src/chat/MessageItem.tsx +28 -2
- package/src/chat/agui.ts +3 -0
- package/src/chat/protocol.ts +10 -3
- package/src/chat/sessionReducer.test.ts +9 -0
- package/src/chat/sessionReducer.ts +8 -3
- package/src/chat/turnStatus.test.ts +19 -0
- package/src/chat/turnStatus.ts +25 -0
package/package.json
CHANGED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
3
|
+
import { cleanup, render, screen } from "@testing-library/react";
|
|
4
|
+
|
|
5
|
+
import type { Message } from "./protocol";
|
|
6
|
+
import { MessageItem } from "./MessageItem";
|
|
7
|
+
|
|
8
|
+
function userRow(content: Record<string, unknown>): Message {
|
|
9
|
+
return {
|
|
10
|
+
id: "1", turn_index: 1, role: "user", content,
|
|
11
|
+
plaintext: "This session is being continued from a previous conversation…",
|
|
12
|
+
status: "complete", error_detail: null, started_at: null, completed_at: null,
|
|
13
|
+
created_at: "2026-10-17T12:35:14Z",
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
cleanup();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("MessageItem — a compaction summary", () => {
|
|
22
|
+
it("renders collapsed as context, not as a line the person typed", () => {
|
|
23
|
+
render(<MessageItem message={userRow({ record: { compact_summary: true } })} />);
|
|
24
|
+
const row = screen.getByTestId("compaction-summary-row");
|
|
25
|
+
expect(row.tagName).toBe("DETAILS");
|
|
26
|
+
expect(row.hasAttribute("open")).toBe(false);
|
|
27
|
+
expect(row.textContent).toContain("Context compacted");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("leaves an ordinary typed line as a bubble", () => {
|
|
31
|
+
render(<MessageItem message={userRow({ record: {} })} />);
|
|
32
|
+
expect(screen.queryByTestId("compaction-summary-row")).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
});
|
package/src/chat/MessageItem.tsx
CHANGED
|
@@ -42,6 +42,14 @@ function otherAuthorOf(
|
|
|
42
42
|
return isMine(message.author, currentUserId, currentContactId) ? null : message.author;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/** A `user` row Claude Code wrote, not a person: the summary it leaves when it
|
|
46
|
+
* compacts the conversation. The runner keeps the transcript record's own
|
|
47
|
+
* flags on the row as `content.record` (`canopy_transcript.record_flags`). */
|
|
48
|
+
function isCompactionSummary(message: Message): boolean {
|
|
49
|
+
const record = message.content?.record as Record<string, unknown> | undefined;
|
|
50
|
+
return message.role === "user" && Boolean(record?.compact_summary);
|
|
51
|
+
}
|
|
52
|
+
|
|
45
53
|
/** Count visible lines for the "▸ System context (N lines)" header. */
|
|
46
54
|
function countLines(text: string): number {
|
|
47
55
|
if (!text) return 0;
|
|
@@ -137,6 +145,20 @@ export function MessageItem({
|
|
|
137
145
|
/>
|
|
138
146
|
);
|
|
139
147
|
}
|
|
148
|
+
// Same treatment for a compaction summary: it sits on the human's side of the
|
|
149
|
+
// transcript, but nobody typed it — as a user bubble it read as the person
|
|
150
|
+
// pasting a wall of text.
|
|
151
|
+
if (isCompactionSummary(message)) {
|
|
152
|
+
return (
|
|
153
|
+
<SystemSeedRow
|
|
154
|
+
message={message}
|
|
155
|
+
forceOpen={forceToolOpen}
|
|
156
|
+
renderMarkdown={renderMarkdown}
|
|
157
|
+
label="Context compacted"
|
|
158
|
+
testId="compaction-summary-row"
|
|
159
|
+
/>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
140
162
|
|
|
141
163
|
const otherAuthor = otherAuthorOf(message, currentUserId, currentContactId);
|
|
142
164
|
// A teammate's line sits on the agent's side of the column, so it needs its
|
|
@@ -210,10 +232,14 @@ function SystemSeedRow({
|
|
|
210
232
|
message,
|
|
211
233
|
forceOpen,
|
|
212
234
|
renderMarkdown,
|
|
235
|
+
label = "System context",
|
|
236
|
+
testId = "system-seed-row",
|
|
213
237
|
}: {
|
|
214
238
|
message: Message;
|
|
215
239
|
forceOpen: boolean | undefined;
|
|
216
240
|
renderMarkdown: RenderMarkdown;
|
|
241
|
+
label?: string;
|
|
242
|
+
testId?: string;
|
|
217
243
|
}) {
|
|
218
244
|
const text = message.plaintext;
|
|
219
245
|
const lineCount = countLines(text);
|
|
@@ -221,13 +247,13 @@ function SystemSeedRow({
|
|
|
221
247
|
return (
|
|
222
248
|
<details
|
|
223
249
|
className="group my-2 mr-auto max-w-[80%] rounded-2xl border border-border bg-muted/40 px-3 py-1.5 text-sm"
|
|
224
|
-
data-testid=
|
|
250
|
+
data-testid={testId}
|
|
225
251
|
{...(openProp !== undefined ? { open: openProp } : {})}
|
|
226
252
|
>
|
|
227
253
|
<summary className="flex cursor-pointer items-center gap-1.5 text-muted-foreground hover:text-foreground select-none list-none [&::-webkit-details-marker]:hidden">
|
|
228
254
|
<ChevronRight className="h-3.5 w-3.5 transition-transform group-open:rotate-90" />
|
|
229
255
|
<span className="text-xs font-medium uppercase tracking-wide">
|
|
230
|
-
|
|
256
|
+
{label}
|
|
231
257
|
</span>
|
|
232
258
|
{lineCount > 0 && (
|
|
233
259
|
<span className="text-xs text-muted-foreground/70">
|
package/src/chat/agui.ts
CHANGED
|
@@ -122,6 +122,9 @@ export function fromAgui(frame: AguiFrame): WsEvent[] {
|
|
|
122
122
|
// canopy's only chance to say (spec 2026-09-26). Absent when the
|
|
123
123
|
// server sent none (an agent/scheduled/email turn's own send).
|
|
124
124
|
author: m.author as MessageAuthor | undefined,
|
|
125
|
+
// The transcript record's provenance — the `content.record` a
|
|
126
|
+
// history row carries — so a compaction summary is known live.
|
|
127
|
+
...(m.record ? { record: m.record as Record<string, unknown> } : {}),
|
|
125
128
|
},
|
|
126
129
|
},
|
|
127
130
|
];
|
package/src/chat/protocol.ts
CHANGED
|
@@ -222,13 +222,20 @@ export interface TurnStatus {
|
|
|
222
222
|
/** The agent is sitting on a dialog. A separate axis from `state`: a turn
|
|
223
223
|
* blocked on a question is still RUNNING as far as the turn is concerned. */
|
|
224
224
|
menu_pending: boolean;
|
|
225
|
-
/** Why a `failed` turn failed, in the runner's words. Null for every other
|
|
226
|
-
* state. Absent on an older server. */
|
|
227
225
|
/** For `queued_behind`: what it waits behind — `session` (the previous
|
|
228
226
|
* message in this conversation) or `agent` (another turn of the same
|
|
229
227
|
* agent). Absent on an older server. */
|
|
230
228
|
behind?: "session" | "agent" | null;
|
|
229
|
+
/** Why a `failed` turn failed, in the runner's words; or why a `cancelled`
|
|
230
|
+
* one was never run (an admission rule rejected it). Null otherwise, and
|
|
231
|
+
* for a turn someone stopped. Absent on an older server. */
|
|
231
232
|
detail?: string | null;
|
|
233
|
+
/** For `waiting_runner`: its runners are PAUSED by someone, not asleep.
|
|
234
|
+
* Absent on an older server. */
|
|
235
|
+
parked?: boolean;
|
|
236
|
+
/** For a `parked` turn: when the first of its runners resumes on its own
|
|
237
|
+
* (ISO). Null = only a person resuming one will run it. */
|
|
238
|
+
held_until?: string | null;
|
|
232
239
|
/** When a settled turn finished — lets the kit drop a failure the
|
|
233
240
|
* conversation has since moved past. Absent on an older server. */
|
|
234
241
|
finished_at?: string | null;
|
|
@@ -349,7 +356,7 @@ export type WsEvent =
|
|
|
349
356
|
// this row to the server's queued-turn projection, and a contact's or
|
|
350
357
|
// widget's own send rendered twice: once as their bubble, once as a "queued"
|
|
351
358
|
// placeholder for the same send.
|
|
352
|
-
| { event: "chat.user_message"; data: { message_id: string; turn_index: number; plaintext: string; author?: MessageAuthor | null; client_id?: string } }
|
|
359
|
+
| { event: "chat.user_message"; data: { message_id: string; turn_index: number; plaintext: string; author?: MessageAuthor | null; client_id?: string; record?: Record<string, unknown> } }
|
|
353
360
|
| { event: "chat.delta"; data: { message_id: string; text: string } }
|
|
354
361
|
// `turn_index` is the row's transcript ordinal — the same key the persisted
|
|
355
362
|
// Message carries, so a live tool row sorts into exactly the position it will
|
|
@@ -551,6 +551,15 @@ describe("sessionReducer — a web send arriving twice", () => {
|
|
|
551
551
|
expect(next.messages).toHaveLength(10)
|
|
552
552
|
})
|
|
553
553
|
|
|
554
|
+
it("keeps the record's provenance, so a compaction summary is known live", () => {
|
|
555
|
+
const next = sessionReducer(makeState(), {
|
|
556
|
+
event: "chat.user_message",
|
|
557
|
+
data: { message_id: "9", turn_index: 9, plaintext: "This session is being continued…",
|
|
558
|
+
record: { compact_summary: true } },
|
|
559
|
+
} as WsEvent)
|
|
560
|
+
expect(next.messages[0].content.record).toEqual({ compact_summary: true })
|
|
561
|
+
})
|
|
562
|
+
|
|
554
563
|
it("an empty-text frame never collapses onto another empty row", () => {
|
|
555
564
|
const seeded = makeState({
|
|
556
565
|
messages: [makeMessage({ id: "1", turn_index: 1, role: "user", plaintext: "" })],
|
|
@@ -201,6 +201,9 @@ export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState
|
|
|
201
201
|
// had it (an optimistic send knows its own author) and the
|
|
202
202
|
// frame is silent, but let the frame win otherwise.
|
|
203
203
|
author: frame.data.author ?? m.author ?? null,
|
|
204
|
+
...(frame.data.record
|
|
205
|
+
? { content: { ...m.content, record: frame.data.record } }
|
|
206
|
+
: {}),
|
|
204
207
|
// The agent read it, so a pending or unconfirmed row is sent.
|
|
205
208
|
...(m.role === "user" ? { status: "complete" as const, error_detail: null } : {}),
|
|
206
209
|
}
|
|
@@ -216,9 +219,11 @@ export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState
|
|
|
216
219
|
// `client_id` (when the caller has one — an HTTP send) lets
|
|
217
220
|
// `QueuedRows.hideClientIds` recognise the server's queued-turn
|
|
218
221
|
// projection as THIS row rather than a duplicate.
|
|
219
|
-
content:
|
|
220
|
-
|
|
221
|
-
|
|
222
|
+
content: {
|
|
223
|
+
text: frame.data.plaintext,
|
|
224
|
+
...(frame.data.client_id ? { client_id: frame.data.client_id } : {}),
|
|
225
|
+
...(frame.data.record ? { record: frame.data.record } : {}),
|
|
226
|
+
},
|
|
222
227
|
plaintext: frame.data.plaintext,
|
|
223
228
|
status: "complete",
|
|
224
229
|
error_detail: null,
|
|
@@ -113,6 +113,25 @@ describe("turnNotice", () => {
|
|
|
113
113
|
expect(n?.text).toContain("jj-mbp is offline");
|
|
114
114
|
});
|
|
115
115
|
|
|
116
|
+
it("says a paused box is paused, and until when it holds the turn", () => {
|
|
117
|
+
const held = turnNotice(status({
|
|
118
|
+
state: "waiting_runner", stuck: true, claimed_by: null, parked: true,
|
|
119
|
+
held_until: "2026-10-12T21:00:00Z",
|
|
120
|
+
}));
|
|
121
|
+
expect(held?.text).toContain("jj-mbp is paused until");
|
|
122
|
+
expect(held?.text).toContain("It runs then.");
|
|
123
|
+
const open = turnNotice(status({ state: "waiting_runner", stuck: true, claimed_by: null, parked: true }));
|
|
124
|
+
expect(open?.text).toContain("runs only when someone resumes it");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("says why a rejected turn was never run, and nothing for one somebody stopped", () => {
|
|
128
|
+
const rejected = turnNotice(status({
|
|
129
|
+
state: "cancelled", settled: true, detail: "jj-mbp was paused by Jonathan Jackson",
|
|
130
|
+
}));
|
|
131
|
+
expect(rejected).toMatchObject({ tone: "error", text: "Not run — jj-mbp was paused by Jonathan Jackson" });
|
|
132
|
+
expect(turnNotice(status({ state: "cancelled", settled: true, detail: null }))).toBeNull();
|
|
133
|
+
});
|
|
134
|
+
|
|
116
135
|
it("reads differently for the state waiting cannot fix", () => {
|
|
117
136
|
const n = turnNotice(status({ state: "unrouted", stuck: true, runners: [], claimed_by: null }));
|
|
118
137
|
// Not a warning you can wait out — an error somebody has to go and fix.
|
package/src/chat/turnStatus.ts
CHANGED
|
@@ -108,6 +108,14 @@ export interface TurnNotice {
|
|
|
108
108
|
* straight into the session makes messages but no Turn, so without this a
|
|
109
109
|
* day-old failure sat in red under a thread that was plainly working.
|
|
110
110
|
*/
|
|
111
|
+
/** A resume time as the reader's local "Oct 12, 3:00 PM". */
|
|
112
|
+
function formatWhen(iso: string): string {
|
|
113
|
+
const d = new Date(iso);
|
|
114
|
+
return Number.isNaN(d.getTime())
|
|
115
|
+
? iso
|
|
116
|
+
: d.toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
|
|
117
|
+
}
|
|
118
|
+
|
|
111
119
|
export function turnNotice(
|
|
112
120
|
status?: TurnStatus | null,
|
|
113
121
|
{ latestMessageAt }: { latestMessageAt?: string | null } = {},
|
|
@@ -124,6 +132,17 @@ export function turnNotice(
|
|
|
124
132
|
const need = status.requires?.length ? status.requires.map((r) => r.toUpperCase()).join(", ") : "";
|
|
125
133
|
switch (status.state) {
|
|
126
134
|
case "waiting_runner":
|
|
135
|
+
if (status.parked) {
|
|
136
|
+
return {
|
|
137
|
+
tone: "warn",
|
|
138
|
+
// Paused is somebody's decision, not a laptop lid: say whether it ends
|
|
139
|
+
// on its own.
|
|
140
|
+
text: status.held_until
|
|
141
|
+
? `Queued — ${runners || "its runner"} is paused until ${formatWhen(status.held_until)}. It runs then.`
|
|
142
|
+
: `Queued — ${runners || "its runner"} is paused, so this runs only when someone resumes it.`,
|
|
143
|
+
offer,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
127
146
|
return {
|
|
128
147
|
tone: "warn",
|
|
129
148
|
// Names the box, because the fix is physical: go and open it.
|
|
@@ -170,6 +189,12 @@ export function turnNotice(
|
|
|
170
189
|
text: status.detail ? `Could not finish — ${status.detail}` : "Could not finish.",
|
|
171
190
|
offer: null,
|
|
172
191
|
};
|
|
192
|
+
case "cancelled":
|
|
193
|
+
// Only a REJECTED turn carries a detail (an admission rule closed it before
|
|
194
|
+
// it ran); one somebody stopped needs no sentence.
|
|
195
|
+
return status.detail
|
|
196
|
+
? { tone: "error", text: `Not run — ${status.detail}`, offer: null }
|
|
197
|
+
: null;
|
|
173
198
|
case "missed":
|
|
174
199
|
return { tone: "warn", text: "Missed — nothing picked it up in time.", offer: null };
|
|
175
200
|
default:
|