@lemonriver/core 0.1.0 → 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.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/dist/{build-f-fM2ZY4.js → build-Df_xwIpg.js} +1 -1
  3. package/dist/cli/bin.js +4 -4
  4. package/dist/{config-DO45GU3e.js → config-BjWCLoMQ.js} +2343 -363
  5. package/dist/{config-B6JaKJM4.d.ts → config-rMGCkz6i.d.ts} +6 -2
  6. package/dist/{dev-CqS2xpgE.js → dev-Ctsx9IeO.js} +2 -2
  7. package/dist/index.d.ts +3 -3
  8. package/dist/index.js +4 -3
  9. package/dist/locale/index.d.ts +5 -2
  10. package/dist/locale/index.js +2 -2
  11. package/dist/{preview-BihgMlEl.js → preview-CJgcqsPk.js} +1 -1
  12. package/dist/{types-BjW4glip.d.ts → types-8VUyA2bu.d.ts} +133 -5
  13. package/dist/{update-C_R0ycVC.js → update-BKOA3Z6o.js} +153 -21
  14. package/dist/vite/index.d.ts +1 -1
  15. package/dist/vite/index.js +1 -1
  16. package/dist/{zh-tw-Ci0jK8eg.js → zh-tw-CeLrYwRE.js} +1178 -25
  17. package/package.json +21 -15
  18. package/skills/apply-comments/SKILL.md +10 -6
  19. package/skills/create-slide/SKILL.md +2 -0
  20. package/skills/current-slide/SKILL.md +1 -0
  21. package/skills/slide-authoring/SKILL.md +31 -1
  22. package/src/agent/prompt.ts +141 -0
  23. package/src/agent/provider.ts +123 -0
  24. package/src/agent/types.ts +151 -0
  25. package/src/app/app.tsx +2 -0
  26. package/src/app/assets/open-slide.svg +39 -0
  27. package/src/app/components/agent/agent-dock.tsx +35 -0
  28. package/src/app/components/agent/agent-markdown.tsx +45 -0
  29. package/src/app/components/agent/agent-panel.tsx +692 -0
  30. package/src/app/components/agent/agent-presence.tsx +74 -0
  31. package/src/app/components/agent/composer.tsx +120 -0
  32. package/src/app/components/agent/connect-view.tsx +184 -0
  33. package/src/app/components/agent/history-view.tsx +135 -0
  34. package/src/app/components/agent/provider-logo.tsx +34 -0
  35. package/src/app/components/agent/transcript.tsx +609 -0
  36. package/src/app/components/board/board-canvas.tsx +143 -12
  37. package/src/app/components/board/board.css +51 -0
  38. package/src/app/components/board/layout.test.ts +53 -1
  39. package/src/app/components/board/layout.ts +50 -2
  40. package/src/app/components/board/page-node.tsx +287 -13
  41. package/src/app/components/board/use-board-cards.ts +210 -0
  42. package/src/app/components/board/use-page-order.ts +17 -9
  43. package/src/app/components/command/slide-command-menu.tsx +10 -0
  44. package/src/app/components/icon-switcher-indicator.tsx +1 -1
  45. package/src/app/components/inspector/comment-layer.tsx +1011 -0
  46. package/src/app/components/inspector/image-crop-dialog.tsx +1 -5
  47. package/src/app/components/inspector/inspect-overlay.tsx +2 -39
  48. package/src/app/components/inspector/inspector-panel.tsx +10 -93
  49. package/src/app/components/inspector/inspector-provider.tsx +121 -47
  50. package/src/app/components/panel/editor-sidebar.tsx +85 -16
  51. package/src/app/components/panel/panel-shell.tsx +2 -2
  52. package/src/app/components/panel/resize-handle.tsx +129 -0
  53. package/src/app/components/player.tsx +0 -1
  54. package/src/app/components/preview-step-host.tsx +16 -0
  55. package/src/app/components/sidebar/sidebar.tsx +2 -2
  56. package/src/app/components/style-panel/style-panel.tsx +3 -2
  57. package/src/app/components/thumbnail-rail.tsx +76 -1
  58. package/src/app/favicon.ico +0 -0
  59. package/src/app/favicon.svg +39 -0
  60. package/src/app/index.html +2 -1
  61. package/src/app/lib/agent/use-agent.ts +543 -0
  62. package/src/app/lib/inspector/freeze-motion.ts +61 -0
  63. package/src/app/lib/inspector/use-comments.ts +32 -3
  64. package/src/app/lib/locale-store.ts +6 -1
  65. package/src/app/lib/print-ready.ts +9 -6
  66. package/src/app/lib/use-auto-grow.ts +15 -0
  67. package/src/app/lib/utils.ts +4 -0
  68. package/src/app/routes/board.tsx +48 -11
  69. package/src/app/routes/home.tsx +43 -7
  70. package/src/app/routes/presenter.tsx +1 -16
  71. package/src/app/routes/preview.tsx +169 -0
  72. package/src/app/routes/slide.tsx +221 -173
  73. package/src/app/styles.css +234 -80
  74. package/src/editing/placeholder-id.ts +1 -0
  75. package/src/locale/en.ts +127 -5
  76. package/src/locale/es.ts +692 -0
  77. package/src/locale/index.ts +1 -0
  78. package/src/locale/ja.ts +120 -6
  79. package/src/locale/types.ts +132 -5
  80. package/src/locale/zh-cn.ts +117 -5
  81. package/src/locale/zh-tw.ts +117 -5
  82. package/src/app/assets/open-slide.png +0 -0
  83. package/src/app/components/inspector/comment-widget.tsx +0 -119
@@ -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,61 @@
1
+ import { useEffect } from 'react';
2
+
3
+ export type FreezeMode = 'edit' | 'comment';
4
+
5
+ // Pauses slide animations and transitions while an overlay measures and
6
+ // hit-tests the canvas, so element rects stay put under the pointer.
7
+ export function useFreezeSlideMotion(mode: FreezeMode | null): void {
8
+ useEffect(() => {
9
+ if (!mode) return;
10
+ const root = document.querySelector<HTMLElement>('[data-inspector-root]');
11
+ if (!root) return;
12
+ const style = document.createElement('style');
13
+ style.textContent = FREEZE_CSS;
14
+ document.head.appendChild(style);
15
+ root.dataset.inspectorEditing = mode;
16
+ return () => {
17
+ style.remove();
18
+ delete root.dataset.inspectorEditing;
19
+ };
20
+ }, [mode]);
21
+ }
22
+
23
+ // A speech-bubble pin with its tip at the bottom-left corner; the hotspot
24
+ // sits on that tip so the pointer "stamps" where the comment will land.
25
+ const COMMENT_CURSOR_SVG = [
26
+ "<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'>",
27
+ "<path d='M12 3a9 9 0 0 1 9 9a9 9 0 0 1-9 9H5a2 2 0 0 1-2-2v-7a9 9 0 0 1 9-9z' ",
28
+ "fill='#fff' stroke='#111' stroke-width='1.5' stroke-linejoin='round'/></svg>",
29
+ ].join('');
30
+ const COMMENT_CURSOR = `url("data:image/svg+xml;utf8,${encodeURIComponent(COMMENT_CURSOR_SVG)}") 3 21, crosshair`;
31
+
32
+ const FREEZE_CSS = `
33
+ [data-inspector-editing] { touch-action: none; }
34
+ [data-inspector-editing] *:not([data-inspector-ui], [data-inspector-ui] *),
35
+ [data-inspector-editing] *:not([data-inspector-ui], [data-inspector-ui] *)::before,
36
+ [data-inspector-editing] *:not([data-inspector-ui], [data-inspector-ui] *)::after {
37
+ animation-duration: 1ms !important;
38
+ animation-delay: 0s !important;
39
+ animation-iteration-count: 1 !important;
40
+ animation-fill-mode: forwards !important;
41
+ transition: none !important;
42
+ view-transition-name: none !important;
43
+ user-select: none;
44
+ }
45
+ [data-inspector-editing] *:not([data-inspector-ui], [data-inspector-ui] *, [data-visual-gesture], [data-visual-gesture] *),
46
+ [data-inspector-editing] *:not([data-inspector-ui], [data-inspector-ui] *, [data-visual-gesture], [data-visual-gesture] *)::before,
47
+ [data-inspector-editing] *:not([data-inspector-ui], [data-inspector-ui] *, [data-visual-gesture], [data-visual-gesture] *)::after {
48
+ cursor: default !important;
49
+ }
50
+ [data-inspector-editing] [data-visual-gesture],
51
+ [data-inspector-editing] [data-visual-gesture] *,
52
+ [data-inspector-editing] [data-visual-gesture] *::before,
53
+ [data-inspector-editing] [data-visual-gesture] *::after {
54
+ cursor: var(--osd-gesture-cursor, default) !important;
55
+ }
56
+ [data-inspector-editing="comment"] *:not([data-inspector-ui], [data-inspector-ui] *),
57
+ [data-inspector-editing="comment"] *:not([data-inspector-ui], [data-inspector-ui] *)::before,
58
+ [data-inspector-editing="comment"] *:not([data-inspector-ui], [data-inspector-ui] *)::after {
59
+ cursor: ${COMMENT_CURSOR} !important;
60
+ }
61
+ `;
@@ -1,11 +1,24 @@
1
1
  import { useCallback, useEffect, useState } from 'react';
2
2
 
3
+ export type CommentRect = { x: number; y: number; w: number; h: number };
4
+
3
5
  export type SlideComment = {
4
6
  id: string;
5
7
  line: number;
6
8
  ts: string;
7
9
  note: string;
8
10
  hint?: string;
11
+ rect?: CommentRect;
12
+ page?: number;
13
+ target?: { line: number; column: number } | null;
14
+ };
15
+
16
+ export type AddCommentInput = {
17
+ line: number;
18
+ column: number;
19
+ text: string;
20
+ rect?: CommentRect;
21
+ page?: number;
9
22
  };
10
23
 
11
24
  type ListResponse = { comments: SlideComment[] };
@@ -31,11 +44,11 @@ export function useComments(slideId: string) {
31
44
  }, [slideId]);
32
45
 
33
46
  const add = useCallback(
34
- async (line: number, column: number, text: string) => {
47
+ async (input: AddCommentInput) => {
35
48
  const res = await fetch('/__comments/add', {
36
49
  method: 'POST',
37
50
  headers: { 'content-type': 'application/json' },
38
- body: JSON.stringify({ slideId, line, column, text }),
51
+ body: JSON.stringify({ slideId, ...input }),
39
52
  });
40
53
  if (!res.ok) {
41
54
  const body = (await res.json().catch(() => ({}))) as { error?: string };
@@ -46,6 +59,22 @@ export function useComments(slideId: string) {
46
59
  [slideId, refetch],
47
60
  );
48
61
 
62
+ const update = useCallback(
63
+ async (id: string, text: string) => {
64
+ const res = await fetch(`/__comments/${id}`, {
65
+ method: 'PUT',
66
+ headers: { 'content-type': 'application/json' },
67
+ body: JSON.stringify({ slideId, text }),
68
+ });
69
+ if (!res.ok) {
70
+ const body = (await res.json().catch(() => ({}))) as { error?: string };
71
+ throw new Error(body.error ?? `PUT /__comments/${id} → ${res.status}`);
72
+ }
73
+ await refetch();
74
+ },
75
+ [slideId, refetch],
76
+ );
77
+
49
78
  const remove = useCallback(
50
79
  async (id: string) => {
51
80
  const res = await fetch(`/__comments/${id}?slideId=${encodeURIComponent(slideId)}`, {
@@ -70,5 +99,5 @@ export function useComments(slideId: string) {
70
99
  };
71
100
  }, [refetch]);
72
101
 
73
- return { comments, error, add, remove };
102
+ return { comments, error, add, update, remove };
74
103
  }
@@ -1,6 +1,7 @@
1
1
  import config from 'virtual:open-slide/config';
2
2
  import { useSyncExternalStore } from 'react';
3
3
  import { en } from '../../locale/en';
4
+ import { es } from '../../locale/es';
4
5
  import { ja } from '../../locale/ja';
5
6
  import type { Locale } from '../../locale/types';
6
7
  import { zhCN } from '../../locale/zh-cn';
@@ -13,6 +14,7 @@ const LOCALES: Record<LocaleId, Locale> = {
13
14
  'zh-TW': zhTW,
14
15
  'zh-CN': zhCN,
15
16
  ja,
17
+ es,
16
18
  };
17
19
 
18
20
  export const LOCALE_OPTIONS: ReadonlyArray<{ id: LocaleId; label: string }> = [
@@ -20,13 +22,16 @@ export const LOCALE_OPTIONS: ReadonlyArray<{ id: LocaleId; label: string }> = [
20
22
  { id: 'zh-TW', label: '繁體中文' },
21
23
  { id: 'zh-CN', label: '简体中文' },
22
24
  { id: 'ja', label: '日本語' },
25
+ { id: 'es', label: 'Español' },
23
26
  ];
24
27
 
25
28
  const STORAGE_KEY = 'open-slide:locale';
26
29
  const configLocale = config.locale as Locale | undefined;
27
30
 
28
31
  function isLocaleId(value: string | null): value is LocaleId {
29
- return value === 'en' || value === 'zh-TW' || value === 'zh-CN' || value === 'ja';
32
+ return (
33
+ value === 'en' || value === 'zh-TW' || value === 'zh-CN' || value === 'ja' || value === 'es'
34
+ );
30
35
  }
31
36
 
32
37
  function readStored(): Locale {