canopy-ui 0.17.0 → 0.18.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.17.0",
3
+ "version": "0.18.0",
4
4
  "license": "BSD-3-Clause",
5
5
  "type": "module",
6
6
  "repository": {
@@ -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;
@@ -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.
@@ -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: