canopy-ui 0.2.0 → 0.4.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 +20 -1
- package/src/chat/ChatPanel.tsx +140 -0
- package/src/chat/ConnectionStatus.tsx +30 -0
- package/src/chat/MessageItem.tsx +198 -0
- package/src/chat/MessageList.tsx +95 -0
- package/src/chat/PlacementBanner.test.tsx +112 -0
- package/src/chat/PlacementBanner.tsx +92 -0
- package/src/chat/PresenceChips.tsx +52 -0
- package/src/chat/SendBox.tsx +144 -0
- package/src/chat/ToolCallPair.tsx +86 -0
- package/src/chat/drafts.test.ts +66 -0
- package/src/chat/drafts.ts +19 -0
- package/src/chat/history.test.ts +35 -0
- package/src/chat/history.ts +16 -0
- package/src/chat/index.ts +56 -0
- package/src/chat/pairToolMessages.test.ts +208 -0
- package/src/chat/pairToolMessages.ts +162 -0
- package/src/chat/protocol.ts +99 -0
- package/src/chat/sessionReducer.test.ts +284 -0
- package/src/chat/sessionReducer.ts +245 -0
- package/src/chat/useSessionSocket.ts +269 -0
- package/src/chat/useStickyBottom.ts +77 -0
- package/src/ui/AutoResizeTextarea.tsx +61 -0
- package/src/ui/dialog.tsx +131 -0
- package/src/ui/dropdown-menu.tsx +226 -0
- package/src/ui/index.ts +32 -0
- package/src/ui/sonner.tsx +23 -0
- package/src/ui/tooltip.tsx +55 -0
package/package.json
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "canopy-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"type": "module",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/jjackson/canopy-web.git",
|
|
8
|
+
"directory": "frontend/packages/canopy-ui"
|
|
9
|
+
},
|
|
5
10
|
"exports": {
|
|
6
11
|
".": "./src/index.ts",
|
|
7
12
|
"./lib": "./src/lib/index.ts",
|
|
8
13
|
"./ui": "./src/ui/index.ts",
|
|
14
|
+
"./chat": "./src/chat/index.ts",
|
|
9
15
|
"./shell": "./src/shell/index.ts",
|
|
10
16
|
"./tokens": "./src/tokens/index.ts",
|
|
11
17
|
"./tokens/preset.css": "./src/tokens/preset.css",
|
|
@@ -19,9 +25,22 @@
|
|
|
19
25
|
"@base-ui/react": "^1.3.0",
|
|
20
26
|
"class-variance-authority": "^0.7.1",
|
|
21
27
|
"clsx": "^2.1.1",
|
|
28
|
+
"lucide-react": "^1.8.0",
|
|
22
29
|
"react": "^19.0.0",
|
|
23
30
|
"react-dom": "^19.0.0",
|
|
31
|
+
"sonner": "^2.0.7",
|
|
24
32
|
"tailwind-merge": "^3.5.0"
|
|
25
33
|
},
|
|
34
|
+
"peerDependenciesMeta": {
|
|
35
|
+
"lucide-react": {
|
|
36
|
+
"optional": true
|
|
37
|
+
},
|
|
38
|
+
"sonner": {
|
|
39
|
+
"optional": true
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"sonner": "^2.0.7"
|
|
44
|
+
},
|
|
26
45
|
"files": ["src"]
|
|
27
46
|
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import type { SessionState } from "./protocol";
|
|
4
|
+
import type { RenderMarkdown } from "./MessageItem";
|
|
5
|
+
import { ConnectionStatus } from "./ConnectionStatus";
|
|
6
|
+
import { MessageList } from "./MessageList";
|
|
7
|
+
import { PresenceChips } from "./PresenceChips";
|
|
8
|
+
import { SendBox } from "./SendBox";
|
|
9
|
+
import { isDraftIdle, msUntilDraftIdle } from "./drafts";
|
|
10
|
+
import { useStickyBottom } from "./useStickyBottom";
|
|
11
|
+
|
|
12
|
+
export interface ChatPanelProps {
|
|
13
|
+
state: SessionState;
|
|
14
|
+
connected: boolean;
|
|
15
|
+
currentUserId: number;
|
|
16
|
+
onSend: () => void;
|
|
17
|
+
onStop: (messageId: string) => void;
|
|
18
|
+
onUpdateDraft: (body: string) => void;
|
|
19
|
+
onTakeOver: () => void;
|
|
20
|
+
onDiscard: () => void;
|
|
21
|
+
renderMarkdown?: RenderMarkdown;
|
|
22
|
+
/** Optional banner rendered above the composer. */
|
|
23
|
+
banner?: ReactNode;
|
|
24
|
+
/** Rendered when there are no messages yet. */
|
|
25
|
+
emptyState?: ReactNode;
|
|
26
|
+
/** When set, sending is disabled and this reason is shown. */
|
|
27
|
+
disabledReason?: string;
|
|
28
|
+
/** Rendered at the top of the scroll container (e.g. a "Load earlier" button / offline banner). */
|
|
29
|
+
historySlot?: ReactNode;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Presentational, app-agnostic chat surface: connection chip + presence,
|
|
34
|
+
* a sticky-bottom message list, and the composer. Props-in / callbacks-out —
|
|
35
|
+
* NO data fetching, NO WebSocket, NO CLI-auth. The container (e.g. canopy's
|
|
36
|
+
* ChatPage) wires `useSessionSocket` returns into these props.
|
|
37
|
+
*/
|
|
38
|
+
export function ChatPanel({
|
|
39
|
+
state,
|
|
40
|
+
connected,
|
|
41
|
+
currentUserId,
|
|
42
|
+
onSend,
|
|
43
|
+
onStop,
|
|
44
|
+
onUpdateDraft,
|
|
45
|
+
onTakeOver,
|
|
46
|
+
onDiscard,
|
|
47
|
+
renderMarkdown,
|
|
48
|
+
banner,
|
|
49
|
+
emptyState,
|
|
50
|
+
disabledReason,
|
|
51
|
+
historySlot,
|
|
52
|
+
}: ChatPanelProps) {
|
|
53
|
+
// `onDiscard` is part of the public surface (co-edit teardown) even though
|
|
54
|
+
// the default composer doesn't render a discard button. Referenced to keep
|
|
55
|
+
// it wired without an unused-var error; a future toolbar can surface it.
|
|
56
|
+
void onDiscard;
|
|
57
|
+
|
|
58
|
+
// Force a re-render when the draft lock transitions from live to idle so
|
|
59
|
+
// PresenceChips' amber-highlight updates at T+2s without waiting for some
|
|
60
|
+
// unrelated event to arrive.
|
|
61
|
+
const [, forceIdleTick] = useState(0);
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
const draft = state.active_draft;
|
|
64
|
+
if (!draft) return;
|
|
65
|
+
const remaining = msUntilDraftIdle(draft);
|
|
66
|
+
if (remaining === 0) return;
|
|
67
|
+
const t = window.setTimeout(() => forceIdleTick((n) => n + 1), remaining + 10);
|
|
68
|
+
return () => window.clearTimeout(t);
|
|
69
|
+
}, [state.active_draft?.last_edit_at, state.active_draft]);
|
|
70
|
+
|
|
71
|
+
const holderId = state.active_draft?.last_editor ?? null;
|
|
72
|
+
const holderIsPresent =
|
|
73
|
+
holderId != null && state.presence_user_ids.includes(holderId);
|
|
74
|
+
|
|
75
|
+
// A turn is "in flight" from the moment the assistant row appears
|
|
76
|
+
// (status=pending/streaming) until chat.stream_complete flips it to
|
|
77
|
+
// complete. Treat pending AND streaming as in-flight so the send button
|
|
78
|
+
// stays locked out and the stop button is reachable during the "waiting
|
|
79
|
+
// for first token" window.
|
|
80
|
+
const inFlightMessage = useMemo(
|
|
81
|
+
() =>
|
|
82
|
+
state.messages.find(
|
|
83
|
+
(m) => m.status === "streaming" || m.status === "pending",
|
|
84
|
+
) ?? null,
|
|
85
|
+
[state.messages],
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
// Sticky-bottom scroll: dep changes on (a) new message arrival and (b)
|
|
89
|
+
// streaming text growth on the last message. length-only (cheap) instead
|
|
90
|
+
// of the full string so the effect doesn't re-run on equal characters.
|
|
91
|
+
const messages = state.messages;
|
|
92
|
+
const lastMessageLen =
|
|
93
|
+
messages.length > 0 ? messages[messages.length - 1].plaintext.length : 0;
|
|
94
|
+
const scrollDep = `${messages.length}:${lastMessageLen}`;
|
|
95
|
+
const { containerRef, onScroll } = useStickyBottom(scrollDep);
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<div className="flex h-full flex-col">
|
|
99
|
+
<div className="flex items-center gap-3 border-b border-border bg-background px-3 py-1.5 text-xs">
|
|
100
|
+
<ConnectionStatus connected={connected} />
|
|
101
|
+
<div className="ml-auto">
|
|
102
|
+
<PresenceChips
|
|
103
|
+
participants={state.participants}
|
|
104
|
+
presenceUserIds={state.presence_user_ids}
|
|
105
|
+
draftHolderId={holderId}
|
|
106
|
+
draftHolderIdle={isDraftIdle(state.active_draft)}
|
|
107
|
+
/>
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
{/* Pinned ABOVE the scroll container, not inside it. The container is
|
|
111
|
+
auto-scrolled to the bottom (useStickyBottom), so a slot rendered as
|
|
112
|
+
the first child of the scroll content sits above the visible area —
|
|
113
|
+
on prod the "Load full session" control on an empty runner-discovered
|
|
114
|
+
session was in the DOM but scrolled out of view and covered by the
|
|
115
|
+
header, i.e. unreachable. Pinning keeps "Load earlier"/"Load full"
|
|
116
|
+
always visible. */}
|
|
117
|
+
{historySlot}
|
|
118
|
+
<div ref={containerRef} onScroll={onScroll} className="flex-1 overflow-y-auto">
|
|
119
|
+
<MessageList
|
|
120
|
+
messages={state.messages}
|
|
121
|
+
emptyState={emptyState}
|
|
122
|
+
renderMarkdown={renderMarkdown}
|
|
123
|
+
/>
|
|
124
|
+
</div>
|
|
125
|
+
<SendBox
|
|
126
|
+
draft={state.active_draft}
|
|
127
|
+
currentUserId={currentUserId}
|
|
128
|
+
holderIsPresent={holderIsPresent}
|
|
129
|
+
isStreaming={inFlightMessage != null}
|
|
130
|
+
streamingMessageId={inFlightMessage?.id ?? null}
|
|
131
|
+
onUpdate={onUpdateDraft}
|
|
132
|
+
onSend={onSend}
|
|
133
|
+
onStop={onStop}
|
|
134
|
+
onTakeOver={onTakeOver}
|
|
135
|
+
banner={banner}
|
|
136
|
+
disabledReason={disabledReason}
|
|
137
|
+
/>
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
interface Props {
|
|
2
|
+
connected: boolean;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* WS connection chip — "Connected" (success) or "Reconnecting…" (warning).
|
|
7
|
+
* The ace CLI-auth variant is stripped; the kit only knows about the socket.
|
|
8
|
+
*/
|
|
9
|
+
export function ConnectionStatus({ connected }: Props) {
|
|
10
|
+
if (!connected) {
|
|
11
|
+
return (
|
|
12
|
+
<span
|
|
13
|
+
className="inline-flex items-center gap-1.5 rounded-full border border-warning/40 bg-warning/10 px-2 py-0.5 text-xs text-warning"
|
|
14
|
+
title="Trying to reconnect to the chat server."
|
|
15
|
+
>
|
|
16
|
+
<span className="h-2 w-2 animate-pulse rounded-full bg-warning" />
|
|
17
|
+
Reconnecting…
|
|
18
|
+
</span>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
return (
|
|
22
|
+
<span
|
|
23
|
+
className="inline-flex items-center gap-1.5 rounded-full border border-success/40 bg-success/10 px-2 py-0.5 text-xs text-success"
|
|
24
|
+
title="Connected."
|
|
25
|
+
>
|
|
26
|
+
<span className="h-2 w-2 rounded-full bg-success" />
|
|
27
|
+
Connected
|
|
28
|
+
</span>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { AlertTriangle, ChevronRight, OctagonX } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
import type { Message } from "./protocol";
|
|
5
|
+
import { ToolCallPair } from "./ToolCallPair";
|
|
6
|
+
|
|
7
|
+
/** How to render assistant/system markdown. Injected by the app so the kit
|
|
8
|
+
* stays free of `react-markdown`. Defaults to plain text in a <span>. */
|
|
9
|
+
export type RenderMarkdown = (text: string) => ReactNode;
|
|
10
|
+
|
|
11
|
+
const plainText: RenderMarkdown = (text) => (
|
|
12
|
+
<span className="whitespace-pre-wrap">{text}</span>
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
interface Props {
|
|
16
|
+
message: Message;
|
|
17
|
+
/** When set, expand/collapse this row regardless of native toggle. Lets
|
|
18
|
+
* MessageList's toolbar drive bulk expand/collapse without having to
|
|
19
|
+
* duplicate the rendering logic per row. */
|
|
20
|
+
forceToolOpen?: boolean;
|
|
21
|
+
renderMarkdown?: RenderMarkdown;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Count visible lines for the "▸ System context (N lines)" header. */
|
|
25
|
+
function countLines(text: string): number {
|
|
26
|
+
if (!text) return 0;
|
|
27
|
+
return text.split("\n").length;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The backend marks cancelled-by-user turns as status=error with
|
|
31
|
+
// error_detail prefixed by "cancelled". Treat that visually as a
|
|
32
|
+
// neutral "stopped" state, not a scary "error" state.
|
|
33
|
+
function classifyError(detail: string | null) {
|
|
34
|
+
const text = (detail ?? "").trim();
|
|
35
|
+
if (text.toLowerCase().startsWith("cancelled")) {
|
|
36
|
+
return {
|
|
37
|
+
kind: "stopped" as const,
|
|
38
|
+
label: text.replace(/^cancelled/i, "Stopped").trim() || "Stopped",
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
kind: "error" as const,
|
|
43
|
+
label: text || "Something went wrong",
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function MessageItem({
|
|
48
|
+
message,
|
|
49
|
+
forceToolOpen,
|
|
50
|
+
renderMarkdown = plainText,
|
|
51
|
+
}: Props) {
|
|
52
|
+
const text = message.plaintext;
|
|
53
|
+
const isStreaming = message.status === "streaming";
|
|
54
|
+
const isPending = message.status === "pending";
|
|
55
|
+
const isError = message.status === "error";
|
|
56
|
+
|
|
57
|
+
// tool_use and tool_result rows that survived the pairing pass in
|
|
58
|
+
// MessageList didn't find a partner — render as standalone with the
|
|
59
|
+
// same component for visual consistency. The common case (paired
|
|
60
|
+
// tool_use+tool_result) is rendered by MessageList itself via
|
|
61
|
+
// ToolCallPair so we never reach here for those.
|
|
62
|
+
if (message.role === "tool_use") {
|
|
63
|
+
return <ToolCallPair use={message} result={null} forceOpen={forceToolOpen} />;
|
|
64
|
+
}
|
|
65
|
+
if (message.role === "tool_result") {
|
|
66
|
+
// Synthesize a fake "use" message so the pair component can render
|
|
67
|
+
// a uniform header. Defensive — should be rare.
|
|
68
|
+
const fakeUse: Message = {
|
|
69
|
+
...message,
|
|
70
|
+
role: "tool_use",
|
|
71
|
+
content: { name: "tool_result (orphan)" },
|
|
72
|
+
};
|
|
73
|
+
return (
|
|
74
|
+
<ToolCallPair use={fakeUse} result={message} forceOpen={forceToolOpen} />
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// System messages are seed context for the agent: load-bearing for the
|
|
79
|
+
// assistant's first response, but a wall-of-text from the human reader's
|
|
80
|
+
// POV. Render collapsed by default with a chevron header so the send box
|
|
81
|
+
// stays the focal point on session open.
|
|
82
|
+
if (message.role === "system") {
|
|
83
|
+
return (
|
|
84
|
+
<SystemSeedRow
|
|
85
|
+
message={message}
|
|
86
|
+
forceOpen={forceToolOpen}
|
|
87
|
+
renderMarkdown={renderMarkdown}
|
|
88
|
+
/>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const bubbleClass =
|
|
93
|
+
message.role === "user"
|
|
94
|
+
? "ml-auto bg-primary text-primary-foreground"
|
|
95
|
+
: "mr-auto bg-muted text-foreground";
|
|
96
|
+
// Hold the "Thinking…" treatment through the gap between
|
|
97
|
+
// chat.stream_start (status flips to "streaming") and the first
|
|
98
|
+
// chat.delta (text becomes non-empty).
|
|
99
|
+
const showThinking =
|
|
100
|
+
(isPending || isStreaming) && message.role === "assistant" && !text;
|
|
101
|
+
return (
|
|
102
|
+
<div
|
|
103
|
+
className={`my-2 max-w-[80%] rounded-2xl px-4 py-2 ${bubbleClass}`}
|
|
104
|
+
aria-live={isStreaming || isPending ? "polite" : undefined}
|
|
105
|
+
>
|
|
106
|
+
{showThinking ? (
|
|
107
|
+
<span className="inline-flex items-center gap-1.5 text-muted-foreground">
|
|
108
|
+
<span className="inline-flex gap-0.5" aria-label="thinking">
|
|
109
|
+
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-current [animation-delay:-0.3s]" />
|
|
110
|
+
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-current [animation-delay:-0.15s]" />
|
|
111
|
+
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-current" />
|
|
112
|
+
</span>
|
|
113
|
+
<span className="text-xs italic">Thinking…</span>
|
|
114
|
+
</span>
|
|
115
|
+
) : message.role === "assistant" ? (
|
|
116
|
+
renderMarkdown(text)
|
|
117
|
+
) : (
|
|
118
|
+
<div className="whitespace-pre-wrap">{text}</div>
|
|
119
|
+
)}
|
|
120
|
+
{isStreaming && text && (
|
|
121
|
+
<span className="ml-1 inline-block h-3 w-1 animate-pulse bg-current align-middle" />
|
|
122
|
+
)}
|
|
123
|
+
{isError && message.role === "assistant" && (
|
|
124
|
+
<ErrorFooter detail={message.error_detail} hasPartial={Boolean(text)} />
|
|
125
|
+
)}
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Collapsed-by-default rendering for a seed/system message. Uses a native
|
|
132
|
+
* <details> element so it's keyboard-accessible and screen-reader friendly.
|
|
133
|
+
* ``forceToolOpen`` (from MessageList's bulk toolbar) overrides the native
|
|
134
|
+
* state to match the surrounding tool rows.
|
|
135
|
+
*/
|
|
136
|
+
function SystemSeedRow({
|
|
137
|
+
message,
|
|
138
|
+
forceOpen,
|
|
139
|
+
renderMarkdown,
|
|
140
|
+
}: {
|
|
141
|
+
message: Message;
|
|
142
|
+
forceOpen: boolean | undefined;
|
|
143
|
+
renderMarkdown: RenderMarkdown;
|
|
144
|
+
}) {
|
|
145
|
+
const text = message.plaintext;
|
|
146
|
+
const lineCount = countLines(text);
|
|
147
|
+
const openProp = forceOpen === undefined ? undefined : forceOpen;
|
|
148
|
+
return (
|
|
149
|
+
<details
|
|
150
|
+
className="group my-2 mr-auto max-w-[80%] rounded-2xl border border-border bg-muted/40 px-3 py-1.5 text-sm"
|
|
151
|
+
data-testid="system-seed-row"
|
|
152
|
+
{...(openProp !== undefined ? { open: openProp } : {})}
|
|
153
|
+
>
|
|
154
|
+
<summary className="flex cursor-pointer items-center gap-1.5 text-muted-foreground hover:text-foreground select-none list-none [&::-webkit-details-marker]:hidden">
|
|
155
|
+
<ChevronRight className="h-3.5 w-3.5 transition-transform group-open:rotate-90" />
|
|
156
|
+
<span className="text-xs font-medium uppercase tracking-wide">
|
|
157
|
+
System context
|
|
158
|
+
</span>
|
|
159
|
+
{lineCount > 0 && (
|
|
160
|
+
<span className="text-xs text-muted-foreground/70">
|
|
161
|
+
· {lineCount} line{lineCount === 1 ? "" : "s"}
|
|
162
|
+
</span>
|
|
163
|
+
)}
|
|
164
|
+
</summary>
|
|
165
|
+
<div className="mt-2 border-t border-border/40 pt-2 text-foreground">
|
|
166
|
+
{renderMarkdown(text)}
|
|
167
|
+
</div>
|
|
168
|
+
</details>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function ErrorFooter({
|
|
173
|
+
detail,
|
|
174
|
+
hasPartial,
|
|
175
|
+
}: {
|
|
176
|
+
detail: string | null;
|
|
177
|
+
hasPartial: boolean;
|
|
178
|
+
}) {
|
|
179
|
+
const { kind, label } = classifyError(detail);
|
|
180
|
+
const isStopped = kind === "stopped";
|
|
181
|
+
// Stopped = neutral muted treatment; error = amber warning. Avoid
|
|
182
|
+
// destructive red — even a real error is recoverable (the user resends
|
|
183
|
+
// the next turn) and red bubbles reflexively read as "your chat is broken".
|
|
184
|
+
const Icon = isStopped ? OctagonX : AlertTriangle;
|
|
185
|
+
const tone = isStopped ? "text-muted-foreground" : "text-warning";
|
|
186
|
+
return (
|
|
187
|
+
<div
|
|
188
|
+
className={`mt-2 flex items-start gap-1.5 border-t border-border/40 pt-1.5 text-xs italic ${tone}`}
|
|
189
|
+
role="status"
|
|
190
|
+
>
|
|
191
|
+
<Icon className="mt-0.5 h-3 w-3 shrink-0" aria-hidden="true" />
|
|
192
|
+
<span>
|
|
193
|
+
{label}
|
|
194
|
+
{hasPartial ? " · partial response shown above" : ""}
|
|
195
|
+
</span>
|
|
196
|
+
</div>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { useMemo, useState, type ReactNode } from "react";
|
|
2
|
+
import { ChevronsDownUp, ChevronsUpDown } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
import type { Message } from "./protocol";
|
|
5
|
+
import { Button } from "../ui/button";
|
|
6
|
+
import type { RenderMarkdown } from "./MessageItem";
|
|
7
|
+
import { MessageItem } from "./MessageItem";
|
|
8
|
+
import { ToolCallPair } from "./ToolCallPair";
|
|
9
|
+
import { pairToolMessages } from "./pairToolMessages";
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
messages: Message[];
|
|
13
|
+
/** Rendered when there are no messages yet (replaces ace's WelcomePanel). */
|
|
14
|
+
emptyState?: ReactNode;
|
|
15
|
+
renderMarkdown?: RenderMarkdown;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Show the bulk expand/collapse toolbar once a session has more than this
|
|
19
|
+
// many tool rows. Below that, the per-row toggle is enough.
|
|
20
|
+
const TOOLBAR_THRESHOLD = 5;
|
|
21
|
+
|
|
22
|
+
type BulkState = "default" | "all" | "none";
|
|
23
|
+
|
|
24
|
+
export function MessageList({ messages, emptyState, renderMarkdown }: Props) {
|
|
25
|
+
const rows = useMemo(() => pairToolMessages(messages), [messages]);
|
|
26
|
+
const toolPairCount = useMemo(
|
|
27
|
+
() => rows.filter((r) => r.kind === "tool_pair").length,
|
|
28
|
+
[rows],
|
|
29
|
+
);
|
|
30
|
+
// ``default`` = each <details> uses its own native state (collapsed
|
|
31
|
+
// initially, user can toggle individually). The bulk toggles flip
|
|
32
|
+
// every row open or closed at once. Reverting to "default" hands
|
|
33
|
+
// control back to per-row state.
|
|
34
|
+
const [bulkState, setBulkState] = useState<BulkState>("default");
|
|
35
|
+
|
|
36
|
+
if (messages.length === 0) {
|
|
37
|
+
return <>{emptyState ?? null}</>;
|
|
38
|
+
}
|
|
39
|
+
const forceToolOpen =
|
|
40
|
+
bulkState === "all" ? true : bulkState === "none" ? false : undefined;
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div className="flex flex-col">
|
|
44
|
+
{toolPairCount > TOOLBAR_THRESHOLD && (
|
|
45
|
+
<div className="sticky top-0 z-10 flex items-center justify-end gap-2 border-b border-border bg-background/80 px-4 py-1.5 backdrop-blur">
|
|
46
|
+
<span className="text-xs text-muted-foreground">
|
|
47
|
+
{toolPairCount} tool calls
|
|
48
|
+
</span>
|
|
49
|
+
<Button
|
|
50
|
+
variant={bulkState === "all" ? "secondary" : "ghost"}
|
|
51
|
+
size="xs"
|
|
52
|
+
onClick={() => setBulkState(bulkState === "all" ? "default" : "all")}
|
|
53
|
+
aria-pressed={bulkState === "all"}
|
|
54
|
+
>
|
|
55
|
+
<ChevronsUpDown className="h-3 w-3" />
|
|
56
|
+
Expand all
|
|
57
|
+
</Button>
|
|
58
|
+
<Button
|
|
59
|
+
variant={bulkState === "none" ? "secondary" : "ghost"}
|
|
60
|
+
size="xs"
|
|
61
|
+
onClick={() =>
|
|
62
|
+
setBulkState(bulkState === "none" ? "default" : "none")
|
|
63
|
+
}
|
|
64
|
+
aria-pressed={bulkState === "none"}
|
|
65
|
+
>
|
|
66
|
+
<ChevronsDownUp className="h-3 w-3" />
|
|
67
|
+
Collapse all
|
|
68
|
+
</Button>
|
|
69
|
+
</div>
|
|
70
|
+
)}
|
|
71
|
+
<div className="flex flex-col gap-2 p-4">
|
|
72
|
+
{rows.map((row) => {
|
|
73
|
+
if (row.kind === "tool_pair") {
|
|
74
|
+
return (
|
|
75
|
+
<ToolCallPair
|
|
76
|
+
key={row.key}
|
|
77
|
+
use={row.use}
|
|
78
|
+
result={row.result}
|
|
79
|
+
forceOpen={forceToolOpen}
|
|
80
|
+
/>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return (
|
|
84
|
+
<MessageItem
|
|
85
|
+
key={row.key}
|
|
86
|
+
message={row.message}
|
|
87
|
+
forceToolOpen={forceToolOpen}
|
|
88
|
+
renderMarkdown={renderMarkdown}
|
|
89
|
+
/>
|
|
90
|
+
);
|
|
91
|
+
})}
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
4
|
+
|
|
5
|
+
import { PlacementBanner, type PlacementRunner } from "./PlacementBanner";
|
|
6
|
+
|
|
7
|
+
function runner(id: string, overrides: Partial<PlacementRunner> = {}): PlacementRunner {
|
|
8
|
+
return { id, name: `Runner ${id}`, online: true, ...overrides };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
cleanup();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe("PlacementBanner", () => {
|
|
16
|
+
it("renders the bound-but-offline runner's name", () => {
|
|
17
|
+
render(
|
|
18
|
+
<PlacementBanner
|
|
19
|
+
runnerName="Laptop"
|
|
20
|
+
eligibleRunners={[]}
|
|
21
|
+
onWait={vi.fn()}
|
|
22
|
+
onPlace={vi.fn()}
|
|
23
|
+
/>,
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
expect(screen.getByText("Laptop is unavailable")).toBeTruthy();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("fires onPlace with the picked runner id", () => {
|
|
30
|
+
const onPlace = vi.fn();
|
|
31
|
+
render(
|
|
32
|
+
<PlacementBanner
|
|
33
|
+
runnerName="Laptop"
|
|
34
|
+
eligibleRunners={[runner("r1", { name: "Cloud" }), runner("r2", { name: "Backup" })]}
|
|
35
|
+
onWait={vi.fn()}
|
|
36
|
+
onPlace={onPlace}
|
|
37
|
+
/>,
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
// The picker is collapsed until "Continue on…" is clicked.
|
|
41
|
+
expect(screen.queryByLabelText("Continue on")).toBeNull();
|
|
42
|
+
fireEvent.click(screen.getByText("Continue on…"));
|
|
43
|
+
|
|
44
|
+
const select = screen.getByLabelText("Continue on") as HTMLSelectElement;
|
|
45
|
+
fireEvent.change(select, { target: { value: "r2" } });
|
|
46
|
+
|
|
47
|
+
expect(onPlace).toHaveBeenCalledWith("r2");
|
|
48
|
+
expect(onPlace).toHaveBeenCalledTimes(1);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("disables both actions when busy", () => {
|
|
52
|
+
render(
|
|
53
|
+
<PlacementBanner
|
|
54
|
+
runnerName="Laptop"
|
|
55
|
+
eligibleRunners={[runner("r1")]}
|
|
56
|
+
busy
|
|
57
|
+
onWait={vi.fn()}
|
|
58
|
+
onPlace={vi.fn()}
|
|
59
|
+
/>,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const waitButton = screen.getByText("Wait for it") as HTMLButtonElement;
|
|
63
|
+
const continueButton = screen.getByText("Continue on…") as HTMLButtonElement;
|
|
64
|
+
expect(waitButton.disabled).toBe(true);
|
|
65
|
+
expect(continueButton.disabled).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("fires onWait when 'Wait for it' is clicked", () => {
|
|
69
|
+
const onWait = vi.fn();
|
|
70
|
+
render(
|
|
71
|
+
<PlacementBanner
|
|
72
|
+
runnerName="Laptop"
|
|
73
|
+
eligibleRunners={[]}
|
|
74
|
+
onWait={onWait}
|
|
75
|
+
onPlace={vi.fn()}
|
|
76
|
+
/>,
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
fireEvent.click(screen.getByText("Wait for it"));
|
|
80
|
+
expect(onWait).toHaveBeenCalledTimes(1);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("renders an error message when provided", () => {
|
|
84
|
+
render(
|
|
85
|
+
<PlacementBanner
|
|
86
|
+
runnerName="Laptop"
|
|
87
|
+
eligibleRunners={[]}
|
|
88
|
+
error="Could not place the turn."
|
|
89
|
+
onWait={vi.fn()}
|
|
90
|
+
onPlace={vi.fn()}
|
|
91
|
+
/>,
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
expect(screen.getByText("Could not place the turn.")).toBeTruthy();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("renders error and info distinctly styled when both are present", () => {
|
|
98
|
+
render(
|
|
99
|
+
<PlacementBanner
|
|
100
|
+
runnerName="Laptop"
|
|
101
|
+
eligibleRunners={[]}
|
|
102
|
+
error="boom"
|
|
103
|
+
info="Placed."
|
|
104
|
+
onWait={vi.fn()}
|
|
105
|
+
onPlace={vi.fn()}
|
|
106
|
+
/>,
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
expect(screen.getByText("Placed.").className).toContain("text-muted-foreground");
|
|
110
|
+
expect(screen.getByText("boom").className).toContain("text-destructive");
|
|
111
|
+
});
|
|
112
|
+
});
|