canopy-ui 0.18.0 → 0.19.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canopy-ui",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "license": "BSD-3-Clause",
5
5
  "type": "module",
6
6
  "repository": {
@@ -1,4 +1,4 @@
1
- import { useMemo, type ReactNode } from "react";
1
+ import { useEffect, useMemo, useState, type ReactNode } from "react";
2
2
 
3
3
  import type { PeerDraft, SessionState, TypingVisibility } from "./protocol";
4
4
  import type { RenderMarkdown } from "./MessageItem";
@@ -145,7 +145,14 @@ export function ChatPanel({
145
145
  // with no banner of their own, which is exactly the embedded widget.
146
146
  const latestMessageAt =
147
147
  state.messages.length > 0 ? state.messages[state.messages.length - 1].created_at : null;
148
- const notice = banner ? null : turnNotice(status, { latestMessageAt });
148
+ // A clock only while a turn is working: the quiet notice is about time passing
149
+ // with NOTHING arriving, so no frame would ever re-render it.
150
+ const now = useNow(status?.state === "working" ? 30_000 : null);
151
+ // A reply streaming in right now is something coming back, however old its row.
152
+ const notice = banner ? null : turnNotice(status, {
153
+ latestMessageAt: inFlightMessage ? new Date(now).toISOString() : latestMessageAt,
154
+ now,
155
+ });
149
156
 
150
157
  // Sticky-bottom scroll: dep changes on (a) new message arrival and (b)
151
158
  // streaming text growth on the last message. length-only (cheap) instead
@@ -284,3 +291,16 @@ export function ChatPanel({
284
291
  </AppHostContext.Provider>
285
292
  );
286
293
  }
294
+
295
+
296
+ /** `Date.now()`, re-read every `everyMs` — or frozen when `everyMs` is null. */
297
+ function useNow(everyMs: number | null): number {
298
+ const [now, setNow] = useState(() => Date.now());
299
+ useEffect(() => {
300
+ if (everyMs == null) return;
301
+ setNow(Date.now());
302
+ const id = setInterval(() => setNow(Date.now()), everyMs);
303
+ return () => clearInterval(id);
304
+ }, [everyMs]);
305
+ return now;
306
+ }
@@ -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
+ });
@@ -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="system-seed-row"
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
- System context
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
  ];
@@ -244,6 +244,11 @@ export interface TurnStatus {
244
244
  * (some runner would take it without one) — so render it as the reason.
245
245
  * Absent on an older server. */
246
246
  requires?: string[];
247
+ /** For `working`: when its runner took it, and when anything last came back
248
+ * from it (reply text, a tool call — or the claim itself). ISO. Absent on an
249
+ * older server. */
250
+ claimed_at?: string | null;
251
+ last_progress_at?: string | null;
247
252
  /** Nothing more will happen without someone asking for it. */
248
253
  settled: boolean;
249
254
  /** Nothing is moving and only a person can change that. The one question a
@@ -356,7 +361,7 @@ export type WsEvent =
356
361
  // this row to the server's queued-turn projection, and a contact's or
357
362
  // widget's own send rendered twice: once as their bubble, once as a "queued"
358
363
  // placeholder for the same send.
359
- | { event: "chat.user_message"; data: { message_id: string; turn_index: number; plaintext: string; author?: MessageAuthor | null; client_id?: string } }
364
+ | { event: "chat.user_message"; data: { message_id: string; turn_index: number; plaintext: string; author?: MessageAuthor | null; client_id?: string; record?: Record<string, unknown> } }
360
365
  | { event: "chat.delta"; data: { message_id: string; text: string } }
361
366
  // `turn_index` is the row's transcript ordinal — the same key the persisted
362
367
  // 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: frame.data.client_id
220
- ? { text: frame.data.plaintext, client_id: frame.data.client_id }
221
- : { text: frame.data.plaintext },
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,
@@ -227,3 +227,45 @@ describe("turnNotice, when the conversation requires a runner flag", () => {
227
227
  expect(n?.text).toContain("ZDR");
228
228
  });
229
229
  });
230
+
231
+ describe("turnNotice, when a working turn goes quiet", () => {
232
+ // 2026-10-17: a box whose token was revoked took four turns and could report
233
+ // nothing back; each read "Thinking on cloud-ec2-2…" until its lease ran out.
234
+ const claimed = "2026-10-17T15:45:00Z";
235
+ const at = (min: number) => Date.parse(claimed) + min * 60_000;
236
+ const working = (over: Partial<TurnStatus> = {}) =>
237
+ status({ state: "working", claimed_by: "cloud-ec2-2", runners: ["cloud-ec2-2"],
238
+ claimed_at: claimed, last_progress_at: claimed, ...over });
239
+
240
+ it("says nothing while it is still fresh", () => {
241
+ expect(turnNotice(working(), { now: at(4) })).toBeNull();
242
+ });
243
+
244
+ it("names the box and how long ago it took the turn when nothing came back", () => {
245
+ expect(turnNotice(working(), { now: at(6) })).toEqual({
246
+ tone: "warn",
247
+ text: "cloud-ec2-2 took this 6m ago and nothing has come back from it since.",
248
+ offer: null,
249
+ });
250
+ });
251
+
252
+ it("counts from the last thing that came back once something has", () => {
253
+ const st = working({ last_progress_at: "2026-10-17T15:50:00Z" });
254
+ expect(turnNotice(st, { now: at(9) })).toBeNull();
255
+ expect(turnNotice(st, { now: at(12) })?.text).toBe("Nothing new from cloud-ec2-2 for 7m.");
256
+ });
257
+
258
+ it("a message that streamed onto the screen counts as something coming back", () => {
259
+ expect(turnNotice(working(), { now: at(8), latestMessageAt: "2026-10-17T15:50:00Z" })).toBeNull();
260
+ });
261
+
262
+ it("never speaks over a dialog the agent is waiting on, or for an older server", () => {
263
+ expect(turnNotice(working({ menu_pending: true }), { now: at(30) })).toBeNull();
264
+ expect(turnNotice(working({ claimed_at: undefined }), { now: at(30) })).toBeNull();
265
+ });
266
+
267
+ it("reads hours as hours", () => {
268
+ expect(turnNotice(working(), { now: at(65) })?.text).toBe(
269
+ "cloud-ec2-2 took this 1h 5m ago and nothing has come back from it since.");
270
+ });
271
+ });
@@ -116,11 +116,55 @@ function formatWhen(iso: string): string {
116
116
  : d.toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
117
117
  }
118
118
 
119
+ /** How long a working turn may say nothing before the kit says so. Long enough
120
+ * that a model thinking through a hard step is not called out; short enough
121
+ * that a box which took the turn and went silent is not "Thinking…" for the
122
+ * rest of its lease. */
123
+ export const QUIET_AFTER_MS = 5 * 60 * 1000;
124
+
125
+ /** "6m" / "1h 5m" — a wait, not a timestamp. */
126
+ function waited(ms: number): string {
127
+ const mins = Math.max(1, Math.round(ms / 60000));
128
+ if (mins < 60) return `${mins}m`;
129
+ const h = Math.floor(mins / 60);
130
+ return mins % 60 ? `${h}h ${mins % 60}m` : `${h}h`;
131
+ }
132
+
133
+ /**
134
+ * A working turn nothing has come back from in a while — in facts, not a verdict:
135
+ * who took it, and for how long it has been quiet. On 2026-10-17 a box whose
136
+ * token had been revoked claimed four turns and could report nothing; each one
137
+ * read "Thinking on cloud-ec2-2…" until its lease ran out.
138
+ *
139
+ * "Back" is the newer of the server's `last_progress_at` (a ledger event) and the
140
+ * newest message on screen, so a transcript row that streamed in counts too.
141
+ */
142
+ function quietNotice(status: TurnStatus, latestMessageAt: string | null | undefined,
143
+ now: number): TurnNotice | null {
144
+ if (status.state !== "working" || status.menu_pending || !status.claimed_at) return null;
145
+ const claimed = Date.parse(status.claimed_at);
146
+ if (Number.isNaN(claimed)) return null;
147
+ let back = status.last_progress_at ? Date.parse(status.last_progress_at) : claimed;
148
+ const seen = latestMessageAt ? Date.parse(latestMessageAt) : NaN;
149
+ if (!Number.isNaN(seen) && seen > back) back = seen;
150
+ if (now - back < QUIET_AFTER_MS) return null;
151
+ const who = status.claimed_by ?? "its runner";
152
+ return {
153
+ tone: "warn",
154
+ text: back <= claimed
155
+ ? `${who} took this ${waited(now - claimed)} ago and nothing has come back from it since.`
156
+ : `Nothing new from ${who} for ${waited(now - back)}.`,
157
+ offer: null,
158
+ };
159
+ }
160
+
119
161
  export function turnNotice(
120
162
  status?: TurnStatus | null,
121
- { latestMessageAt }: { latestMessageAt?: string | null } = {},
163
+ { latestMessageAt, now = Date.now() }: { latestMessageAt?: string | null; now?: number } = {},
122
164
  ): TurnNotice | null {
123
165
  if (!status) return null;
166
+ const quiet = quietNotice(status, latestMessageAt, now);
167
+ if (quiet) return quiet;
124
168
  if (status.settled && status.finished_at && latestMessageAt
125
169
  && Date.parse(latestMessageAt) > Date.parse(status.finished_at)) {
126
170
  return null;