canopy-ui 0.14.0 → 0.14.2

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.14.0",
3
+ "version": "0.14.2",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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 notice = banner ? null : turnNotice(status);
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
@@ -210,6 +210,17 @@ 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;
219
+ /** What the conversation's host requires of the runner (e.g. `zdr`). Only
220
+ * set while queued, and only when the requirement is what blocks the turn
221
+ * (some runner would take it without one) — so render it as the reason.
222
+ * Absent on an older server. */
223
+ requires?: string[];
213
224
  /** Nothing more will happen without someone asking for it. */
214
225
  settled: boolean;
215
226
  /** Nothing is moving and only a person can change that. The one question a
@@ -136,4 +136,65 @@ 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
+ });
188
+ });
189
+
190
+ describe("turnNotice, when the conversation requires a runner flag", () => {
191
+ it("names the missing requirement when unrouted", () => {
192
+ const n = turnNotice(status({ state: "unrouted", stuck: true, runners: [], claimed_by: null, agent_slug: "ace", requires: ["zdr"] }));
193
+ expect(n?.text).toContain("ZDR runner");
194
+ });
195
+
196
+ it("names it when its runners are offline", () => {
197
+ const n = turnNotice(status({ state: "waiting_runner", stuck: true, runners: ["cloud-1"], claimed_by: null, requires: ["zdr"] }));
198
+ expect(n?.text).toContain("ZDR");
199
+ });
139
200
  });
@@ -95,21 +95,35 @@ 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(status?: TurnStatus | null): TurnNotice | null {
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;
104
116
  const runners = status.runners.join(", ");
117
+ const need = status.requires?.length ? status.requires.map((r) => r.toUpperCase()).join(", ") : "";
105
118
  switch (status.state) {
106
119
  case "waiting_runner":
107
120
  return {
108
121
  tone: "warn",
109
122
  // Names the box, because the fix is physical: go and open it.
110
- text: runners
123
+ text: (runners
111
124
  ? `Queued — ${runners} is offline, so nothing is working on this yet. It runs when the runner is back.`
112
- : "Queued — its runner is offline, so nothing is working on this yet.",
125
+ : "Queued — its runner is offline, so nothing is working on this yet.")
126
+ + (need ? ` It needs a ${need} runner.` : ""),
113
127
  offer,
114
128
  };
115
129
  case "unrouted":
@@ -117,7 +131,9 @@ export function turnNotice(status?: TurnStatus | null): TurnNotice | null {
117
131
  tone: "error",
118
132
  // The one state waiting cannot fix, and it has to READ differently
119
133
  // from the one waiting can.
120
- text: status.agent_slug
134
+ text: need
135
+ ? `Queued — this conversation needs a ${need} runner, and none of ${status.agent_slug ?? "its agent"}'s runners is declared ${need}.`
136
+ : status.agent_slug
121
137
  ? `Queued, but no runner is set up to run ${status.agent_slug} — nothing will pick this up until its routing is fixed.`
122
138
  : "Queued, but no runner is set up to run this — nothing will pick it up until its routing is fixed.",
123
139
  offer,
@@ -139,7 +155,14 @@ export function turnNotice(status?: TurnStatus | null): TurnNotice | null {
139
155
  offer,
140
156
  };
141
157
  case "failed":
142
- return { tone: "error", text: "Could not finish.", offer: null };
158
+ // The reason, when the runner gave one: "failed" covers an undelivered
159
+ // message, a stop that did not take and a create that never rendered,
160
+ // and each has a different fix — which the note usually spells out.
161
+ return {
162
+ tone: "error",
163
+ text: status.detail ? `Could not finish — ${status.detail}` : "Could not finish.",
164
+ offer: null,
165
+ };
143
166
  case "missed":
144
167
  return { tone: "warn", text: "Missed — nothing picked it up in time.", offer: null };
145
168
  default: