canopy-ui 0.6.1 → 0.6.3

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,10 +1,10 @@
1
1
  {
2
2
  "name": "canopy-ui",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
7
- "url": "git+https://github.com/jjackson/canopy-web.git",
7
+ "url": "git+https://github.com/dimagi-internal/canopy-web.git",
8
8
  "directory": "frontend/packages/canopy-ui"
9
9
  },
10
10
  "exports": {
@@ -0,0 +1,58 @@
1
+ import type { SessionMenu } from "./protocol";
2
+
3
+ export interface MenuPromptProps {
4
+ menu: SessionMenu;
5
+ busy?: boolean;
6
+ error?: string;
7
+ onAnswer: (option: number | null) => void;
8
+ }
9
+
10
+ /**
11
+ * The dialog a blocked agent is waiting on, answerable from here.
12
+ *
13
+ * Shows the SUBJECT, not just the question: "Do you want to proceed?" tells you
14
+ * nothing away from the keyboard — the command is the whole decision, so it is
15
+ * rendered verbatim and monospaced rather than summarised.
16
+ *
17
+ * Refusing is always offered separately from the numbered options. Every dialog
18
+ * accepts Escape, and it is the only answer that stays correct if the dialog on
19
+ * screen is not the one rendered here.
20
+ */
21
+ export function MenuPrompt({ menu, busy = false, error, onAnswer }: MenuPromptProps) {
22
+ return (
23
+ <div className="rounded-md border border-warning/30 bg-warning/10 px-3 py-2.5 text-sm">
24
+ <div className="flex items-center gap-1.5 font-medium text-warning">
25
+ <span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-warning" />
26
+ {menu.title || "Waiting on you"}
27
+ </div>
28
+ {menu.body ? (
29
+ <pre className="mt-1.5 max-h-32 overflow-auto whitespace-pre-wrap break-all rounded bg-muted px-2 py-1.5 font-mono text-[12px] text-foreground-secondary">
30
+ {menu.body}
31
+ </pre>
32
+ ) : null}
33
+ <div className="mt-2 text-foreground">{menu.question}</div>
34
+ <div className="mt-2 flex flex-wrap gap-1.5">
35
+ {menu.options.map((option) => (
36
+ <button
37
+ key={option.number}
38
+ type="button"
39
+ disabled={busy}
40
+ onClick={() => onAnswer(option.number)}
41
+ className="rounded border border-input bg-card px-2 py-1 text-[13px] text-foreground hover:bg-muted disabled:opacity-50"
42
+ >
43
+ {option.label}
44
+ </button>
45
+ ))}
46
+ <button
47
+ type="button"
48
+ disabled={busy}
49
+ onClick={() => onAnswer(null)}
50
+ className="rounded px-2 py-1 text-[13px] text-muted-foreground hover:text-foreground disabled:opacity-50"
51
+ >
52
+ Cancel (Esc)
53
+ </button>
54
+ </div>
55
+ {error ? <div className="mt-1.5 text-[12px] text-destructive">{error}</div> : null}
56
+ </div>
57
+ );
58
+ }
package/src/chat/index.ts CHANGED
@@ -11,6 +11,7 @@ export type {
11
11
  MessageStatus,
12
12
  Draft,
13
13
  Participant,
14
+ SessionMenu,
14
15
  SessionState,
15
16
  WsAction,
16
17
  WsEvent,
@@ -43,6 +44,7 @@ export {
43
44
 
44
45
  // Presentational components
45
46
  export { ChatPanel, type ChatPanelProps } from "./ChatPanel";
47
+ export { MenuPrompt, type MenuPromptProps } from "./MenuPrompt";
46
48
  export { MessageList } from "./MessageList";
47
49
  export { MessageItem, type RenderMarkdown } from "./MessageItem";
48
50
  export { ToolCallPair } from "./ToolCallPair";
@@ -60,12 +60,33 @@ export interface Participant {
60
60
  last_seen_at: string | null;
61
61
  }
62
62
 
63
+ /** A dialog an agent is blocked on, read off its terminal.
64
+ *
65
+ * `title` and `body` are what makes it answerable away from the keyboard:
66
+ * "Do you want to proceed?" tells you nothing without the command it means. */
67
+ export interface SessionMenu {
68
+ question: string;
69
+ title?: string;
70
+ body?: string;
71
+ selected?: number | null;
72
+ options: { number: number; label: string }[];
73
+ }
74
+
63
75
  export interface SessionState {
64
76
  messages: Message[];
65
77
  /** Live agent activity, from the runner's turn-boundary hooks. Undefined when
66
78
  * no hook has reported yet — the caller then falls back to the server's
67
- * coarser `running` flag. */
68
- activity?: "working" | "idle";
79
+ * coarser `running` flag.
80
+ *
81
+ * "blocked" means the agent wants a human: a permission prompt, or an idle
82
+ * wait for input. It is deliberately coarse — a hook observer cannot tell
83
+ * those apart — but it is the difference between "still thinking, wait" and
84
+ * "it is waiting on YOU", which previously rendered identically. */
85
+ activity?: "working" | "idle" | "blocked";
86
+ /** The dialog the agent is waiting on, when one could be read off its screen.
87
+ * Absent when the agent is not blocked, or when the runner has no way to look
88
+ * (no CDP) — "blocked" still arrives, it just has no buttons. */
89
+ menu?: SessionMenu;
69
90
  active_draft: Draft | null;
70
91
  participants: Participant[];
71
92
  presence_user_ids: number[];
@@ -91,7 +112,7 @@ export type WsEvent =
91
112
  | { event: "chat.stream_start"; data: { message_id: string; turn_index: number } }
92
113
  // The agent started or finished a turn. Distinct from tool events: it fires
93
114
  // while Claude is THINKING, before any content exists to show.
94
- | { event: "session.activity"; data: { state: "working" | "idle" } }
115
+ | { event: "session.activity"; data: { state: "working" | "idle" | "blocked"; menu?: SessionMenu } }
95
116
  // A human typed into emdash rather than into this page. No client echoed it,
96
117
  // so this is the only way it reaches the browser before a reload.
97
118
  | { event: "chat.user_message"; data: { message_id: string; turn_index: number; plaintext: string } }
@@ -539,3 +539,90 @@ describe("sessionReducer — a web send arriving twice", () => {
539
539
  expect(next.messages).toHaveLength(2)
540
540
  })
541
541
  })
542
+
543
+ describe("blocked — the agent is waiting on YOU", () => {
544
+ it("renders as its own state, not as working", () => {
545
+ const state = sessionReducer(makeState(), {
546
+ event: "session.activity",
547
+ data: { state: "blocked" },
548
+ });
549
+ expect(state.activity).toBe("blocked");
550
+ });
551
+
552
+ it("clears once the agent produces a row again", () => {
553
+ // Notification fires on the way INTO a wait and nothing fires on the way
554
+ // out — approving a permission prompt emits no hook at all. A row is the
555
+ // only proof the wait ended, so without this the chip says "needs you" for
556
+ // the rest of the turn, long after you answered.
557
+ const blocked = sessionReducer(makeState(), {
558
+ event: "session.activity",
559
+ data: { state: "blocked" },
560
+ });
561
+ const after = sessionReducer(blocked, {
562
+ event: "chat.tool_use",
563
+ data: {
564
+ parent_message_id: null,
565
+ tool_message_id: "m1",
566
+ turn_index: 3,
567
+ block: { id: "toolu_1", name: "Bash" },
568
+ },
569
+ });
570
+ expect(after.activity).toBe("working");
571
+ });
572
+
573
+ it("is not cleared by traffic that proves nothing about the agent", () => {
574
+ // A teammate typing, or presence churn, says nothing about whether the
575
+ // agent is still waiting for an answer.
576
+ const blocked = sessionReducer(makeState(), {
577
+ event: "session.activity",
578
+ data: { state: "blocked" },
579
+ });
580
+ const after = sessionReducer(blocked, {
581
+ event: "presence.joined",
582
+ data: { user_id: 2, display_name: "Someone" },
583
+ });
584
+ expect(after.activity).toBe("blocked");
585
+ });
586
+ });
587
+
588
+ describe("the dialog an agent is blocked on", () => {
589
+ const MENU = {
590
+ question: "Do you want to proceed?",
591
+ title: "Bash command",
592
+ body: "rm target.txt",
593
+ options: [{ number: 1, label: "Yes" }, { number: 2, label: "No" }],
594
+ };
595
+
596
+ it("is carried with the blocked state", () => {
597
+ const state = sessionReducer(makeState(), {
598
+ event: "session.activity",
599
+ data: { state: "blocked", menu: MENU },
600
+ });
601
+ expect(state.menu?.options).toHaveLength(2);
602
+ expect(state.menu?.body).toBe("rm target.txt");
603
+ });
604
+
605
+ it("is dropped when the agent starts producing again", () => {
606
+ // Buttons that answer a dialog no longer on screen send a stray keystroke
607
+ // into the prompt — worse than showing nothing.
608
+ const blocked = sessionReducer(makeState(), {
609
+ event: "session.activity",
610
+ data: { state: "blocked", menu: MENU },
611
+ });
612
+ const after = sessionReducer(blocked, {
613
+ event: "chat.tool_use",
614
+ data: { parent_message_id: null, tool_message_id: "m1", turn_index: 3, block: { id: "t1" } },
615
+ });
616
+ expect(after.activity).toBe("working");
617
+ expect(after.menu).toBeUndefined();
618
+ });
619
+
620
+ it("is cleared by a later activity frame that has no menu", () => {
621
+ const blocked = sessionReducer(makeState(), {
622
+ event: "session.activity",
623
+ data: { state: "blocked", menu: MENU },
624
+ });
625
+ const idle = sessionReducer(blocked, { event: "session.activity", data: { state: "idle" } });
626
+ expect(idle.menu).toBeUndefined();
627
+ });
628
+ });
@@ -13,7 +13,27 @@ import type { Draft, Message, SessionState, WsEvent } from "./protocol";
13
13
  // `draft.committed` carries only `user_message_id` (no assistant id to
14
14
  // pre-insert), so the assistant row is created lazily when its first stream
15
15
  // frame arrives.
16
+ /** Frames that prove the agent is producing again.
17
+ *
18
+ * `Notification` fires on the way IN to a wait and NOTHING fires on the way
19
+ * out — approving a permission prompt emits no hook at all. So without this,
20
+ * a session shows "needs you" for the rest of the turn, long after you
21
+ * answered. Any row the agent emits afterwards is the proof the hook cannot
22
+ * give. */
23
+ const UNBLOCKING_FRAMES = new Set([
24
+ "chat.stream_start",
25
+ "chat.delta",
26
+ "chat.tool_use",
27
+ "chat.tool_result",
28
+ ]);
29
+
16
30
  export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState {
31
+ if (prev.activity === "blocked" && UNBLOCKING_FRAMES.has(frame.event)) {
32
+ // Dropping the menu with the state matters as much as the state itself —
33
+ // the dialog is gone, and buttons that answer a gone dialog send a stray
34
+ // keystroke into the prompt.
35
+ prev = { ...prev, activity: "working", menu: undefined };
36
+ }
17
37
  switch (frame.event) {
18
38
  case "session.state":
19
39
  return frame.data;
@@ -51,7 +71,10 @@ export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState
51
71
  }
52
72
 
53
73
  case "session.activity":
54
- return { ...prev, activity: frame.data.state };
74
+ // The menu is cleared unless this frame carries one: a stale dialog is
75
+ // worse than none, because its buttons would answer a prompt that is no
76
+ // longer on screen.
77
+ return { ...prev, activity: frame.data.state, menu: frame.data.menu };
55
78
 
56
79
  case "chat.user_message": {
57
80
  // Someone typed into emdash, OR into this page. Both reach here, and that