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 +2 -2
- package/src/chat/MenuPrompt.tsx +58 -0
- package/src/chat/index.ts +2 -0
- package/src/chat/protocol.ts +24 -3
- package/src/chat/sessionReducer.test.ts +87 -0
- package/src/chat/sessionReducer.ts +24 -1
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "canopy-ui",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
|
-
"url": "git+https://github.com/
|
|
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";
|
package/src/chat/protocol.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|