@kywk/open-slide-core 2.3.1 → 2.3.2

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.
Files changed (50) hide show
  1. package/dist/{build-CZ6Qtrng.js → build-46dO14h1.js} +1 -1
  2. package/dist/cli/bin.js +4 -4
  3. package/dist/{config-CpmGqpQi.d.ts → config-CDrYgaEj.d.ts} +6 -2
  4. package/dist/{config-B5LArqpp.js → config-KyYvWqq-.js} +2061 -355
  5. package/dist/{dev-CCgdakbZ.js → dev-C0n-o0-c.js} +2 -2
  6. package/dist/index.d.ts +3 -3
  7. package/dist/index.js +73 -32
  8. package/dist/locale/index.d.ts +1 -1
  9. package/dist/locale/index.js +1 -1
  10. package/dist/{preview-AwAqvG17.js → preview-CDWAGSfB.js} +1 -1
  11. package/dist/{types-CvAqFqsY.d.ts → types-DFK02XVv.d.ts} +110 -0
  12. package/dist/{update-C_RlnKu4.js → update-CqZqQRQk.js} +128 -7
  13. package/dist/vite/index.d.ts +1 -1
  14. package/dist/vite/index.js +1 -1
  15. package/dist/{zh-tw-B7_Oqv2q.js → zh-tw-B1pkKQsc.js} +535 -0
  16. package/package.json +9 -2
  17. package/src/agent/prompt.ts +141 -0
  18. package/src/agent/provider.ts +123 -0
  19. package/src/agent/types.ts +151 -0
  20. package/src/app/components/agent/agent-dock.tsx +35 -0
  21. package/src/app/components/agent/agent-markdown.tsx +45 -0
  22. package/src/app/components/agent/agent-panel.tsx +692 -0
  23. package/src/app/components/agent/agent-presence.tsx +74 -0
  24. package/src/app/components/agent/composer.tsx +120 -0
  25. package/src/app/components/agent/connect-view.tsx +184 -0
  26. package/src/app/components/agent/history-view.tsx +135 -0
  27. package/src/app/components/agent/provider-logo.tsx +34 -0
  28. package/src/app/components/agent/transcript.tsx +609 -0
  29. package/src/app/components/command/slide-command-menu.tsx +10 -0
  30. package/src/app/components/inline-svg.tsx +52 -21
  31. package/src/app/components/inspector/comment-layer.tsx +20 -1
  32. package/src/app/components/inspector/image-crop-dialog.tsx +1 -5
  33. package/src/app/components/inspector/inspector-panel.tsx +1 -1
  34. package/src/app/components/mermaid.tsx +42 -11
  35. package/src/app/components/panel/editor-sidebar.tsx +85 -16
  36. package/src/app/components/panel/panel-shell.tsx +2 -2
  37. package/src/app/components/panel/resize-handle.tsx +129 -0
  38. package/src/app/lib/agent/use-agent.ts +543 -0
  39. package/src/app/lib/use-auto-grow.ts +15 -0
  40. package/src/app/lib/utils.ts +4 -0
  41. package/src/app/routes/home.tsx +43 -7
  42. package/src/app/routes/slide.tsx +103 -104
  43. package/src/app/styles.css +170 -0
  44. package/src/editing/placeholder-id.ts +1 -0
  45. package/src/locale/en.ts +106 -0
  46. package/src/locale/es.ts +108 -0
  47. package/src/locale/ja.ts +99 -0
  48. package/src/locale/types.ts +109 -0
  49. package/src/locale/zh-cn.ts +96 -0
  50. package/src/locale/zh-tw.ts +96 -0
@@ -0,0 +1,543 @@
1
+ import { useCallback, useEffect, useSyncExternalStore } from 'react';
2
+ import { toast } from 'sonner';
3
+ import { useLocale } from '@/lib/use-locale';
4
+ import {
5
+ type AgentEffort,
6
+ type AgentItem,
7
+ type AgentModelOption,
8
+ type AgentProviderId,
9
+ type AgentSession,
10
+ type AgentSessionSummary,
11
+ type AgentStatus,
12
+ type AgentStreamEvent,
13
+ type AgentTurnContext,
14
+ isAgentEffort,
15
+ isAgentProviderId,
16
+ type RespondPayload,
17
+ } from '../../../agent/types';
18
+
19
+ type ModelChoice = { model: string; effort: AgentEffort | null };
20
+
21
+ type AgentRequest = { text: string };
22
+
23
+ type State = {
24
+ status: AgentStatus | null;
25
+ statusError: boolean;
26
+ sessions: AgentSessionSummary[];
27
+ sessionsLoaded: boolean;
28
+ full: Record<string, AgentSession>;
29
+ activeId: string | null;
30
+ provider: AgentProviderId;
31
+ modelChoices: Partial<Record<AgentProviderId, ModelChoice>>;
32
+ autoApprove: boolean;
33
+ dockOpen: boolean;
34
+ request: AgentRequest | null;
35
+ installing: AgentProviderId | null;
36
+ connectErrors: Partial<Record<AgentProviderId, string>>;
37
+ newDeck: string | null;
38
+ };
39
+
40
+ const ACTIVE_KEY = 'open-slide:agent-session';
41
+ const PROVIDER_KEY = 'open-slide:agent-provider';
42
+ const MODEL_KEY = 'open-slide:agent-model';
43
+ const AUTO_APPROVE_KEY = 'open-slide:agent-auto-approve';
44
+ const DOCK_KEY = 'open-slide:agent-dock';
45
+ const NEW_DECK_KEY = 'open-slide:agent-new-deck';
46
+
47
+ function readStorage(key: string): string | null {
48
+ try {
49
+ return localStorage.getItem(key);
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ function writeStorage(key: string, value: string | null) {
56
+ try {
57
+ if (value === null) localStorage.removeItem(key);
58
+ else localStorage.setItem(key, value);
59
+ } catch {}
60
+ }
61
+
62
+ function basePath(): string {
63
+ return import.meta.env.BASE_URL.replace(/\/$/, '');
64
+ }
65
+
66
+ function readModelChoices(): Partial<Record<AgentProviderId, ModelChoice>> {
67
+ const choices: Partial<Record<AgentProviderId, ModelChoice>> = {};
68
+ try {
69
+ const raw = JSON.parse(readStorage(MODEL_KEY) ?? '{}') as Record<string, unknown>;
70
+ for (const [provider, value] of Object.entries(raw)) {
71
+ if (!isAgentProviderId(provider) || !value || typeof value !== 'object') continue;
72
+ const v = value as Record<string, unknown>;
73
+ if (typeof v.model !== 'string') continue;
74
+ choices[provider] = { model: v.model, effort: isAgentEffort(v.effort) ? v.effort : null };
75
+ }
76
+ } catch {}
77
+ return choices;
78
+ }
79
+
80
+ const available = import.meta.env.DEV && typeof window !== 'undefined';
81
+
82
+ let state: State = {
83
+ status: null,
84
+ statusError: false,
85
+ sessions: [],
86
+ sessionsLoaded: false,
87
+ full: {},
88
+ activeId: readStorage(ACTIVE_KEY),
89
+ provider: (() => {
90
+ const stored = readStorage(PROVIDER_KEY);
91
+ return isAgentProviderId(stored) ? stored : 'claude';
92
+ })(),
93
+ modelChoices: readModelChoices(),
94
+ autoApprove: readStorage(AUTO_APPROVE_KEY) === '1',
95
+ dockOpen: readStorage(DOCK_KEY) === '1',
96
+ request: null,
97
+ installing: null,
98
+ connectErrors: {},
99
+ newDeck: readStorage(NEW_DECK_KEY),
100
+ };
101
+
102
+ const listeners = new Set<() => void>();
103
+
104
+ function setState(patch: Partial<State> | ((prev: State) => Partial<State>)) {
105
+ const next = typeof patch === 'function' ? patch(state) : patch;
106
+ state = { ...state, ...next };
107
+ for (const l of listeners) l();
108
+ }
109
+
110
+ function subscribe(listener: () => void): () => void {
111
+ listeners.add(listener);
112
+ return () => {
113
+ listeners.delete(listener);
114
+ };
115
+ }
116
+
117
+ function getSnapshot(): State {
118
+ return state;
119
+ }
120
+
121
+ export function useAgentState(): State {
122
+ return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
123
+ }
124
+
125
+ let stream: EventSource | null = null;
126
+ let streamOpened = false;
127
+
128
+ function syncStream(enabled: boolean) {
129
+ if (!enabled) {
130
+ stream?.close();
131
+ stream = null;
132
+ return;
133
+ }
134
+ if (stream) return;
135
+ stream = new EventSource('/__agent/events');
136
+ stream.onmessage = (message) => applyEvent(JSON.parse(message.data) as AgentStreamEvent);
137
+ stream.onopen = () => {
138
+ // Anything streamed while disconnected is gone, so reload what is on screen.
139
+ if (!streamOpened) {
140
+ streamOpened = true;
141
+ return;
142
+ }
143
+ void refreshStatus();
144
+ if (state.sessionsLoaded) void loadSessions();
145
+ for (const id of Object.keys(state.full)) void loadSession(id);
146
+ };
147
+ }
148
+
149
+ function upsertSummary(list: AgentSessionSummary[], summary: AgentSessionSummary) {
150
+ const rest = list.filter((s) => s.id !== summary.id);
151
+ return [summary, ...rest].sort((a, b) =>
152
+ a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : 0,
153
+ );
154
+ }
155
+
156
+ function applyEvent(event: AgentStreamEvent) {
157
+ switch (event.type) {
158
+ case 'session':
159
+ setState((prev) => {
160
+ const current = prev.full[event.session.id];
161
+ return {
162
+ sessions: upsertSummary(prev.sessions, event.session),
163
+ full: current
164
+ ? { ...prev.full, [event.session.id]: { ...current, ...event.session } }
165
+ : prev.full,
166
+ };
167
+ });
168
+ return;
169
+ case 'session-removed':
170
+ setState((prev) => {
171
+ const { [event.sessionId]: _gone, ...full } = prev.full;
172
+ return {
173
+ sessions: prev.sessions.filter((s) => s.id !== event.sessionId),
174
+ full,
175
+ activeId: prev.activeId === event.sessionId ? null : prev.activeId,
176
+ };
177
+ });
178
+ return;
179
+ case 'item':
180
+ setState((prev) => {
181
+ const current = prev.full[event.sessionId];
182
+ if (!current) return {};
183
+ const index = current.items.findIndex((i) => i.id === event.item.id);
184
+ const items =
185
+ index === -1
186
+ ? [...current.items, event.item]
187
+ : current.items.map((i, n) => (n === index ? event.item : i));
188
+ return { full: { ...prev.full, [event.sessionId]: { ...current, items } } };
189
+ });
190
+ return;
191
+ case 'truncate':
192
+ setState((prev) => {
193
+ const current = prev.full[event.sessionId];
194
+ const index = current?.items.findIndex((i) => i.id === event.itemId) ?? -1;
195
+ if (!current || index === -1) return {};
196
+ const items = current.items.slice(0, index);
197
+ return { full: { ...prev.full, [event.sessionId]: { ...current, items } } };
198
+ });
199
+ return;
200
+ case 'slide-renamed':
201
+ followSlideRename(event.from, event.to);
202
+ return;
203
+ case 'delta':
204
+ setState((prev) => {
205
+ const current = prev.full[event.sessionId];
206
+ if (!current) return {};
207
+ const items = current.items.map((i): AgentItem => {
208
+ if (i.id !== event.itemId || i.kind !== 'assistant') return i;
209
+ return { ...i, text: i.text + event.text };
210
+ });
211
+ return { full: { ...prev.full, [event.sessionId]: { ...current, items } } };
212
+ });
213
+ return;
214
+ }
215
+ }
216
+
217
+ function followSlideRename(from: string, to: string) {
218
+ const base = basePath();
219
+ const prefix = `${base}/s/${encodeURIComponent(from)}`;
220
+ const { pathname, search, hash } = window.location;
221
+ if (pathname === prefix || pathname.startsWith(`${prefix}/`)) {
222
+ const rest = pathname.slice(prefix.length);
223
+ window.location.replace(`${base}/s/${encodeURIComponent(to)}${rest}${search}${hash}`);
224
+ } else if (!pathname.startsWith(`${base}/s/`)) {
225
+ // The slide list is baked into the bundle, so pages listing decks reload
226
+ // to pick up the new id. Another deck's page may hold unsaved edits, so it
227
+ // is left alone and catches up on its next load.
228
+ window.location.reload();
229
+ }
230
+ }
231
+
232
+ async function request<T>(path: string, init?: RequestInit): Promise<T> {
233
+ const res = await fetch(path, init);
234
+ if (!res.ok) {
235
+ const body = (await res.json().catch(() => ({}))) as { error?: string };
236
+ throw new Error(body.error ?? `${init?.method ?? 'GET'} ${path} → ${res.status}`);
237
+ }
238
+ return (await res.json()) as T;
239
+ }
240
+
241
+ function post(body?: unknown): RequestInit {
242
+ return body === undefined
243
+ ? { method: 'POST' }
244
+ : {
245
+ method: 'POST',
246
+ headers: { 'content-type': 'application/json' },
247
+ body: JSON.stringify(body),
248
+ };
249
+ }
250
+
251
+ export async function refreshStatus(): Promise<void> {
252
+ if (!available) return;
253
+ try {
254
+ const status = await request<AgentStatus>('/__agent/status');
255
+ setState({ status, statusError: false });
256
+ syncStream(status.enabled);
257
+ } catch {
258
+ setState({ statusError: true });
259
+ }
260
+ }
261
+
262
+ async function loadSessions(): Promise<void> {
263
+ if (!available) return;
264
+ try {
265
+ const { sessions } = await request<{ sessions: AgentSessionSummary[] }>('/__agent/sessions');
266
+ setState((prev) => ({
267
+ sessions,
268
+ sessionsLoaded: true,
269
+ activeId:
270
+ prev.activeId && !sessions.some((s) => s.id === prev.activeId) ? null : prev.activeId,
271
+ }));
272
+ } catch {
273
+ setState({ sessionsLoaded: true });
274
+ }
275
+ }
276
+
277
+ async function loadSession(id: string): Promise<AgentSession | null> {
278
+ if (!available) return null;
279
+ try {
280
+ const session = await request<AgentSession>(`/__agent/sessions/${encodeURIComponent(id)}`);
281
+ setState((prev) => ({ full: { ...prev.full, [id]: session } }));
282
+ return session;
283
+ } catch {
284
+ setState((prev) => {
285
+ const { [id]: _gone, ...full } = prev.full;
286
+ return { full, activeId: prev.activeId === id ? null : prev.activeId };
287
+ });
288
+ return null;
289
+ }
290
+ }
291
+
292
+ export async function createSession(
293
+ provider: AgentProviderId,
294
+ slideId: string | null,
295
+ ): Promise<AgentSession> {
296
+ const session = await request<AgentSession>('/__agent/sessions', post({ provider, slideId }));
297
+ setState((prev) => ({
298
+ full: { ...prev.full, [session.id]: session },
299
+ sessions: upsertSummary(prev.sessions, session),
300
+ }));
301
+ setActiveSession(session.id);
302
+ return session;
303
+ }
304
+
305
+ export async function deleteSession(id: string): Promise<void> {
306
+ await request(`/__agent/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' });
307
+ applyEvent({ type: 'session-removed', sessionId: id });
308
+ }
309
+
310
+ function turnSettings(id: string) {
311
+ const provider = state.full[id]?.provider ?? state.provider;
312
+ const status = state.status?.providers.find((p) => p.id === provider);
313
+ const choice = status ? resolveModelChoice(status.models, state.modelChoices[provider]) : null;
314
+ return {
315
+ autoApprove: state.autoApprove,
316
+ model: choice?.model.id || null,
317
+ effort: choice?.effort ?? null,
318
+ };
319
+ }
320
+
321
+ export async function sendMessage(
322
+ id: string,
323
+ text: string,
324
+ context: AgentTurnContext | undefined,
325
+ edit?: string,
326
+ ): Promise<void> {
327
+ await request(
328
+ `/__agent/sessions/${encodeURIComponent(id)}/messages`,
329
+ post({ text, context, ...turnSettings(id), edit }),
330
+ );
331
+ }
332
+
333
+ export async function stopSession(id: string): Promise<void> {
334
+ await request(`/__agent/sessions/${encodeURIComponent(id)}/stop`, post());
335
+ }
336
+
337
+ // Answers to questions that ended the turn start the next one, so they carry
338
+ // the same settings and context a new message would.
339
+ export async function respond(
340
+ id: string,
341
+ itemId: string,
342
+ payload: RespondPayload,
343
+ context: AgentTurnContext | undefined,
344
+ ): Promise<void> {
345
+ await request(
346
+ `/__agent/sessions/${encodeURIComponent(id)}/respond`,
347
+ post({ itemId, ...payload, context, ...turnSettings(id) }),
348
+ );
349
+ }
350
+
351
+ export async function installProvider(provider: AgentProviderId): Promise<void> {
352
+ setState((prev) => ({
353
+ installing: provider,
354
+ connectErrors: { ...prev.connectErrors, [provider]: undefined },
355
+ }));
356
+ try {
357
+ await request('/__agent/install', post({ provider }));
358
+ await refreshStatus();
359
+ } catch (err) {
360
+ const message = err instanceof Error ? err.message : String(err);
361
+ setState((prev) => ({ connectErrors: { ...prev.connectErrors, [provider]: message } }));
362
+ throw err;
363
+ } finally {
364
+ setState({ installing: null });
365
+ }
366
+ }
367
+
368
+ let autoConnecting: Promise<void> | null = null;
369
+
370
+ export function autoConnect(): Promise<void> {
371
+ if (autoConnecting) return autoConnecting;
372
+ const status = state.status;
373
+ if (!status?.enabled) return Promise.resolve();
374
+ const pending = status.providers.filter(
375
+ (p) => p.cliFound && !p.ready && !state.connectErrors[p.id],
376
+ );
377
+ if (pending.length === 0) return Promise.resolve();
378
+ autoConnecting = (async () => {
379
+ for (const p of pending) {
380
+ await installProvider(p.id).catch(() => {});
381
+ }
382
+ })().finally(() => {
383
+ autoConnecting = null;
384
+ });
385
+ return autoConnecting;
386
+ }
387
+
388
+ export function setActiveSession(id: string | null): void {
389
+ writeStorage(ACTIVE_KEY, id);
390
+ setState({ activeId: id });
391
+ }
392
+
393
+ export function setModelChoice(provider: AgentProviderId, choice: ModelChoice): void {
394
+ const modelChoices = { ...state.modelChoices, [provider]: choice };
395
+ writeStorage(MODEL_KEY, JSON.stringify(modelChoices));
396
+ writeStorage(PROVIDER_KEY, provider);
397
+ setState({ modelChoices, provider });
398
+ }
399
+
400
+ export function resolveModelChoice(
401
+ models: AgentModelOption[],
402
+ choice: ModelChoice | undefined,
403
+ ): { model: AgentModelOption; effort: AgentEffort | null } | null {
404
+ const model = models.find((m) => m.id === choice?.model) ?? models[0];
405
+ if (!model) return null;
406
+ const effort = choice?.effort && model.efforts.includes(choice.effort) ? choice.effort : null;
407
+ return { model, effort };
408
+ }
409
+
410
+ export function setAutoApprove(value: boolean): void {
411
+ writeStorage(AUTO_APPROVE_KEY, value ? '1' : null);
412
+ setState({ autoApprove: value });
413
+ }
414
+
415
+ export function setDockOpen(open: boolean): void {
416
+ writeStorage(DOCK_KEY, open ? '1' : null);
417
+ setState({ dockOpen: open });
418
+ }
419
+
420
+ export function requestAgent(req: AgentRequest): void {
421
+ setState({ request: req, dockOpen: true });
422
+ writeStorage(DOCK_KEY, '1');
423
+ }
424
+
425
+ // The blank deck is created up front and the user lands in it with the dock
426
+ // open; their first message there becomes the brief the agent drafts from.
427
+ let creatingDeck: Promise<void> | null = null;
428
+
429
+ function startBlankDeck(): Promise<void> {
430
+ creatingDeck ??= (async () => {
431
+ const { slideId } = await request<{ slideId: string }>('/__slides', post());
432
+ writeStorage(NEW_DECK_KEY, slideId);
433
+ setActiveSession(null);
434
+ setDockOpen(true);
435
+ window.location.assign(`${basePath()}/s/${encodeURIComponent(slideId)}`);
436
+ })().finally(() => {
437
+ creatingDeck = null;
438
+ });
439
+ return creatingDeck;
440
+ }
441
+
442
+ export function useCreateDeck(): () => Promise<void> {
443
+ const t = useLocale();
444
+ return useCallback(async () => {
445
+ try {
446
+ await startBlankDeck();
447
+ } catch (err) {
448
+ toast.error(t.agent.toastCreateDeckFailed, {
449
+ description: err instanceof Error ? err.message : undefined,
450
+ });
451
+ }
452
+ }, [t]);
453
+ }
454
+
455
+ export function clearNewDeck(): void {
456
+ writeStorage(NEW_DECK_KEY, null);
457
+ setState({ newDeck: null });
458
+ }
459
+
460
+ export function consumeRequest(): AgentRequest | null {
461
+ const req = state.request;
462
+ if (req) setState({ request: null });
463
+ return req;
464
+ }
465
+
466
+ export function readyProviders(status: AgentStatus | null) {
467
+ return status?.enabled ? status.providers.filter((p) => p.ready) : [];
468
+ }
469
+
470
+ function resolveProvider(status: AgentStatus | null, preferred: AgentProviderId) {
471
+ const ready = readyProviders(status);
472
+ return ready.find((p) => p.id === preferred) ?? ready[0] ?? null;
473
+ }
474
+
475
+ export function useAgentStatus() {
476
+ const s = useAgentState();
477
+ useEffect(() => {
478
+ if (!available || s.status) return;
479
+ void refreshStatus();
480
+ }, [s.status]);
481
+ const provider = resolveProvider(s.status, s.provider);
482
+ const choice = provider ? resolveModelChoice(provider.models, s.modelChoices[provider.id]) : null;
483
+ return {
484
+ status: s.status,
485
+ loading: s.status === null && !s.statusError,
486
+ enabled: s.status?.enabled ?? false,
487
+ connected: provider !== null,
488
+ provider,
489
+ providerName: provider?.name ?? null,
490
+ model: choice?.model ?? null,
491
+ effort: choice?.effort ?? null,
492
+ modelChoices: s.modelChoices,
493
+ };
494
+ }
495
+
496
+ export function useAgentSessions() {
497
+ const s = useAgentState();
498
+ useEffect(() => {
499
+ if (!available || s.sessionsLoaded) return;
500
+ void loadSessions();
501
+ }, [s.sessionsLoaded]);
502
+ return { sessions: s.sessions, loaded: s.sessionsLoaded, activeId: s.activeId };
503
+ }
504
+
505
+ export function useAgentSession(id: string | null): AgentSession | null {
506
+ const s = useAgentState();
507
+ const session = id ? (s.full[id] ?? null) : null;
508
+ useEffect(() => {
509
+ if (!available || !id || session) return;
510
+ void loadSession(id);
511
+ }, [id, session]);
512
+ return session;
513
+ }
514
+
515
+ export type AgentActivity = {
516
+ provider: AgentProviderId;
517
+ phase: 'thinking' | 'tool' | 'waiting';
518
+ title: string | null;
519
+ detail: string | null;
520
+ };
521
+
522
+ export function useAgentActivity(slideId: string): AgentActivity | null {
523
+ const { sessions } = useAgentSessions();
524
+ const summary = sessions.find((s) => s.slideId === slideId && s.status === 'running') ?? null;
525
+ const session = useAgentSession(summary?.id ?? null);
526
+ if (!summary) return null;
527
+ const items = session?.items ?? [];
528
+ const waiting = items.some(
529
+ (i) => (i.kind === 'question' || i.kind === 'permission') && i.status === 'pending',
530
+ );
531
+ if (waiting) return { provider: summary.provider, phase: 'waiting', title: null, detail: null };
532
+ const last = items.at(-1);
533
+ if (last?.kind === 'tool' && last.status === 'running') {
534
+ return { provider: summary.provider, phase: 'tool', title: last.title, detail: last.detail };
535
+ }
536
+ return { provider: summary.provider, phase: 'thinking', title: null, detail: null };
537
+ }
538
+
539
+ export function useAgentDock() {
540
+ const s = useAgentState();
541
+ const toggle = useCallback(() => setDockOpen(!state.dockOpen), []);
542
+ return { open: s.dockOpen, setOpen: setDockOpen, toggle };
543
+ }
@@ -0,0 +1,15 @@
1
+ import { type RefObject, useLayoutEffect } from 'react';
2
+
3
+ export function useAutoGrow(
4
+ ref: RefObject<HTMLTextAreaElement | null>,
5
+ value: string,
6
+ max: number,
7
+ ): void {
8
+ // biome-ignore lint/correctness/useExhaustiveDependencies: re-measure whenever the text changes
9
+ useLayoutEffect(() => {
10
+ const node = ref.current;
11
+ if (!node) return;
12
+ node.style.height = '0px';
13
+ node.style.height = `${Math.min(max, Math.max(40, node.scrollHeight))}px`;
14
+ }, [ref, value, max]);
15
+ }
@@ -9,6 +9,10 @@ export function round2(n: number): number {
9
9
  return Math.round(n * 100) / 100;
10
10
  }
11
11
 
12
+ export function clamp(value: number, min: number, max: number): number {
13
+ return Math.min(max, Math.max(min, value));
14
+ }
15
+
12
16
  export function pad2(n: number): string {
13
17
  return n.toString().padStart(2, '0');
14
18
  }
@@ -8,6 +8,7 @@ import {
8
8
  MoreHorizontal,
9
9
  Palette,
10
10
  Pencil,
11
+ Plus,
11
12
  Search,
12
13
  Trash2,
13
14
  X,
@@ -30,8 +31,10 @@ import {
30
31
  DropdownMenuItem,
31
32
  DropdownMenuTrigger,
32
33
  } from '@/components/ui/dropdown-menu';
34
+ import { useAgentStatus, useCreateDeck } from '@/lib/agent/use-agent';
33
35
  import { format, useLocale } from '@/lib/use-locale';
34
36
  import { cn, pad2 } from '@/lib/utils';
37
+ import { ProviderLogo } from '../components/agent/provider-logo';
35
38
  import { FolderIconChip, SLIDE_DND_MIME, SystemViewIcon } from '../components/sidebar/folder-item';
36
39
  import { ALL_SLIDES_ID, DRAFT_ID } from '../components/sidebar/sidebar';
37
40
  import { SlideCanvas } from '../components/slide-canvas';
@@ -193,6 +196,7 @@ export function Home() {
193
196
  <div className="ml-auto flex w-full items-center gap-2 md:w-auto">
194
197
  <SortControl value={sortKey} onChange={setSortKey} />
195
198
  <SearchInput value={query} onChange={setQuery} />
199
+ {import.meta.env.DEV && <NewDeckButton />}
196
200
  </div>
197
201
  </div>
198
202
  </header>
@@ -319,6 +323,30 @@ function SortControl({ value, onChange }: { value: SortKey; onChange: (next: Sor
319
323
  );
320
324
  }
321
325
 
326
+ function useCreateDeckWithAgent() {
327
+ const { provider } = useAgentStatus();
328
+ const start = useCreateDeck();
329
+ if (!provider) return null;
330
+ return { provider, start };
331
+ }
332
+
333
+ function NewDeckButton() {
334
+ const t = useLocale();
335
+ const agent = useCreateDeckWithAgent();
336
+ if (!agent) return null;
337
+ return (
338
+ <Button
339
+ size="sm"
340
+ className="h-8 shrink-0"
341
+ onClick={agent.start}
342
+ title={format(t.agent.createDeckWith, { provider: agent.provider.name })}
343
+ >
344
+ <Plus />
345
+ {t.agent.createDeck}
346
+ </Button>
347
+ );
348
+ }
349
+
322
350
  function HomeLoading() {
323
351
  const t = useLocale();
324
352
  return (
@@ -360,6 +388,7 @@ function NoResultsState({ query, onClear }: { query: string; onClear: () => void
360
388
 
361
389
  function EmptyState({ isDraft, folderName }: { isDraft: boolean; folderName?: string }) {
362
390
  const t = useLocale();
391
+ const agent = useCreateDeckWithAgent();
363
392
  const folderEmptyTitle = t.home.folderEmptyTitle.replace(
364
393
  '{name}',
365
394
  folderName ?? t.home.folderEmptyTitle,
@@ -373,13 +402,20 @@ function EmptyState({ isDraft, folderName }: { isDraft: boolean; folderName?: st
373
402
  <p className="mt-4 font-heading text-[14px] font-semibold tracking-tight">
374
403
  {t.home.noSlidesYet}
375
404
  </p>
376
- <p className="mt-1.5 text-[13px] leading-relaxed text-muted-foreground">
377
- {t.home.createSlideHintPrefix}
378
- <code className="rounded-[4px] bg-muted px-1.5 py-0.5 font-mono text-[11.5px] text-foreground">
379
- /create-slide
380
- </code>
381
- {t.home.createSlideHintSuffix}
382
- </p>
405
+ {agent ? (
406
+ <Button size="sm" className="mt-4" onClick={agent.start}>
407
+ <ProviderLogo provider={agent.provider.id} />
408
+ {format(t.agent.createDeckWith, { provider: agent.provider.name })}
409
+ </Button>
410
+ ) : (
411
+ <p className="mt-1.5 text-[13px] leading-relaxed text-muted-foreground">
412
+ {t.home.createSlideHintPrefix}
413
+ <code className="rounded-[4px] bg-muted px-1.5 py-0.5 font-mono text-[11.5px] text-foreground">
414
+ /create-slide
415
+ </code>
416
+ {t.home.createSlideHintSuffix}
417
+ </p>
418
+ )}
383
419
  </>
384
420
  ) : (
385
421
  <>