@lemonriver/core 0.1.1 → 0.2.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/dist/{build-f-fM2ZY4.js → build-Df_xwIpg.js} +1 -1
- package/dist/cli/bin.js +4 -4
- package/dist/{config-DO45GU3e.js → config-BjWCLoMQ.js} +2343 -363
- package/dist/{config-B6JaKJM4.d.ts → config-rMGCkz6i.d.ts} +6 -2
- package/dist/{dev-CqS2xpgE.js → dev-Ctsx9IeO.js} +2 -2
- package/dist/index.d.ts +3 -3
- package/dist/index.js +4 -3
- package/dist/locale/index.d.ts +5 -2
- package/dist/locale/index.js +2 -2
- package/dist/{preview-BihgMlEl.js → preview-CJgcqsPk.js} +1 -1
- package/dist/{types-BjW4glip.d.ts → types-8VUyA2bu.d.ts} +133 -5
- package/dist/{update-C_R0ycVC.js → update-BKOA3Z6o.js} +153 -21
- package/dist/vite/index.d.ts +1 -1
- package/dist/vite/index.js +1 -1
- package/dist/{zh-tw-Ci0jK8eg.js → zh-tw-CeLrYwRE.js} +1178 -25
- package/package.json +13 -6
- package/skills/apply-comments/SKILL.md +10 -6
- package/skills/create-slide/SKILL.md +2 -0
- package/skills/current-slide/SKILL.md +1 -0
- package/skills/slide-authoring/SKILL.md +31 -1
- package/src/agent/prompt.ts +141 -0
- package/src/agent/provider.ts +123 -0
- package/src/agent/types.ts +151 -0
- package/src/app/app.tsx +2 -0
- package/src/app/assets/open-slide.svg +39 -0
- package/src/app/components/agent/agent-dock.tsx +35 -0
- package/src/app/components/agent/agent-markdown.tsx +45 -0
- package/src/app/components/agent/agent-panel.tsx +692 -0
- package/src/app/components/agent/agent-presence.tsx +74 -0
- package/src/app/components/agent/composer.tsx +120 -0
- package/src/app/components/agent/connect-view.tsx +184 -0
- package/src/app/components/agent/history-view.tsx +135 -0
- package/src/app/components/agent/provider-logo.tsx +34 -0
- package/src/app/components/agent/transcript.tsx +609 -0
- package/src/app/components/board/board-canvas.tsx +143 -12
- package/src/app/components/board/board.css +51 -0
- package/src/app/components/board/layout.test.ts +53 -1
- package/src/app/components/board/layout.ts +50 -2
- package/src/app/components/board/page-node.tsx +287 -13
- package/src/app/components/board/use-board-cards.ts +210 -0
- package/src/app/components/board/use-page-order.ts +17 -9
- package/src/app/components/command/slide-command-menu.tsx +10 -0
- package/src/app/components/icon-switcher-indicator.tsx +1 -1
- package/src/app/components/inspector/comment-layer.tsx +1011 -0
- package/src/app/components/inspector/image-crop-dialog.tsx +1 -5
- package/src/app/components/inspector/inspect-overlay.tsx +2 -39
- package/src/app/components/inspector/inspector-panel.tsx +10 -93
- package/src/app/components/inspector/inspector-provider.tsx +121 -47
- package/src/app/components/panel/editor-sidebar.tsx +85 -16
- package/src/app/components/panel/panel-shell.tsx +2 -2
- package/src/app/components/panel/resize-handle.tsx +129 -0
- package/src/app/components/player.tsx +0 -1
- package/src/app/components/preview-step-host.tsx +16 -0
- package/src/app/components/sidebar/sidebar.tsx +2 -2
- package/src/app/components/style-panel/style-panel.tsx +3 -2
- package/src/app/components/thumbnail-rail.tsx +76 -1
- package/src/app/favicon.ico +0 -0
- package/src/app/favicon.svg +39 -0
- package/src/app/index.html +2 -1
- package/src/app/lib/agent/use-agent.ts +543 -0
- package/src/app/lib/inspector/freeze-motion.ts +61 -0
- package/src/app/lib/inspector/use-comments.ts +32 -3
- package/src/app/lib/locale-store.ts +6 -1
- package/src/app/lib/print-ready.ts +9 -6
- package/src/app/lib/use-auto-grow.ts +15 -0
- package/src/app/lib/utils.ts +4 -0
- package/src/app/routes/board.tsx +48 -11
- package/src/app/routes/home.tsx +43 -7
- package/src/app/routes/presenter.tsx +1 -16
- package/src/app/routes/preview.tsx +169 -0
- package/src/app/routes/slide.tsx +221 -173
- package/src/app/styles.css +234 -80
- package/src/editing/placeholder-id.ts +1 -0
- package/src/locale/en.ts +127 -5
- package/src/locale/es.ts +692 -0
- package/src/locale/index.ts +1 -0
- package/src/locale/ja.ts +120 -6
- package/src/locale/types.ts +132 -5
- package/src/locale/zh-cn.ts +117 -5
- package/src/locale/zh-tw.ts +117 -5
- package/src/app/assets/open-slide.png +0 -0
- package/src/app/components/inspector/comment-widget.tsx +0 -119
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
import { Check, ChevronRight, CircleAlert, Copy, Pencil } from 'lucide-react';
|
|
2
|
+
import { type ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
3
|
+
import { Button } from '@/components/ui/button';
|
|
4
|
+
import { useAutoGrow } from '@/lib/use-auto-grow';
|
|
5
|
+
import { format, plural, useLocale } from '@/lib/use-locale';
|
|
6
|
+
import { cn } from '@/lib/utils';
|
|
7
|
+
import { stripDeckSlug } from '../../../agent/prompt';
|
|
8
|
+
import type { AgentItem, AgentQuestion, AgentSession, RespondPayload } from '../../../agent/types';
|
|
9
|
+
import { AgentMarkdown } from './agent-markdown';
|
|
10
|
+
|
|
11
|
+
export function Transcript({
|
|
12
|
+
session,
|
|
13
|
+
onRespond,
|
|
14
|
+
onEdit,
|
|
15
|
+
}: {
|
|
16
|
+
session: AgentSession;
|
|
17
|
+
onRespond: (itemId: string, payload: RespondPayload) => Promise<void>;
|
|
18
|
+
onEdit: (itemId: string, text: string) => Promise<void>;
|
|
19
|
+
}) {
|
|
20
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
21
|
+
const stickRef = useRef(true);
|
|
22
|
+
const lastItem = session.items.at(-1);
|
|
23
|
+
const lastText = lastItem?.kind === 'assistant' ? lastItem.text.length : 0;
|
|
24
|
+
const running = session.status === 'running';
|
|
25
|
+
const replies = replyTexts(session.items, running);
|
|
26
|
+
|
|
27
|
+
// Follow the stream while the user is parked at the bottom; stop once they
|
|
28
|
+
// scroll up to read, like a terminal.
|
|
29
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: re-run as the transcript grows or streams
|
|
30
|
+
useLayoutEffect(() => {
|
|
31
|
+
const node = scrollRef.current;
|
|
32
|
+
if (!node || !stickRef.current) return;
|
|
33
|
+
node.scrollTop = node.scrollHeight;
|
|
34
|
+
}, [session.items.length, lastText, session.status]);
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div
|
|
38
|
+
ref={scrollRef}
|
|
39
|
+
onScroll={(e) => {
|
|
40
|
+
const node = e.currentTarget;
|
|
41
|
+
stickRef.current = node.scrollHeight - node.scrollTop - node.clientHeight < 48;
|
|
42
|
+
}}
|
|
43
|
+
className="min-h-0 flex-1 overflow-y-auto px-4 py-4"
|
|
44
|
+
>
|
|
45
|
+
<ol className="flex flex-col gap-3">
|
|
46
|
+
{toBlocks(session.items).map((block) => (
|
|
47
|
+
<li key={block.key}>
|
|
48
|
+
{'tools' in block ? (
|
|
49
|
+
<ToolGroup tools={block.tools} />
|
|
50
|
+
) : (
|
|
51
|
+
<TranscriptItem
|
|
52
|
+
item={block.item}
|
|
53
|
+
running={running}
|
|
54
|
+
replyText={replies.get(block.item.id)}
|
|
55
|
+
onRespond={(payload) => onRespond(block.item.id, payload)}
|
|
56
|
+
onEdit={(text) => onEdit(block.item.id, text)}
|
|
57
|
+
/>
|
|
58
|
+
)}
|
|
59
|
+
</li>
|
|
60
|
+
))}
|
|
61
|
+
{running && !(lastItem?.kind === 'assistant' && lastItem.streaming) && (
|
|
62
|
+
<li>
|
|
63
|
+
<WorkingIndicator />
|
|
64
|
+
</li>
|
|
65
|
+
)}
|
|
66
|
+
</ol>
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function WorkingIndicator() {
|
|
72
|
+
const t = useLocale();
|
|
73
|
+
return (
|
|
74
|
+
<div className="text-[11.5px]">
|
|
75
|
+
<span className="agent-shimmer">{t.agent.working}…</span>
|
|
76
|
+
</div>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// One reply spans every assistant text between a user message and the turn's
|
|
81
|
+
// result. Its copy button sits on the last of them and copies them all.
|
|
82
|
+
function replyTexts(items: AgentItem[], running: boolean): Map<string, string> {
|
|
83
|
+
const replies = new Map<string, string>();
|
|
84
|
+
let parts: { id: string; text: string }[] = [];
|
|
85
|
+
const flush = () => {
|
|
86
|
+
const last = parts.at(-1);
|
|
87
|
+
const text = parts
|
|
88
|
+
.map((p) => p.text.trim())
|
|
89
|
+
.filter(Boolean)
|
|
90
|
+
.join('\n\n');
|
|
91
|
+
if (last && text) replies.set(last.id, text);
|
|
92
|
+
parts = [];
|
|
93
|
+
};
|
|
94
|
+
for (const item of items) {
|
|
95
|
+
if (item.kind === 'assistant') parts.push({ id: item.id, text: stripDeckSlug(item.text) });
|
|
96
|
+
else if (item.kind === 'user' || item.kind === 'result') flush();
|
|
97
|
+
}
|
|
98
|
+
if (!running) flush();
|
|
99
|
+
return replies;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function TranscriptItem({
|
|
103
|
+
item,
|
|
104
|
+
running,
|
|
105
|
+
replyText,
|
|
106
|
+
onRespond,
|
|
107
|
+
onEdit,
|
|
108
|
+
}: {
|
|
109
|
+
item: AgentItem;
|
|
110
|
+
running: boolean;
|
|
111
|
+
replyText?: string;
|
|
112
|
+
onRespond: (payload: RespondPayload) => Promise<void>;
|
|
113
|
+
onEdit: (text: string) => Promise<void>;
|
|
114
|
+
}) {
|
|
115
|
+
switch (item.kind) {
|
|
116
|
+
case 'user':
|
|
117
|
+
return <UserItem text={item.text} running={running} onEdit={onEdit} />;
|
|
118
|
+
case 'assistant':
|
|
119
|
+
if (!item.text && item.streaming) return <WorkingIndicator />;
|
|
120
|
+
return <AssistantItem text={stripDeckSlug(item.text)} replyText={replyText} />;
|
|
121
|
+
case 'question':
|
|
122
|
+
return <QuestionItem item={item} onRespond={onRespond} />;
|
|
123
|
+
case 'permission':
|
|
124
|
+
return <PermissionItem item={item} onRespond={onRespond} />;
|
|
125
|
+
case 'error':
|
|
126
|
+
return <ErrorItem text={item.text} />;
|
|
127
|
+
case 'result':
|
|
128
|
+
return <ResultItem item={item} />;
|
|
129
|
+
default:
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function ActionButton({
|
|
135
|
+
label,
|
|
136
|
+
onClick,
|
|
137
|
+
children,
|
|
138
|
+
}: {
|
|
139
|
+
label: string;
|
|
140
|
+
onClick: () => void;
|
|
141
|
+
children: ReactNode;
|
|
142
|
+
}) {
|
|
143
|
+
return (
|
|
144
|
+
<button
|
|
145
|
+
type="button"
|
|
146
|
+
aria-label={label}
|
|
147
|
+
title={label}
|
|
148
|
+
onClick={onClick}
|
|
149
|
+
className="grid size-6 place-items-center rounded-[5px] text-muted-foreground outline-none transition-colors duration-100 hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/30 [&_svg]:size-3"
|
|
150
|
+
>
|
|
151
|
+
{children}
|
|
152
|
+
</button>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function CopyButton({ text }: { text: string }) {
|
|
157
|
+
const t = useLocale();
|
|
158
|
+
const [copied, setCopied] = useState(false);
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
if (!copied) return;
|
|
161
|
+
const timer = setTimeout(() => setCopied(false), 1500);
|
|
162
|
+
return () => clearTimeout(timer);
|
|
163
|
+
}, [copied]);
|
|
164
|
+
return (
|
|
165
|
+
<ActionButton
|
|
166
|
+
label={copied ? t.agent.copied : t.agent.copyMessage}
|
|
167
|
+
onClick={() => {
|
|
168
|
+
navigator.clipboard.writeText(text).then(
|
|
169
|
+
() => setCopied(true),
|
|
170
|
+
() => {},
|
|
171
|
+
);
|
|
172
|
+
}}
|
|
173
|
+
>
|
|
174
|
+
{copied ? <Check /> : <Copy />}
|
|
175
|
+
</ActionButton>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const ACTIONS_REVEAL =
|
|
180
|
+
'flex opacity-0 transition-opacity duration-100 focus-within:opacity-100 group-hover/message:opacity-100 motion-reduce:transition-none';
|
|
181
|
+
|
|
182
|
+
function AssistantItem({ text, replyText }: { text: string; replyText?: string }) {
|
|
183
|
+
return (
|
|
184
|
+
<div className="group/message">
|
|
185
|
+
<AgentMarkdown text={text} />
|
|
186
|
+
{replyText && (
|
|
187
|
+
<div className={cn(ACTIONS_REVEAL, '-mb-2 -ml-1.5')}>
|
|
188
|
+
<CopyButton text={replyText} />
|
|
189
|
+
</div>
|
|
190
|
+
)}
|
|
191
|
+
</div>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function UserItem({
|
|
196
|
+
text,
|
|
197
|
+
running,
|
|
198
|
+
onEdit,
|
|
199
|
+
}: {
|
|
200
|
+
text: string;
|
|
201
|
+
running: boolean;
|
|
202
|
+
onEdit: (text: string) => Promise<void>;
|
|
203
|
+
}) {
|
|
204
|
+
const t = useLocale();
|
|
205
|
+
const [editing, setEditing] = useState(false);
|
|
206
|
+
|
|
207
|
+
if (editing) {
|
|
208
|
+
return (
|
|
209
|
+
<UserEditor
|
|
210
|
+
text={text}
|
|
211
|
+
running={running}
|
|
212
|
+
onCancel={() => setEditing(false)}
|
|
213
|
+
onSend={onEdit}
|
|
214
|
+
/>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return (
|
|
219
|
+
<div className="group/message flex flex-col items-end">
|
|
220
|
+
<div className="max-w-[88%] whitespace-pre-wrap rounded-[8px] rounded-br-[3px] bg-muted px-3 py-2 text-[12.5px] leading-[1.5]">
|
|
221
|
+
{text}
|
|
222
|
+
</div>
|
|
223
|
+
<div className={cn(ACTIONS_REVEAL, '-mr-1.5 -mb-2 mt-0.5')}>
|
|
224
|
+
<CopyButton text={text} />
|
|
225
|
+
{!running && (
|
|
226
|
+
<ActionButton label={t.agent.editMessage} onClick={() => setEditing(true)}>
|
|
227
|
+
<Pencil />
|
|
228
|
+
</ActionButton>
|
|
229
|
+
)}
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function UserEditor({
|
|
236
|
+
text,
|
|
237
|
+
running,
|
|
238
|
+
onCancel,
|
|
239
|
+
onSend,
|
|
240
|
+
}: {
|
|
241
|
+
text: string;
|
|
242
|
+
running: boolean;
|
|
243
|
+
onCancel: () => void;
|
|
244
|
+
onSend: (text: string) => Promise<void>;
|
|
245
|
+
}) {
|
|
246
|
+
const t = useLocale();
|
|
247
|
+
const [value, setValue] = useState(text);
|
|
248
|
+
const [busy, runBusy] = useBusy();
|
|
249
|
+
const ref = useRef<HTMLTextAreaElement>(null);
|
|
250
|
+
const canSend = !running && !busy && value.trim().length > 0 && value.trim() !== text.trim();
|
|
251
|
+
|
|
252
|
+
useAutoGrow(ref, value, 200);
|
|
253
|
+
|
|
254
|
+
useEffect(() => {
|
|
255
|
+
const node = ref.current;
|
|
256
|
+
if (!node) return;
|
|
257
|
+
node.focus();
|
|
258
|
+
node.setSelectionRange(node.value.length, node.value.length);
|
|
259
|
+
}, []);
|
|
260
|
+
|
|
261
|
+
const submit = async () => {
|
|
262
|
+
if (!canSend) return;
|
|
263
|
+
await runBusy(() => onSend(value.trim()));
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
return (
|
|
267
|
+
<div
|
|
268
|
+
data-agent-message-editor
|
|
269
|
+
className="rounded-[8px] border border-border bg-background shadow-edge transition-colors duration-100 focus-within:border-foreground/40 focus-within:ring-2 focus-within:ring-ring/30"
|
|
270
|
+
>
|
|
271
|
+
<textarea
|
|
272
|
+
ref={ref}
|
|
273
|
+
value={value}
|
|
274
|
+
rows={1}
|
|
275
|
+
aria-label={t.agent.editMessage}
|
|
276
|
+
onChange={(e) => setValue(e.target.value)}
|
|
277
|
+
onKeyDown={(e) => {
|
|
278
|
+
if (e.nativeEvent.isComposing) return;
|
|
279
|
+
if (e.key === 'Escape') {
|
|
280
|
+
e.preventDefault();
|
|
281
|
+
e.stopPropagation();
|
|
282
|
+
onCancel();
|
|
283
|
+
} else if (e.key === 'Enter' && !e.shiftKey) {
|
|
284
|
+
e.preventDefault();
|
|
285
|
+
void submit();
|
|
286
|
+
}
|
|
287
|
+
}}
|
|
288
|
+
className="w-full resize-none bg-transparent px-3 pt-2.5 text-[12.5px] leading-[1.5] outline-none"
|
|
289
|
+
/>
|
|
290
|
+
<p className="px-3 pt-0.5 text-[11px] leading-snug text-muted-foreground">
|
|
291
|
+
{t.agent.editHint}
|
|
292
|
+
</p>
|
|
293
|
+
<div className="flex justify-end gap-1 px-2 pt-2 pb-2">
|
|
294
|
+
<Button size="xs" variant="ghost" disabled={busy} onClick={onCancel}>
|
|
295
|
+
{t.agent.cancelEdit}
|
|
296
|
+
</Button>
|
|
297
|
+
<Button size="xs" disabled={!canSend} onClick={() => void submit()}>
|
|
298
|
+
{t.agent.sendEdit}
|
|
299
|
+
</Button>
|
|
300
|
+
</div>
|
|
301
|
+
</div>
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function useBusy() {
|
|
306
|
+
const [busy, setBusy] = useState(false);
|
|
307
|
+
const run = async (task: () => Promise<void>) => {
|
|
308
|
+
setBusy(true);
|
|
309
|
+
try {
|
|
310
|
+
await task();
|
|
311
|
+
} finally {
|
|
312
|
+
setBusy(false);
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
return [busy, run] as const;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
type ToolEntry = Extract<AgentItem, { kind: 'tool' }>;
|
|
319
|
+
|
|
320
|
+
type Block = { key: string; item: AgentItem } | { key: string; tools: ToolEntry[] };
|
|
321
|
+
|
|
322
|
+
function toBlocks(items: AgentItem[]): Block[] {
|
|
323
|
+
const blocks: Block[] = [];
|
|
324
|
+
for (const item of items) {
|
|
325
|
+
const last = blocks.at(-1);
|
|
326
|
+
if (item.kind === 'tool' && last && 'tools' in last) last.tools.push(item);
|
|
327
|
+
else if (item.kind === 'tool') blocks.push({ key: item.id, tools: [item] });
|
|
328
|
+
else blocks.push({ key: item.id, item });
|
|
329
|
+
}
|
|
330
|
+
return blocks;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function ToolRow({ item }: { item: ToolEntry }) {
|
|
334
|
+
return (
|
|
335
|
+
<div className="flex min-w-0 items-center gap-2 text-[11.5px] leading-5 text-muted-foreground">
|
|
336
|
+
<span
|
|
337
|
+
aria-hidden
|
|
338
|
+
className={cn(
|
|
339
|
+
'size-1 shrink-0 rounded-full',
|
|
340
|
+
item.status === 'running'
|
|
341
|
+
? 'animate-pulse bg-foreground/70 motion-reduce:animate-none'
|
|
342
|
+
: item.status === 'error'
|
|
343
|
+
? 'bg-destructive'
|
|
344
|
+
: 'bg-muted-foreground/40',
|
|
345
|
+
)}
|
|
346
|
+
/>
|
|
347
|
+
<span
|
|
348
|
+
className={cn(
|
|
349
|
+
'shrink-0',
|
|
350
|
+
item.status === 'running' && 'agent-shimmer',
|
|
351
|
+
item.status === 'error' && 'text-destructive',
|
|
352
|
+
)}
|
|
353
|
+
>
|
|
354
|
+
{item.title}
|
|
355
|
+
</span>
|
|
356
|
+
{item.detail && (
|
|
357
|
+
<span
|
|
358
|
+
className="min-w-0 truncate font-mono text-[10.5px] text-muted-foreground/70"
|
|
359
|
+
title={item.detail}
|
|
360
|
+
>
|
|
361
|
+
{item.detail}
|
|
362
|
+
</span>
|
|
363
|
+
)}
|
|
364
|
+
</div>
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function ToolGroup({ tools }: { tools: ToolEntry[] }) {
|
|
369
|
+
const t = useLocale();
|
|
370
|
+
const [open, setOpen] = useState(false);
|
|
371
|
+
if (tools.length === 1) return <ToolRow item={tools[0]} />;
|
|
372
|
+
const live = [...tools].reverse().find((tool) => tool.status === 'running');
|
|
373
|
+
const failed = tools.some((tool) => tool.status === 'error');
|
|
374
|
+
return (
|
|
375
|
+
<div>
|
|
376
|
+
<button
|
|
377
|
+
type="button"
|
|
378
|
+
aria-expanded={open}
|
|
379
|
+
onClick={() => setOpen((v) => !v)}
|
|
380
|
+
className="flex items-center gap-1 rounded-[4px] text-[11.5px] leading-5 text-muted-foreground outline-none transition-colors duration-100 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/30"
|
|
381
|
+
>
|
|
382
|
+
<ChevronRight
|
|
383
|
+
aria-hidden
|
|
384
|
+
className={cn(
|
|
385
|
+
'size-3 transition-transform duration-150 motion-reduce:transition-none',
|
|
386
|
+
open && 'rotate-90',
|
|
387
|
+
)}
|
|
388
|
+
/>
|
|
389
|
+
{format(plural(tools.length, t.agent.steps), { count: tools.length })}
|
|
390
|
+
{failed && <span aria-hidden className="ml-0.5 size-1 rounded-full bg-destructive" />}
|
|
391
|
+
</button>
|
|
392
|
+
{(open || live) && (
|
|
393
|
+
<div className="mt-0.5 ml-1.5 flex flex-col border-l border-hairline pl-3">
|
|
394
|
+
{open ? (
|
|
395
|
+
tools.map((tool) => <ToolRow key={tool.id} item={tool} />)
|
|
396
|
+
) : live ? (
|
|
397
|
+
<ToolRow item={live} />
|
|
398
|
+
) : null}
|
|
399
|
+
</div>
|
|
400
|
+
)}
|
|
401
|
+
</div>
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function ErrorItem({ text }: { text: string }) {
|
|
406
|
+
const t = useLocale();
|
|
407
|
+
return (
|
|
408
|
+
<div className="rounded-[6px] border border-destructive/30 bg-destructive/5 px-3 py-2">
|
|
409
|
+
<span className="eyebrow flex items-center gap-1 text-destructive/80">
|
|
410
|
+
<CircleAlert className="size-3" aria-hidden />
|
|
411
|
+
{t.agent.errorEyebrow}
|
|
412
|
+
</span>
|
|
413
|
+
<pre className="mt-1 whitespace-pre-wrap font-mono text-[11px] leading-relaxed text-foreground/85">
|
|
414
|
+
{text}
|
|
415
|
+
</pre>
|
|
416
|
+
</div>
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function ResultItem({ item }: { item: Extract<AgentItem, { kind: 'result' }> }) {
|
|
421
|
+
const t = useLocale();
|
|
422
|
+
const parts = [item.aborted ? t.agent.resultStopped : t.agent.resultDone];
|
|
423
|
+
if (item.durationMs !== null) {
|
|
424
|
+
parts.push(
|
|
425
|
+
format(t.agent.resultSeconds, { seconds: Math.max(1, Math.round(item.durationMs / 1000)) }),
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
if (item.costUsd !== null && item.costUsd > 0) parts.push(`$${item.costUsd.toFixed(2)}`);
|
|
429
|
+
return <p className="folio text-muted-foreground/70">{parts.join(' · ')}</p>;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function PermissionItem({
|
|
433
|
+
item,
|
|
434
|
+
onRespond,
|
|
435
|
+
}: {
|
|
436
|
+
item: Extract<AgentItem, { kind: 'permission' }>;
|
|
437
|
+
onRespond: (payload: RespondPayload) => Promise<void>;
|
|
438
|
+
}) {
|
|
439
|
+
const t = useLocale();
|
|
440
|
+
const [busy, runBusy] = useBusy();
|
|
441
|
+
const decide = (allow: boolean) => runBusy(() => onRespond({ allow }));
|
|
442
|
+
|
|
443
|
+
if (item.status !== 'pending') {
|
|
444
|
+
return (
|
|
445
|
+
<p className="flex min-w-0 items-center gap-2 text-[11.5px] leading-5 text-muted-foreground">
|
|
446
|
+
<span
|
|
447
|
+
aria-hidden
|
|
448
|
+
className={cn(
|
|
449
|
+
'size-1 shrink-0 rounded-full',
|
|
450
|
+
item.status === 'allowed' ? 'bg-muted-foreground/40' : 'bg-destructive',
|
|
451
|
+
)}
|
|
452
|
+
/>
|
|
453
|
+
<span className="shrink-0">
|
|
454
|
+
{item.status === 'allowed' ? t.agent.allowed : t.agent.denied}
|
|
455
|
+
</span>
|
|
456
|
+
<span className="min-w-0 truncate text-foreground/70">{item.title}</span>
|
|
457
|
+
</p>
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return (
|
|
462
|
+
<div className="rounded-[8px] border border-hairline bg-background px-3 py-2.5">
|
|
463
|
+
<p className="text-[11.5px] text-muted-foreground">{t.agent.permissionTitle}</p>
|
|
464
|
+
<p className="mt-0.5 text-[12.5px] font-medium leading-snug">{item.title}</p>
|
|
465
|
+
{item.detail && (
|
|
466
|
+
<pre className="mt-1.5 line-clamp-4 whitespace-pre-wrap break-all font-mono text-[10.5px] leading-relaxed text-muted-foreground">
|
|
467
|
+
{item.detail}
|
|
468
|
+
</pre>
|
|
469
|
+
)}
|
|
470
|
+
<div className="mt-2.5 flex justify-end gap-1">
|
|
471
|
+
<Button size="xs" variant="ghost" disabled={busy} onClick={() => decide(false)}>
|
|
472
|
+
{t.agent.deny}
|
|
473
|
+
</Button>
|
|
474
|
+
<Button size="xs" disabled={busy} onClick={() => decide(true)}>
|
|
475
|
+
{t.agent.allow}
|
|
476
|
+
</Button>
|
|
477
|
+
</div>
|
|
478
|
+
</div>
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function QuestionItem({
|
|
483
|
+
item,
|
|
484
|
+
onRespond,
|
|
485
|
+
}: {
|
|
486
|
+
item: Extract<AgentItem, { kind: 'question' }>;
|
|
487
|
+
onRespond: (payload: RespondPayload) => Promise<void>;
|
|
488
|
+
}) {
|
|
489
|
+
const t = useLocale();
|
|
490
|
+
const [picked, setPicked] = useState<Record<string, string[]>>({});
|
|
491
|
+
const [other, setOther] = useState<Record<string, string>>({});
|
|
492
|
+
const [busy, runBusy] = useBusy();
|
|
493
|
+
|
|
494
|
+
const answerFor = (q: AgentQuestion): string | null => {
|
|
495
|
+
const custom = other[q.question]?.trim();
|
|
496
|
+
const chosen = picked[q.question] ?? [];
|
|
497
|
+
const parts = [...chosen, ...(custom ? [custom] : [])];
|
|
498
|
+
return parts.length ? parts.join(', ') : null;
|
|
499
|
+
};
|
|
500
|
+
const complete = item.questions.every((q) => answerFor(q) !== null);
|
|
501
|
+
|
|
502
|
+
const reply = (payload: RespondPayload) => runBusy(() => onRespond(payload));
|
|
503
|
+
const submit = () => {
|
|
504
|
+
const answers: Record<string, string> = {};
|
|
505
|
+
for (const q of item.questions) answers[q.question] = answerFor(q) ?? '';
|
|
506
|
+
void reply({ answers });
|
|
507
|
+
};
|
|
508
|
+
const toggle = (q: AgentQuestion, label: string) =>
|
|
509
|
+
setPicked((prev) => {
|
|
510
|
+
const current = prev[q.question] ?? [];
|
|
511
|
+
const on = current.includes(label);
|
|
512
|
+
const next = q.multiSelect
|
|
513
|
+
? on
|
|
514
|
+
? current.filter((l) => l !== label)
|
|
515
|
+
: [...current, label]
|
|
516
|
+
: on
|
|
517
|
+
? []
|
|
518
|
+
: [label];
|
|
519
|
+
return { ...prev, [q.question]: next };
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
if (item.status !== 'pending') {
|
|
523
|
+
return (
|
|
524
|
+
<div className="flex flex-col gap-0.5">
|
|
525
|
+
{item.questions.map((q) => (
|
|
526
|
+
<p key={q.question} className="text-[11.5px] leading-5 text-muted-foreground">
|
|
527
|
+
<span>{q.header || q.question}</span>
|
|
528
|
+
<span aria-hidden className="mx-1.5">
|
|
529
|
+
·
|
|
530
|
+
</span>
|
|
531
|
+
<span className="text-foreground/80">
|
|
532
|
+
{item.status === 'answered'
|
|
533
|
+
? item.answers?.[q.question] || '—'
|
|
534
|
+
: t.agent.questionCancelled}
|
|
535
|
+
</span>
|
|
536
|
+
</p>
|
|
537
|
+
))}
|
|
538
|
+
</div>
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
return (
|
|
543
|
+
<div className="rounded-[8px] border border-hairline bg-background px-1.5 pt-2.5 pb-2">
|
|
544
|
+
<div className="flex flex-col gap-3">
|
|
545
|
+
{item.questions.map((q) => (
|
|
546
|
+
<fieldset key={q.question} className="min-w-0">
|
|
547
|
+
<legend className="px-1.5 text-[12.5px] font-medium leading-snug">{q.question}</legend>
|
|
548
|
+
<div className="mt-1 flex flex-col">
|
|
549
|
+
{q.options.map((option) => {
|
|
550
|
+
const selected = (picked[q.question] ?? []).includes(option.label);
|
|
551
|
+
return (
|
|
552
|
+
<button
|
|
553
|
+
key={option.label}
|
|
554
|
+
type="button"
|
|
555
|
+
aria-pressed={selected}
|
|
556
|
+
onClick={() => toggle(q, option.label)}
|
|
557
|
+
className="flex items-start gap-2 rounded-[5px] px-1.5 py-1 text-left outline-none transition-colors duration-100 hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring/30"
|
|
558
|
+
>
|
|
559
|
+
<span
|
|
560
|
+
aria-hidden
|
|
561
|
+
className={cn(
|
|
562
|
+
'mt-[3px] grid size-3 shrink-0 place-items-center border transition-colors duration-100',
|
|
563
|
+
q.multiSelect ? 'rounded-[3px]' : 'rounded-full',
|
|
564
|
+
selected
|
|
565
|
+
? 'border-foreground bg-foreground text-background'
|
|
566
|
+
: 'border-muted-foreground/40',
|
|
567
|
+
)}
|
|
568
|
+
>
|
|
569
|
+
{selected && <Check className="size-2" strokeWidth={3} />}
|
|
570
|
+
</span>
|
|
571
|
+
<span className="min-w-0">
|
|
572
|
+
<span className="block text-[12px] leading-snug">{option.label}</span>
|
|
573
|
+
{option.description && (
|
|
574
|
+
<span className="block text-[11px] leading-snug text-muted-foreground">
|
|
575
|
+
{option.description}
|
|
576
|
+
</span>
|
|
577
|
+
)}
|
|
578
|
+
</span>
|
|
579
|
+
</button>
|
|
580
|
+
);
|
|
581
|
+
})}
|
|
582
|
+
<input
|
|
583
|
+
value={other[q.question] ?? ''}
|
|
584
|
+
onChange={(e) => setOther((prev) => ({ ...prev, [q.question]: e.target.value }))}
|
|
585
|
+
onKeyDown={(e) => {
|
|
586
|
+
if (e.key === 'Enter' && !e.nativeEvent.isComposing && complete && !busy) {
|
|
587
|
+
e.preventDefault();
|
|
588
|
+
submit();
|
|
589
|
+
}
|
|
590
|
+
}}
|
|
591
|
+
placeholder={`${t.agent.questionOther}…`}
|
|
592
|
+
aria-label={t.agent.questionOtherPlaceholder}
|
|
593
|
+
className="mx-1.5 mt-1 h-7 border-b border-hairline bg-transparent text-[12px] outline-none placeholder:text-muted-foreground/60 focus-visible:border-foreground/40"
|
|
594
|
+
/>
|
|
595
|
+
</div>
|
|
596
|
+
</fieldset>
|
|
597
|
+
))}
|
|
598
|
+
</div>
|
|
599
|
+
<div className="mt-2.5 flex justify-end gap-1 px-1">
|
|
600
|
+
<Button size="xs" variant="ghost" disabled={busy} onClick={() => reply({ cancel: true })}>
|
|
601
|
+
{t.agent.questionSkip}
|
|
602
|
+
</Button>
|
|
603
|
+
<Button size="xs" disabled={busy || !complete} onClick={submit}>
|
|
604
|
+
{t.agent.questionSubmit}
|
|
605
|
+
</Button>
|
|
606
|
+
</div>
|
|
607
|
+
</div>
|
|
608
|
+
);
|
|
609
|
+
}
|