canopy-ui 0.14.0 → 0.14.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/ChatPanel.tsx +3 -1
- package/src/chat/protocol.ts +6 -0
- package/src/chat/turnStatus.test.ts +49 -0
- package/src/chat/turnStatus.ts +21 -2
package/package.json
CHANGED
package/src/chat/ChatPanel.tsx
CHANGED
|
@@ -136,7 +136,9 @@ export function ChatPanel({
|
|
|
136
136
|
// underneath would state the same fact twice and put a dead-end restatement
|
|
137
137
|
// directly below the thing you can actually press. The fallback is for hosts
|
|
138
138
|
// with no banner of their own, which is exactly the embedded widget.
|
|
139
|
-
const
|
|
139
|
+
const latestMessageAt =
|
|
140
|
+
state.messages.length > 0 ? state.messages[state.messages.length - 1].created_at : null;
|
|
141
|
+
const notice = banner ? null : turnNotice(status, { latestMessageAt });
|
|
140
142
|
|
|
141
143
|
// Sticky-bottom scroll: dep changes on (a) new message arrival and (b)
|
|
142
144
|
// streaming text growth on the last message. length-only (cheap) instead
|
package/src/chat/protocol.ts
CHANGED
|
@@ -210,6 +210,12 @@ export interface TurnStatus {
|
|
|
210
210
|
/** The agent is sitting on a dialog. A separate axis from `state`: a turn
|
|
211
211
|
* blocked on a question is still RUNNING as far as the turn is concerned. */
|
|
212
212
|
menu_pending: boolean;
|
|
213
|
+
/** Why a `failed` turn failed, in the runner's words. Null for every other
|
|
214
|
+
* state. Absent on an older server. */
|
|
215
|
+
detail?: string | null;
|
|
216
|
+
/** When a settled turn finished — lets the kit drop a failure the
|
|
217
|
+
* conversation has since moved past. Absent on an older server. */
|
|
218
|
+
finished_at?: string | null;
|
|
213
219
|
/** Nothing more will happen without someone asking for it. */
|
|
214
220
|
settled: boolean;
|
|
215
221
|
/** Nothing is moving and only a person can change that. The one question a
|
|
@@ -136,4 +136,53 @@ describe("turnNotice", () => {
|
|
|
136
136
|
// An older client meeting a newer server must fall silent, not crash.
|
|
137
137
|
expect(turnNotice(status({ state: "some_future_state" }))).toBeNull();
|
|
138
138
|
});
|
|
139
|
+
|
|
140
|
+
it("says why a turn failed when the runner gave a reason", () => {
|
|
141
|
+
// 2026-09-27: a bare "Could not finish." hid a note that said exactly
|
|
142
|
+
// what to do.
|
|
143
|
+
const n = turnNotice(status({
|
|
144
|
+
state: "failed", settled: true,
|
|
145
|
+
detail: "Your message was not delivered: the emdash session has unsent text in its prompt.",
|
|
146
|
+
}));
|
|
147
|
+
expect(n?.tone).toBe("error");
|
|
148
|
+
expect(n?.text).toBe(
|
|
149
|
+
"Could not finish — Your message was not delivered: the emdash session has unsent text in its prompt.",
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("keeps the bare wording for a failure with no reason, or an older server", () => {
|
|
154
|
+
expect(turnNotice(status({ state: "failed", settled: true }))?.text).toBe("Could not finish.");
|
|
155
|
+
expect(turnNotice(status({ state: "failed", settled: true, detail: null }))?.text)
|
|
156
|
+
.toBe("Could not finish.");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("drops a settled failure once the conversation has moved past it", () => {
|
|
160
|
+
// Work typed straight into the session makes messages but no Turn, so the
|
|
161
|
+
// latest Turn can be a day-old failure under a thread that is fine.
|
|
162
|
+
const failed = status({
|
|
163
|
+
state: "failed", settled: true, finished_at: "2026-09-26T22:07:07Z",
|
|
164
|
+
});
|
|
165
|
+
expect(turnNotice(failed, { latestMessageAt: "2026-09-27T17:21:38Z" })).toBeNull();
|
|
166
|
+
expect(turnNotice(status({
|
|
167
|
+
state: "lost", settled: true, finished_at: "2026-09-26T22:07:07Z",
|
|
168
|
+
}), { latestMessageAt: "2026-09-27T17:21:38Z" })).toBeNull();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("still shows a failure that is the conversation's last word", () => {
|
|
172
|
+
const failed = status({
|
|
173
|
+
state: "failed", settled: true, finished_at: "2026-09-26T22:07:07Z",
|
|
174
|
+
});
|
|
175
|
+
expect(turnNotice(failed, { latestMessageAt: "2026-09-26T22:06:01Z" })?.tone).toBe("error");
|
|
176
|
+
expect(turnNotice(failed, { latestMessageAt: null })?.tone).toBe("error");
|
|
177
|
+
expect(turnNotice(failed)?.tone).toBe("error");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("never lets a newer message hide a turn that is not settled", () => {
|
|
181
|
+
// A live or stuck turn is still owed something, whatever else was said.
|
|
182
|
+
const n = turnNotice(
|
|
183
|
+
status({ state: "waiting_runner", stuck: true, finished_at: "2026-09-26T22:07:07Z" }),
|
|
184
|
+
{ latestMessageAt: "2026-09-27T17:21:38Z" },
|
|
185
|
+
);
|
|
186
|
+
expect(n?.tone).toBe("warn");
|
|
187
|
+
});
|
|
139
188
|
});
|
package/src/chat/turnStatus.ts
CHANGED
|
@@ -95,9 +95,21 @@ export interface TurnNotice {
|
|
|
95
95
|
* `blocked` deliberately returns null: the dialog itself is the notice, and
|
|
96
96
|
* the host renders it as a menu with buttons. A sentence beside it would say
|
|
97
97
|
* the same thing twice and compete with the thing you can actually press.
|
|
98
|
+
*
|
|
99
|
+
* `latestMessageAt` is the newest message on screen. A settled turn's notice
|
|
100
|
+
* is only true while that turn is the conversation's last word: work typed
|
|
101
|
+
* straight into the session makes messages but no Turn, so without this a
|
|
102
|
+
* day-old failure sat in red under a thread that was plainly working.
|
|
98
103
|
*/
|
|
99
|
-
export function turnNotice(
|
|
104
|
+
export function turnNotice(
|
|
105
|
+
status?: TurnStatus | null,
|
|
106
|
+
{ latestMessageAt }: { latestMessageAt?: string | null } = {},
|
|
107
|
+
): TurnNotice | null {
|
|
100
108
|
if (!status) return null;
|
|
109
|
+
if (status.settled && status.finished_at && latestMessageAt
|
|
110
|
+
&& Date.parse(latestMessageAt) > Date.parse(status.finished_at)) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
101
113
|
const offer = status.cloud_runner && status.cloud_runner_id
|
|
102
114
|
? { name: status.cloud_runner, id: status.cloud_runner_id }
|
|
103
115
|
: null;
|
|
@@ -139,7 +151,14 @@ export function turnNotice(status?: TurnStatus | null): TurnNotice | null {
|
|
|
139
151
|
offer,
|
|
140
152
|
};
|
|
141
153
|
case "failed":
|
|
142
|
-
|
|
154
|
+
// The reason, when the runner gave one: "failed" covers an undelivered
|
|
155
|
+
// message, a stop that did not take and a create that never rendered,
|
|
156
|
+
// and each has a different fix — which the note usually spells out.
|
|
157
|
+
return {
|
|
158
|
+
tone: "error",
|
|
159
|
+
text: status.detail ? `Could not finish — ${status.detail}` : "Could not finish.",
|
|
160
|
+
offer: null,
|
|
161
|
+
};
|
|
143
162
|
case "missed":
|
|
144
163
|
return { tone: "warn", text: "Missed — nothing picked it up in time.", offer: null };
|
|
145
164
|
default:
|