canopy-ui 0.18.1 → 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 +1 -1
- package/src/chat/ChatPanel.tsx +22 -2
- package/src/chat/protocol.ts +5 -0
- package/src/chat/turnStatus.test.ts +42 -0
- package/src/chat/turnStatus.ts +45 -1
package/package.json
CHANGED
package/src/chat/ChatPanel.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/chat/protocol.ts
CHANGED
|
@@ -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
|
|
@@ -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
|
+
});
|
package/src/chat/turnStatus.ts
CHANGED
|
@@ -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;
|