canopy-ui 0.6.3 → 0.8.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/agui.fixture.json +235 -0
- package/src/chat/agui.test.ts +298 -0
- package/src/chat/agui.ts +282 -0
- package/src/chat/drafts.test.ts +120 -0
- package/src/chat/drafts.ts +134 -0
- package/src/chat/index.ts +18 -0
- package/src/chat/pairToolMessages.ts +1 -1
- package/src/chat/protocol.ts +87 -7
- package/src/chat/restMessage.test.ts +56 -0
- package/src/chat/restMessage.ts +54 -0
- package/src/chat/sessionReducer.test.ts +102 -1
- package/src/chat/sessionReducer.ts +50 -5
- package/src/chat/useSessionSocket.ts +74 -3
- package/src/shell/WorkbenchNavItem.tsx +3 -1
- package/src/ui/button.tsx +5 -0
- package/src/ui/input.tsx +3 -1
- package/src/ui/tabs.tsx +11 -2
|
@@ -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":
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
|
|
3
|
+
import { fromAgui, resetAguiState } from "./agui";
|
|
3
4
|
import type { Message, SessionState, WsEvent } from "./protocol";
|
|
4
5
|
import { shouldSyncDraftLive } from "./drafts";
|
|
5
6
|
import { prependHistory } from "./history";
|
|
@@ -32,6 +33,29 @@ export interface UseSessionSocketOptions {
|
|
|
32
33
|
* has no opinion on what to do with it.
|
|
33
34
|
*/
|
|
34
35
|
onTitleUpdated?: () => void;
|
|
36
|
+
/**
|
|
37
|
+
* A frame the kit does not understand.
|
|
38
|
+
*
|
|
39
|
+
* The kit stays agnostic: canopy grew `session.page_action` (an agent asking
|
|
40
|
+
* the embedded page to do something) and ace-web will grow its own. Teaching
|
|
41
|
+
* the reducer about each would make a shared kit carry one app's vocabulary.
|
|
42
|
+
* Same shape as `onTitleUpdated` — handed over, no opinion taken.
|
|
43
|
+
*/
|
|
44
|
+
onUnknownEvent?: (frame: WsEvent) => void;
|
|
45
|
+
/**
|
|
46
|
+
* Which vocabulary to ask the server for.
|
|
47
|
+
*
|
|
48
|
+
* `"canopy"` (the default) is this kit's own frames, unchanged — the reason
|
|
49
|
+
* an existing consumer, including ace-web installing `canopy-ui` from npm,
|
|
50
|
+
* notices nothing. `"ag-ui"` asks the server to project the same conversation
|
|
51
|
+
* into AG-UI and translates it back here, so the reducer never learns a
|
|
52
|
+
* second vocabulary and there is one behaviour to test rather than two.
|
|
53
|
+
*
|
|
54
|
+
* Opting in buys interoperability, not features: a canopy frame and its
|
|
55
|
+
* AG-UI projection reduce to identical state (`agui.test.ts` asserts exactly
|
|
56
|
+
* that against a fixture the server generates).
|
|
57
|
+
*/
|
|
58
|
+
protocol?: "canopy" | "ag-ui";
|
|
35
59
|
}
|
|
36
60
|
|
|
37
61
|
export interface UseSessionSocketResult {
|
|
@@ -51,10 +75,27 @@ export interface UseSessionSocketResult {
|
|
|
51
75
|
lastError: string | null;
|
|
52
76
|
}
|
|
53
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Frames `sessionReducer` understands. Anything else is handed to
|
|
80
|
+
* `onUnknownEvent` rather than dropped — the reducer ignores what it does not
|
|
81
|
+
* recognise, which silently swallows an app-specific frame and leaves the
|
|
82
|
+
* container wondering why its feature never fires.
|
|
83
|
+
*
|
|
84
|
+
* Keep in step with sessionReducer's own switch.
|
|
85
|
+
*/
|
|
86
|
+
const KNOWN_EVENTS = new Set([
|
|
87
|
+
"chat.delta", "chat.stream_cancelled", "chat.stream_complete",
|
|
88
|
+
"chat.stream_error", "chat.stream_start", "chat.tool_result",
|
|
89
|
+
"chat.tool_use", "chat.user_message", "session.activity", "session.error",
|
|
90
|
+
"session.menu", "session.state", "session.stop", "session.title_updated",
|
|
91
|
+
]);
|
|
92
|
+
|
|
54
93
|
export function useSessionSocket({
|
|
55
94
|
sessionId,
|
|
56
95
|
wsUrl,
|
|
57
96
|
onTitleUpdated,
|
|
97
|
+
onUnknownEvent,
|
|
98
|
+
protocol = "canopy",
|
|
58
99
|
}: UseSessionSocketOptions): UseSessionSocketResult {
|
|
59
100
|
const [state, setState] = useState<SessionState>(INITIAL_STATE);
|
|
60
101
|
const [connected, setConnected] = useState(false);
|
|
@@ -73,6 +114,13 @@ export function useSessionSocket({
|
|
|
73
114
|
const pendingDraftBodyRef = useRef<string | null>(null);
|
|
74
115
|
const closedByUserRef = useRef(false);
|
|
75
116
|
const onTitleUpdatedRef = useRef(onTitleUpdated);
|
|
117
|
+
const onUnknownEventRef = useRef(onUnknownEvent);
|
|
118
|
+
// A ref, like the callbacks above: `connect` is a stable callback with empty
|
|
119
|
+
// deps, so reading the prop directly would pin whatever it was on first
|
|
120
|
+
// render — and a socket that reconnects would silently drop back to the other
|
|
121
|
+
// vocabulary mid-session.
|
|
122
|
+
const protocolRef = useRef(protocol);
|
|
123
|
+
protocolRef.current = protocol;
|
|
76
124
|
// Control frames that must not be lost across a reconnect (currently
|
|
77
125
|
// only chat.stop). The WS-world analogue of an abortable chat transport.
|
|
78
126
|
const pendingFramesRef = useRef<{ action: string; data: unknown }[]>([]);
|
|
@@ -85,6 +133,10 @@ export function useSessionSocket({
|
|
|
85
133
|
onTitleUpdatedRef.current = onTitleUpdated;
|
|
86
134
|
}, [onTitleUpdated]);
|
|
87
135
|
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
onUnknownEventRef.current = onUnknownEvent;
|
|
138
|
+
}, [onUnknownEvent]);
|
|
139
|
+
|
|
88
140
|
const send = useCallback((frame: { action: string; data: unknown }) => {
|
|
89
141
|
const ws = socketRef.current;
|
|
90
142
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
@@ -134,12 +186,25 @@ export function useSessionSocket({
|
|
|
134
186
|
}
|
|
135
187
|
}
|
|
136
188
|
}
|
|
189
|
+
// The reducer ignores anything it does not know, which silently drops an
|
|
190
|
+
// app-specific frame. Hand it over instead, so the container can act on
|
|
191
|
+
// vocabulary the shared kit deliberately does not carry.
|
|
192
|
+
if (!KNOWN_EVENTS.has(frame.event)) {
|
|
193
|
+
onUnknownEventRef.current?.(frame);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
137
196
|
setState((prev) => sessionReducer(prev, frame));
|
|
138
197
|
}, []);
|
|
139
198
|
|
|
140
199
|
const connect = useCallback(() => {
|
|
141
200
|
if (closedByUserRef.current) return;
|
|
142
|
-
|
|
201
|
+
// A half-read tool call from the previous connection must not be completed
|
|
202
|
+
// by an ARGS event from this one — the ids are per-stream.
|
|
203
|
+
resetAguiState();
|
|
204
|
+
const path = `ws/canopy-sessions/${sessionId}/`;
|
|
205
|
+
const ws = new WebSocket(
|
|
206
|
+
wsUrl(protocolRef.current === "ag-ui" ? `${path}?protocol=ag-ui` : path),
|
|
207
|
+
);
|
|
143
208
|
socketRef.current = ws;
|
|
144
209
|
|
|
145
210
|
ws.onopen = () => {
|
|
@@ -162,8 +227,14 @@ export function useSessionSocket({
|
|
|
162
227
|
|
|
163
228
|
ws.onmessage = (e) => {
|
|
164
229
|
try {
|
|
165
|
-
const
|
|
166
|
-
|
|
230
|
+
const raw = JSON.parse(e.data);
|
|
231
|
+
if (protocolRef.current === "ag-ui") {
|
|
232
|
+
// One AG-UI event can be several canopy frames (a tool call is three
|
|
233
|
+
// events) or none, so this is a fan-out rather than a rename.
|
|
234
|
+
for (const frame of fromAgui(raw)) applyEvent(frame);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
applyEvent(raw as WsEvent);
|
|
167
238
|
} catch {
|
|
168
239
|
// ignore malformed frames
|
|
169
240
|
}
|
|
@@ -12,8 +12,10 @@ export function workbenchNavItemClass({
|
|
|
12
12
|
variant === 'neutral'
|
|
13
13
|
? 'bg-accent border-transparent text-foreground font-medium'
|
|
14
14
|
: 'bg-primary/10 border-primary/30 text-primary font-medium'
|
|
15
|
+
// `min-h-11` below `sm` is the 44px touch minimum; from `sm` up the rail keeps
|
|
16
|
+
// its original density, where the pointer is precise and vertical space is dear.
|
|
15
17
|
return cn(
|
|
16
|
-
'flex items-center justify-between gap-2 rounded-md border px-3 py-1.5 text-sm transition-colors',
|
|
18
|
+
'flex min-h-11 items-center justify-between gap-2 rounded-md border px-3 py-1.5 text-sm transition-colors sm:min-h-0',
|
|
17
19
|
active
|
|
18
20
|
? activeClass
|
|
19
21
|
: 'border-transparent text-muted-foreground hover:bg-accent hover:text-foreground',
|
package/src/ui/button.tsx
CHANGED
|
@@ -4,6 +4,11 @@ import { cva, type VariantProps } from "class-variance-authority"
|
|
|
4
4
|
import { cn } from "../lib/cn"
|
|
5
5
|
|
|
6
6
|
const buttonVariants = cva(
|
|
7
|
+
// Every size variant sets a fixed height (default h-8, sm h-7, xs h-6), all of
|
|
8
|
+
// them below the 44px touch minimum. Rather than teach each variant about
|
|
9
|
+
// touch, the floor lives here once and lifts off at `sm`, where a pointer is
|
|
10
|
+
// precise and the density is the point.
|
|
11
|
+
"min-h-11 min-w-11 sm:min-h-0 sm:min-w-0 " +
|
|
7
12
|
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
8
13
|
{
|
|
9
14
|
variants: {
|
package/src/ui/input.tsx
CHANGED
|
@@ -9,7 +9,9 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|
|
9
9
|
type={type}
|
|
10
10
|
data-slot="input"
|
|
11
11
|
className={cn(
|
|
12
|
-
|
|
12
|
+
// Touch floor, matching Button: `h-8` is 32px, under the 44px minimum. Lifts
|
|
13
|
+
// off at `sm` so desktop density is unchanged.
|
|
14
|
+
"min-h-11 sm:min-h-0 sm:h-8 h-11 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
|
13
15
|
className
|
|
14
16
|
)}
|
|
15
17
|
{...props}
|
package/src/ui/tabs.tsx
CHANGED
|
@@ -23,8 +23,17 @@ function Tabs({
|
|
|
23
23
|
)
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
// Touch first, then shrink to the desktop density at `sm`.
|
|
27
|
+
//
|
|
28
|
+
// `w-fit` + a fixed `h-8` was two bugs at once on a phone: the list sized itself
|
|
29
|
+
// to its content, so a fourth tab ran off the viewport with no scroll and no
|
|
30
|
+
// wrap (measured on /supervisor at 375px: scrollWidth 302 vs clientWidth 293,
|
|
31
|
+
// clipping "Runners"), and the height capped every trigger at 23px — roughly
|
|
32
|
+
// half the 44px touch minimum, on the surface that ships as the phone PWA.
|
|
33
|
+
// Below `sm` the list is now full-width and wraps; from `sm` up it is exactly
|
|
34
|
+
// what it was.
|
|
26
35
|
const tabsListVariants = cva(
|
|
27
|
-
"group/tabs-list
|
|
36
|
+
"group/tabs-list grid w-full max-w-full grid-cols-2 items-center justify-center rounded-lg p-[3px] text-muted-foreground sm:inline-flex sm:w-fit sm:grid-cols-none group-data-horizontal/tabs:h-auto sm:group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
|
28
37
|
{
|
|
29
38
|
variants: {
|
|
30
39
|
variant: {
|
|
@@ -58,7 +67,7 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
|
|
58
67
|
<TabsPrimitive.Tab
|
|
59
68
|
data-slot="tabs-trigger"
|
|
60
69
|
className={cn(
|
|
61
|
-
"relative inline-flex h-
|
|
70
|
+
"relative inline-flex min-h-11 flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-0.5 text-sm font-medium whitespace-nowrap text-muted-foreground transition-all sm:h-[calc(100%-1px)] sm:min-h-0 sm:px-2.5 group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground-secondary focus-visible:border-primary/50 focus-visible:ring-2 focus-visible:ring-primary/20 focus-visible:outline-1 focus-visible:outline-primary/40 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
62
71
|
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent group-data-[variant=line]/tabs-list:data-active:border-transparent",
|
|
63
72
|
"data-active:bg-background data-active:text-foreground data-active:border-border",
|
|
64
73
|
"after:absolute after:bg-primary after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|