canopy-ui 0.6.3 → 0.7.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 +1 -1
- package/src/chat/ChatPanel.pending.test.tsx +136 -0
- package/src/chat/ChatPanel.tsx +48 -2
- package/src/chat/MenuPrompt.test.tsx +24 -0
- package/src/chat/MenuPrompt.tsx +291 -18
- package/src/chat/MessageItem.tsx +21 -8
- package/src/chat/MessageList.tsx +32 -3
- package/src/chat/PlacementBanner.test.tsx +75 -0
- package/src/chat/PlacementBanner.tsx +52 -15
- package/src/chat/PresenceChips.tsx +113 -19
- package/src/chat/SendBox.test.tsx +151 -0
- package/src/chat/SendBox.tsx +139 -10
- package/src/chat/drafts.test.ts +120 -0
- package/src/chat/drafts.ts +134 -0
- package/src/chat/index.ts +11 -0
- package/src/chat/pairToolMessages.ts +1 -1
- package/src/chat/protocol.ts +87 -7
- package/src/chat/sessionReducer.test.ts +102 -1
- package/src/chat/sessionReducer.ts +50 -5
|
@@ -617,7 +617,7 @@ describe("the dialog an agent is blocked on", () => {
|
|
|
617
617
|
expect(after.menu).toBeUndefined();
|
|
618
618
|
});
|
|
619
619
|
|
|
620
|
-
it("is cleared by a later activity frame that
|
|
620
|
+
it("is cleared by a later activity frame that says the agent is not waiting", () => {
|
|
621
621
|
const blocked = sessionReducer(makeState(), {
|
|
622
622
|
event: "session.activity",
|
|
623
623
|
data: { state: "blocked", menu: MENU },
|
|
@@ -625,4 +625,105 @@ describe("the dialog an agent is blocked on", () => {
|
|
|
625
625
|
const idle = sessionReducer(blocked, { event: "session.activity", data: { state: "idle" } });
|
|
626
626
|
expect(idle.menu).toBeUndefined();
|
|
627
627
|
});
|
|
628
|
+
|
|
629
|
+
it("survives a blocked frame that carries no menu", () => {
|
|
630
|
+
// The hook path reports `blocked` WITHOUT a menu — it deliberately does not
|
|
631
|
+
// read the screen (#510: doing so stole emdash's focus). Treating that as a
|
|
632
|
+
// retraction would erase a menu the snapshot or the session report had
|
|
633
|
+
// already supplied, which is every menu we now have.
|
|
634
|
+
const blocked = sessionReducer(makeState(), {
|
|
635
|
+
event: "session.activity",
|
|
636
|
+
data: { state: "blocked", menu: MENU },
|
|
637
|
+
});
|
|
638
|
+
const again = sessionReducer(blocked, { event: "session.activity", data: { state: "blocked" } });
|
|
639
|
+
expect(again.menu?.question).toBe("Do you want to proceed?");
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
it("arrives in the connect snapshot, not only in a live frame", () => {
|
|
643
|
+
// THE fix for "when I click on the session I don't see the menu". Activity
|
|
644
|
+
// frames are view-only and reach a client only if it was connected when
|
|
645
|
+
// they fired; you open the session precisely because it stopped.
|
|
646
|
+
const state = sessionReducer(makeState(), {
|
|
647
|
+
event: "session.state",
|
|
648
|
+
data: makeState({ activity: "blocked", menu: MENU }),
|
|
649
|
+
});
|
|
650
|
+
expect(state.menu?.question).toBe("Do you want to proceed?");
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
it("appears while you are already watching, via its own frame", () => {
|
|
654
|
+
const state = sessionReducer(makeState(), {
|
|
655
|
+
event: "session.menu",
|
|
656
|
+
data: { menu: MENU },
|
|
657
|
+
});
|
|
658
|
+
expect(state.menu?.options).toHaveLength(2);
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
it("is retracted when somebody answers at the keyboard", () => {
|
|
662
|
+
// `menu: null` from the session report. Buttons that outlive their dialog
|
|
663
|
+
// press a number into what is now an ordinary prompt, where the agent reads
|
|
664
|
+
// a bare "1" as an instruction.
|
|
665
|
+
const blocked = sessionReducer(makeState(), {
|
|
666
|
+
event: "session.menu",
|
|
667
|
+
data: { menu: MENU },
|
|
668
|
+
});
|
|
669
|
+
const cleared = sessionReducer(blocked, { event: "session.menu", data: { menu: null } });
|
|
670
|
+
expect(cleared.menu).toBeUndefined();
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
it("keeps an option's description, which is often the whole difference", () => {
|
|
674
|
+
const state = sessionReducer(makeState(), {
|
|
675
|
+
event: "session.menu",
|
|
676
|
+
data: {
|
|
677
|
+
menu: {
|
|
678
|
+
question: "How should the run proceed?",
|
|
679
|
+
options: [
|
|
680
|
+
{ number: 1, label: "Proceed to Phase 4", description: "nothing reaches a real LLO" },
|
|
681
|
+
{ number: 2, label: "Stop the run here", description: "end at the Phase 3 boundary" },
|
|
682
|
+
],
|
|
683
|
+
},
|
|
684
|
+
},
|
|
685
|
+
});
|
|
686
|
+
expect(state.menu?.options[0].description).toContain("real LLO");
|
|
687
|
+
});
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
describe("session.stop — whether the stop actually landed", () => {
|
|
691
|
+
const base = (over: Partial<SessionState> = {}): SessionState => ({
|
|
692
|
+
messages: [],
|
|
693
|
+
active_draft: null,
|
|
694
|
+
participants: [],
|
|
695
|
+
presence_user_ids: [],
|
|
696
|
+
current_user_id: 1,
|
|
697
|
+
...over,
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
it("records a requested stop without claiming it worked", () => {
|
|
701
|
+
const out = sessionReducer(base(), {
|
|
702
|
+
event: "session.stop",
|
|
703
|
+
data: { state: "requested" },
|
|
704
|
+
} as WsEvent);
|
|
705
|
+
expect(out.stopState).toBe("requested");
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
it("keeps the agent WORKING when the stop failed", () => {
|
|
709
|
+
// The whole point of a separate axis. A failed stop leaves the agent running,
|
|
710
|
+
// and both facts have to survive together — collapsing them into `activity`
|
|
711
|
+
// loses whichever one loses the race, and it was always the stop.
|
|
712
|
+
const out = sessionReducer(base({ activity: "working" }), {
|
|
713
|
+
event: "session.stop",
|
|
714
|
+
data: { state: "failed" },
|
|
715
|
+
} as WsEvent);
|
|
716
|
+
expect(out.stopState).toBe("failed");
|
|
717
|
+
expect(out.activity).toBe("working");
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
it("a new turn clears a stale stop outcome", () => {
|
|
721
|
+
// "your stop did not take" pinned over fresh work is a warning about
|
|
722
|
+
// something the human has already moved on from.
|
|
723
|
+
const out = sessionReducer(base({ stopState: "failed" }), {
|
|
724
|
+
event: "chat.stream_start",
|
|
725
|
+
data: { message_id: "m1" },
|
|
726
|
+
} as WsEvent);
|
|
727
|
+
expect(out.stopState).toBeUndefined();
|
|
728
|
+
});
|
|
628
729
|
});
|
|
@@ -28,6 +28,12 @@ const UNBLOCKING_FRAMES = new Set([
|
|
|
28
28
|
]);
|
|
29
29
|
|
|
30
30
|
export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState {
|
|
31
|
+
if (frame.event === "chat.stream_start" || frame.event === "draft.committed") {
|
|
32
|
+
// A new turn is starting, so the previous turn's stop outcome is history —
|
|
33
|
+
// leaving "your stop did not take" pinned over fresh work would be a stale
|
|
34
|
+
// warning about something the human has already moved on from.
|
|
35
|
+
prev = { ...prev, stopState: undefined };
|
|
36
|
+
}
|
|
31
37
|
if (prev.activity === "blocked" && UNBLOCKING_FRAMES.has(frame.event)) {
|
|
32
38
|
// Dropping the menu with the state matters as much as the state itself —
|
|
33
39
|
// the dialog is gone, and buttons that answer a gone dialog send a stray
|
|
@@ -71,10 +77,33 @@ export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState
|
|
|
71
77
|
}
|
|
72
78
|
|
|
73
79
|
case "session.activity":
|
|
74
|
-
//
|
|
75
|
-
// worse than none, because its buttons would answer a prompt
|
|
76
|
-
// longer on screen.
|
|
77
|
-
|
|
80
|
+
// A frame that says the agent is NOT waiting retracts the menu — a stale
|
|
81
|
+
// dialog is worse than none, because its buttons would answer a prompt
|
|
82
|
+
// that is no longer on screen.
|
|
83
|
+
//
|
|
84
|
+
// A `blocked` frame that carries no menu does NOT, and that asymmetry is
|
|
85
|
+
// load-bearing. The hook path reports `blocked` without one on purpose
|
|
86
|
+
// (#510 — reading the screen stole emdash's focus), so treating a bare
|
|
87
|
+
// `blocked` as a retraction would erase every menu the snapshot and the
|
|
88
|
+
// session report supply, which is now all of them.
|
|
89
|
+
if (frame.data.state !== "blocked") {
|
|
90
|
+
return { ...prev, activity: frame.data.state, menu: undefined };
|
|
91
|
+
}
|
|
92
|
+
return { ...prev, activity: "blocked", menu: frame.data.menu ?? prev.menu };
|
|
93
|
+
|
|
94
|
+
case "session.stop":
|
|
95
|
+
// Independent of `activity` on purpose. A stop that FAILED leaves the agent
|
|
96
|
+
// working, and both facts have to be sayable at once: "it is still going"
|
|
97
|
+
// AND "your stop did not take". Collapsing them loses whichever one loses
|
|
98
|
+
// the race, and it was always the second — which is how a dead Stop button
|
|
99
|
+
// stayed invisible.
|
|
100
|
+
return { ...prev, stopState: frame.data.state };
|
|
101
|
+
|
|
102
|
+
case "session.menu":
|
|
103
|
+
// The authoritative producer: the session report re-derives the dialog
|
|
104
|
+
// from the transcript every ~10s and pushes only the edges. `null` is the
|
|
105
|
+
// retraction, and has to be honoured — somebody answered at the keyboard.
|
|
106
|
+
return { ...prev, menu: frame.data.menu ?? undefined };
|
|
78
107
|
|
|
79
108
|
case "chat.user_message": {
|
|
80
109
|
// Someone typed into emdash, OR into this page. Both reach here, and that
|
|
@@ -341,7 +370,23 @@ export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState
|
|
|
341
370
|
case "presence.joined": {
|
|
342
371
|
const ids = new Set(prev.presence_user_ids);
|
|
343
372
|
ids.add(frame.data.user_id);
|
|
344
|
-
|
|
373
|
+
// Adopt the joiner into `participants` too. The presence ROW renders
|
|
374
|
+
// participants filtered by presence, so an id with no matching
|
|
375
|
+
// participant is invisible — which is what made a first-time joiner
|
|
376
|
+
// unseeable by everyone already in the room until they reloaded.
|
|
377
|
+
// `participant` is optional (an older server may not send it); without
|
|
378
|
+
// it this degrades to exactly the previous behaviour rather than
|
|
379
|
+
// inventing a nameless entry.
|
|
380
|
+
const joined = frame.data.participant;
|
|
381
|
+
const known = joined
|
|
382
|
+
? prev.participants.some((p) => p.user_id === joined.user_id)
|
|
383
|
+
: true;
|
|
384
|
+
return {
|
|
385
|
+
...prev,
|
|
386
|
+
presence_user_ids: [...ids],
|
|
387
|
+
participants:
|
|
388
|
+
joined && !known ? [...prev.participants, joined] : prev.participants,
|
|
389
|
+
};
|
|
345
390
|
}
|
|
346
391
|
|
|
347
392
|
case "presence.left":
|