@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.
Files changed (82) hide show
  1. package/dist/{build-f-fM2ZY4.js → build-Df_xwIpg.js} +1 -1
  2. package/dist/cli/bin.js +4 -4
  3. package/dist/{config-DO45GU3e.js → config-BjWCLoMQ.js} +2343 -363
  4. package/dist/{config-B6JaKJM4.d.ts → config-rMGCkz6i.d.ts} +6 -2
  5. package/dist/{dev-CqS2xpgE.js → dev-Ctsx9IeO.js} +2 -2
  6. package/dist/index.d.ts +3 -3
  7. package/dist/index.js +4 -3
  8. package/dist/locale/index.d.ts +5 -2
  9. package/dist/locale/index.js +2 -2
  10. package/dist/{preview-BihgMlEl.js → preview-CJgcqsPk.js} +1 -1
  11. package/dist/{types-BjW4glip.d.ts → types-8VUyA2bu.d.ts} +133 -5
  12. package/dist/{update-C_R0ycVC.js → update-BKOA3Z6o.js} +153 -21
  13. package/dist/vite/index.d.ts +1 -1
  14. package/dist/vite/index.js +1 -1
  15. package/dist/{zh-tw-Ci0jK8eg.js → zh-tw-CeLrYwRE.js} +1178 -25
  16. package/package.json +13 -6
  17. package/skills/apply-comments/SKILL.md +10 -6
  18. package/skills/create-slide/SKILL.md +2 -0
  19. package/skills/current-slide/SKILL.md +1 -0
  20. package/skills/slide-authoring/SKILL.md +31 -1
  21. package/src/agent/prompt.ts +141 -0
  22. package/src/agent/provider.ts +123 -0
  23. package/src/agent/types.ts +151 -0
  24. package/src/app/app.tsx +2 -0
  25. package/src/app/assets/open-slide.svg +39 -0
  26. package/src/app/components/agent/agent-dock.tsx +35 -0
  27. package/src/app/components/agent/agent-markdown.tsx +45 -0
  28. package/src/app/components/agent/agent-panel.tsx +692 -0
  29. package/src/app/components/agent/agent-presence.tsx +74 -0
  30. package/src/app/components/agent/composer.tsx +120 -0
  31. package/src/app/components/agent/connect-view.tsx +184 -0
  32. package/src/app/components/agent/history-view.tsx +135 -0
  33. package/src/app/components/agent/provider-logo.tsx +34 -0
  34. package/src/app/components/agent/transcript.tsx +609 -0
  35. package/src/app/components/board/board-canvas.tsx +143 -12
  36. package/src/app/components/board/board.css +51 -0
  37. package/src/app/components/board/layout.test.ts +53 -1
  38. package/src/app/components/board/layout.ts +50 -2
  39. package/src/app/components/board/page-node.tsx +287 -13
  40. package/src/app/components/board/use-board-cards.ts +210 -0
  41. package/src/app/components/board/use-page-order.ts +17 -9
  42. package/src/app/components/command/slide-command-menu.tsx +10 -0
  43. package/src/app/components/icon-switcher-indicator.tsx +1 -1
  44. package/src/app/components/inspector/comment-layer.tsx +1011 -0
  45. package/src/app/components/inspector/image-crop-dialog.tsx +1 -5
  46. package/src/app/components/inspector/inspect-overlay.tsx +2 -39
  47. package/src/app/components/inspector/inspector-panel.tsx +10 -93
  48. package/src/app/components/inspector/inspector-provider.tsx +121 -47
  49. package/src/app/components/panel/editor-sidebar.tsx +85 -16
  50. package/src/app/components/panel/panel-shell.tsx +2 -2
  51. package/src/app/components/panel/resize-handle.tsx +129 -0
  52. package/src/app/components/player.tsx +0 -1
  53. package/src/app/components/preview-step-host.tsx +16 -0
  54. package/src/app/components/sidebar/sidebar.tsx +2 -2
  55. package/src/app/components/style-panel/style-panel.tsx +3 -2
  56. package/src/app/components/thumbnail-rail.tsx +76 -1
  57. package/src/app/favicon.ico +0 -0
  58. package/src/app/favicon.svg +39 -0
  59. package/src/app/index.html +2 -1
  60. package/src/app/lib/agent/use-agent.ts +543 -0
  61. package/src/app/lib/inspector/freeze-motion.ts +61 -0
  62. package/src/app/lib/inspector/use-comments.ts +32 -3
  63. package/src/app/lib/locale-store.ts +6 -1
  64. package/src/app/lib/print-ready.ts +9 -6
  65. package/src/app/lib/use-auto-grow.ts +15 -0
  66. package/src/app/lib/utils.ts +4 -0
  67. package/src/app/routes/board.tsx +48 -11
  68. package/src/app/routes/home.tsx +43 -7
  69. package/src/app/routes/presenter.tsx +1 -16
  70. package/src/app/routes/preview.tsx +169 -0
  71. package/src/app/routes/slide.tsx +221 -173
  72. package/src/app/styles.css +234 -80
  73. package/src/editing/placeholder-id.ts +1 -0
  74. package/src/locale/en.ts +127 -5
  75. package/src/locale/es.ts +692 -0
  76. package/src/locale/index.ts +1 -0
  77. package/src/locale/ja.ts +120 -6
  78. package/src/locale/types.ts +132 -5
  79. package/src/locale/zh-cn.ts +117 -5
  80. package/src/locale/zh-tw.ts +117 -5
  81. package/src/app/assets/open-slide.png +0 -0
  82. package/src/app/components/inspector/comment-widget.tsx +0 -119
@@ -1,6 +1,6 @@
1
1
  import { type SyntheticEvent, useEffect, useRef, useState } from 'react';
2
2
  import ReactCrop, { type Crop, type PercentCrop } from 'react-image-crop';
3
- import { round2 } from '@/lib/utils';
3
+ import { clamp, round2 } from '@/lib/utils';
4
4
  import 'react-image-crop/dist/ReactCrop.css';
5
5
  import { Button } from '@/components/ui/button';
6
6
  import {
@@ -209,7 +209,3 @@ function roundRect(crop: PercentCrop): ImageCropRect {
209
209
  height: round2(crop.height),
210
210
  };
211
211
  }
212
-
213
- function clamp(v: number, lo: number, hi: number) {
214
- return v < lo ? lo : v > hi ? hi : v;
215
- }
@@ -1,6 +1,7 @@
1
1
  import { Crop, ImageIcon } from 'lucide-react';
2
2
  import { useEffect, useLayoutEffect, useRef, useState } from 'react';
3
3
  import { findSlideSource } from '@/lib/inspector/fiber';
4
+ import { useFreezeSlideMotion } from '@/lib/inspector/freeze-motion';
4
5
  import {
5
6
  type Guide,
6
7
  type Point,
@@ -98,19 +99,7 @@ export function InspectOverlay() {
98
99
  const [scale, setScale] = useState(1);
99
100
  const displayed = localTargets ?? selection;
100
101
 
101
- useEffect(() => {
102
- if (!active) return;
103
- const root = document.querySelector<HTMLElement>('[data-inspector-root]');
104
- if (!root) return;
105
- const style = document.createElement('style');
106
- style.textContent = EDITING_FREEZE_CSS;
107
- document.head.appendChild(style);
108
- root.dataset.inspectorEditing = 'true';
109
- return () => {
110
- style.remove();
111
- delete root.dataset.inspectorEditing;
112
- };
113
- }, [active]);
102
+ useFreezeSlideMotion(active ? 'edit' : null);
114
103
 
115
104
  useLayoutEffect(() => {
116
105
  if (!active) return;
@@ -656,29 +645,3 @@ export function InspectOverlay() {
656
645
  </div>
657
646
  );
658
647
  }
659
-
660
- const EDITING_FREEZE_CSS = `
661
- [data-inspector-editing] { touch-action: none; }
662
- [data-inspector-editing] *:not([data-inspector-ui], [data-inspector-ui] *),
663
- [data-inspector-editing] *:not([data-inspector-ui], [data-inspector-ui] *)::before,
664
- [data-inspector-editing] *:not([data-inspector-ui], [data-inspector-ui] *)::after {
665
- animation-duration: 1ms !important;
666
- animation-delay: 0s !important;
667
- animation-iteration-count: 1 !important;
668
- animation-fill-mode: forwards !important;
669
- transition: none !important;
670
- view-transition-name: none !important;
671
- user-select: none;
672
- }
673
- [data-inspector-editing] *:not([data-inspector-ui], [data-inspector-ui] *, [data-visual-gesture], [data-visual-gesture] *),
674
- [data-inspector-editing] *:not([data-inspector-ui], [data-inspector-ui] *, [data-visual-gesture], [data-visual-gesture] *)::before,
675
- [data-inspector-editing] *:not([data-inspector-ui], [data-inspector-ui] *, [data-visual-gesture], [data-visual-gesture] *)::after {
676
- cursor: default !important;
677
- }
678
- [data-inspector-editing] [data-visual-gesture],
679
- [data-inspector-editing] [data-visual-gesture] *,
680
- [data-inspector-editing] [data-visual-gesture] *::before,
681
- [data-inspector-editing] [data-visual-gesture] *::after {
682
- cursor: var(--osd-gesture-cursor, default) !important;
683
- }
684
- `;
@@ -46,7 +46,7 @@ import { styleContext } from '@/lib/inspector/text-selection';
46
46
  import type { EditOp } from '@/lib/inspector/use-editor';
47
47
  import { useAgentSocketConnected } from '@/lib/use-agent-socket';
48
48
  import { format, useLocale } from '@/lib/use-locale';
49
- import { cn, round2 } from '@/lib/utils';
49
+ import { round2 } from '@/lib/utils';
50
50
  import type { Locale } from '../../../locale/types';
51
51
  import { ArrangePanel } from './arrange-panel';
52
52
  import { AssetPickerDialog } from './asset-picker-dialog';
@@ -107,7 +107,6 @@ export function InspectorPanel({
107
107
  bufferOps,
108
108
  pendingCount,
109
109
  opsVersion,
110
- add,
111
110
  applyEdit,
112
111
  } = useInspector();
113
112
  const [snapshot, setSnapshot] = useState<ElementSnapshot | null>(null);
@@ -256,19 +255,14 @@ export function InspectorPanel({
256
255
  );
257
256
  const footer =
258
257
  selected && snapshot && !multiple ? (
259
- <>
260
- <CollapsibleSection title={t.inspector.leaveComment}>
261
- <CommentsSection selected={selected} onAdd={add} />
262
- </CollapsibleSection>
263
- <CollapsibleSection title={t.inspector.sourceSection}>
264
- <div className="flex items-center justify-between gap-2">
265
- <span className="font-mono text-[10.5px] text-muted-foreground">
266
- &lt;{selected.anchor.tagName.toLowerCase()}&gt; · {selected.line}:{selected.column}
267
- </span>
268
- <AgentWatchingBadge />
269
- </div>
270
- </CollapsibleSection>
271
- </>
258
+ <CollapsibleSection title={t.inspector.sourceSection}>
259
+ <div className="flex items-center justify-between gap-2">
260
+ <span className="font-mono text-[10.5px] text-muted-foreground">
261
+ &lt;{selected.anchor.tagName.toLowerCase()}&gt; · {selected.line}:{selected.column}
262
+ </span>
263
+ <AgentWatchingBadge />
264
+ </div>
265
+ </CollapsibleSection>
272
266
  ) : undefined;
273
267
 
274
268
  return (
@@ -278,7 +272,7 @@ export function InspectorPanel({
278
272
  if (value === 'arrange') stopInlineEdit();
279
273
  if (value === 'format' || value === 'arrange') onTabChange(value);
280
274
  }}
281
- className="h-full shrink-0 gap-0"
275
+ className="h-full w-full min-w-0 shrink-0 gap-0"
282
276
  onPointerDownCapture={(event) => {
283
277
  if (!inlineEdit || !(event.target instanceof Element)) return;
284
278
  const button = event.target.closest('button');
@@ -894,83 +888,6 @@ function AgentWatchingBadge() {
894
888
  );
895
889
  }
896
890
 
897
- // The cue animation re-mounts with every element selection; without this
898
- // guard it replays each time instead of once per inspector session.
899
- let commentCuePlayed = false;
900
-
901
- function CommentsSection({
902
- selected,
903
- onAdd,
904
- }: {
905
- selected: { line: number; column: number };
906
- onAdd: (line: number, column: number, text: string) => Promise<void>;
907
- }) {
908
- const [draft, setDraft] = useState('');
909
- const [submitting, setSubmitting] = useState(false);
910
- const [showCue] = useState(() => !commentCuePlayed);
911
- const wrapRef = useRef<HTMLDivElement>(null);
912
- const t = useLocale();
913
-
914
- useEffect(() => {
915
- commentCuePlayed = true;
916
- }, []);
917
-
918
- useEffect(() => {
919
- const onKey = (e: KeyboardEvent) => {
920
- if (e.key !== '/') return;
921
- if (!(e.metaKey || e.ctrlKey)) return;
922
- if (e.altKey || e.shiftKey) return;
923
- const ta = wrapRef.current?.querySelector('textarea');
924
- if (!ta) return;
925
- e.preventDefault();
926
- const details = ta.closest('details');
927
- if (details) details.open = true;
928
- ta.focus({ preventScroll: true });
929
- };
930
- window.addEventListener('keydown', onKey);
931
- return () => window.removeEventListener('keydown', onKey);
932
- }, []);
933
-
934
- const submit = async () => {
935
- const trimmed = draft.trim();
936
- if (!trimmed) return;
937
- setSubmitting(true);
938
- try {
939
- await onAdd(selected.line, selected.column, trimmed);
940
- setDraft('');
941
- } finally {
942
- setSubmitting(false);
943
- }
944
- };
945
-
946
- return (
947
- <>
948
- <div ref={wrapRef} className={cn('rounded-[6px]', showCue && 'comment-cue')}>
949
- <Textarea
950
- value={draft}
951
- onChange={(e) => setDraft(e.target.value)}
952
- onKeyDown={(e) => {
953
- if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
954
- e.preventDefault();
955
- submit();
956
- }
957
- }}
958
- placeholder={t.inspector.commentPlaceholder}
959
- className="min-h-16 resize-none text-[12px]"
960
- />
961
- </div>
962
- <div className="flex items-center justify-between gap-2">
963
- <span className="font-mono text-[10.5px] text-muted-foreground/70">
964
- {t.inspector.commentShortcutHint}
965
- </span>
966
- <Button size="sm" variant="brand" disabled={submitting || !draft.trim()} onClick={submit}>
967
- {t.inspector.addComment}
968
- </Button>
969
- </div>
970
- </>
971
- );
972
- }
973
-
974
891
  function readSnapshot(el: HTMLElement): ElementSnapshot {
975
892
  const cs = getComputedStyle(el);
976
893
  const text =
@@ -1,4 +1,4 @@
1
- import { Eye, PanelRight, Pencil } from 'lucide-react';
1
+ import { Eye, MessageSquare, PanelRight, Pencil } from 'lucide-react';
2
2
  import {
3
3
  createContext,
4
4
  type ReactNode,
@@ -12,20 +12,22 @@ import {
12
12
  import { toast } from 'sonner';
13
13
  import { useHistory } from '@/components/history-provider';
14
14
  import { IconSwitcherIndicator } from '@/components/icon-switcher-indicator';
15
+ import { IconTooltip } from '@/components/icon-tooltip';
15
16
  import { Button } from '@/components/ui/button';
16
17
  import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
18
+ import { TooltipTrigger } from '@/components/ui/tooltip';
17
19
  import { findSlideSource } from '@/lib/inspector/fiber';
18
20
  import {
19
21
  appendTextEdit,
20
22
  type TextEditOp,
21
23
  type TextEditStep,
22
24
  } from '@/lib/inspector/text-edit-timeline';
23
- import { type SlideComment, useComments } from '@/lib/inspector/use-comments';
25
+ import { type AddCommentInput, type SlideComment, useComments } from '@/lib/inspector/use-comments';
24
26
  import { type Edit, type EditOp, useEditor } from '@/lib/inspector/use-editor';
25
27
  import { useVisualEditor, type VisualEdit } from '@/lib/inspector/use-visual-editor';
26
28
  import { isShortcutControlTarget, isTypingTarget } from '@/lib/keys';
27
29
  import { textDiff } from '@/lib/text-diff';
28
- import { useLocale } from '@/lib/use-locale';
30
+ import { format, plural, useLocale } from '@/lib/use-locale';
29
31
  import { round2 } from '@/lib/utils';
30
32
  import { AssetPickerDialog } from './asset-picker-dialog';
31
33
  import { ImageCropDialog, type ImageCropRect } from './image-crop-dialog';
@@ -367,9 +369,17 @@ function replayDomTextEdits(el: HTMLElement, snapshot: TextDomSnapshot, steps: T
367
369
  if (el.innerHTML !== next) el.innerHTML = next;
368
370
  }
369
371
 
372
+ export type InspectorMode = 'preview' | 'edit' | 'comment';
373
+
370
374
  type InspectorCtx = {
371
375
  slideId: string;
376
+ pageIndex: number;
377
+ mode: InspectorMode;
378
+ setMode: (mode: InspectorMode) => void;
379
+ // True while the visual editor owns the canvas (`mode === 'edit'`).
372
380
  active: boolean;
381
+ commentMode: boolean;
382
+ exitCommentMode: () => void;
373
383
  toggle: () => void;
374
384
  panelOpen: boolean;
375
385
  panelHidden: boolean;
@@ -377,7 +387,8 @@ type InspectorCtx = {
377
387
  cancel: () => void;
378
388
  comments: SlideComment[];
379
389
  error: string | null;
380
- add: (line: number, column: number, text: string) => Promise<void>;
390
+ add: (input: AddCommentInput) => Promise<void>;
391
+ update: (id: string, text: string) => Promise<void>;
381
392
  remove: (id: string) => Promise<void>;
382
393
  selection: SelectedTarget[];
383
394
  setSelection: (targets: SelectedTarget[]) => void;
@@ -416,6 +427,10 @@ export function useInspector(): InspectorCtx {
416
427
  return v;
417
428
  }
418
429
 
430
+ export function useOptionalInspector(): InspectorCtx | null {
431
+ return useContext(Ctx);
432
+ }
433
+
419
434
  export function InspectorProvider({
420
435
  slideId,
421
436
  pageIndex,
@@ -429,7 +444,11 @@ export function InspectorProvider({
429
444
  onPanelOpen?: () => void;
430
445
  children: ReactNode;
431
446
  }) {
432
- const [active, setActive] = useState(import.meta.env.DEV);
447
+ const [mode, setModeState] = useState<InspectorMode>(import.meta.env.DEV ? 'edit' : 'preview');
448
+ const active = mode === 'edit';
449
+ const commentMode = mode === 'comment';
450
+ // Where Esc / toggling the comment tool off returns the user to.
451
+ const modeBeforeCommentRef = useRef<Exclude<InspectorMode, 'comment'>>('preview');
433
452
  const [panelOpen, setPanelOpen] = useState(
434
453
  () => typeof window !== 'undefined' && window.innerWidth >= 1024,
435
454
  );
@@ -475,7 +494,7 @@ export function InspectorProvider({
475
494
  [],
476
495
  );
477
496
  const [opsVersion, setOpsVersion] = useState(0);
478
- const { comments, error, add, remove } = useComments(slideId);
497
+ const { comments, error, add, update, remove } = useComments(slideId);
479
498
  const { applyEdit, applyEdits } = useEditor(slideId);
480
499
  const history = useHistory();
481
500
 
@@ -1252,19 +1271,32 @@ export function InspectorProvider({
1252
1271
  return () => observer.disconnect();
1253
1272
  }, [selection, setSelection, slideId]);
1254
1273
 
1274
+ const setMode = useCallback(
1275
+ (next: InspectorMode) => {
1276
+ if (next === mode) return;
1277
+ if (next === 'comment' && mode !== 'comment') modeBeforeCommentRef.current = mode;
1278
+ if (mode === 'edit') {
1279
+ setSelected(null);
1280
+ setInlineEdit(null);
1281
+ }
1282
+ setModeState(next);
1283
+ },
1284
+ [mode, setSelected],
1285
+ );
1286
+
1255
1287
  const toggle = useCallback(() => {
1256
- if (active) {
1257
- setSelected(null);
1258
- setInlineEdit(null);
1259
- }
1260
- setActive(!active);
1261
- }, [active, setSelected]);
1288
+ setMode(active ? 'preview' : 'edit');
1289
+ }, [active, setMode]);
1290
+
1291
+ const exitCommentMode = useCallback(() => {
1292
+ if (commentMode) setMode(modeBeforeCommentRef.current);
1293
+ }, [commentMode, setMode]);
1262
1294
 
1263
1295
  const togglePanel = useCallback(() => {
1264
1296
  setPanelOpen(!active || panelHidden || !panelOpen);
1265
- setActive(true);
1297
+ setMode('edit');
1266
1298
  onPanelOpen?.();
1267
- }, [active, panelHidden, panelOpen, onPanelOpen]);
1299
+ }, [active, panelHidden, panelOpen, onPanelOpen, setMode]);
1268
1300
 
1269
1301
  const cancel = useCallback(() => {
1270
1302
  setSelected(null);
@@ -1320,13 +1352,24 @@ export function InspectorProvider({
1320
1352
  e.altKey
1321
1353
  )
1322
1354
  return;
1323
- if (e.key !== 'i' && e.key !== 'I') return;
1324
- e.preventDefault();
1325
- togglePanel();
1355
+ if (e.key === 'i' || e.key === 'I') {
1356
+ e.preventDefault();
1357
+ togglePanel();
1358
+ } else if (e.key === 'c' || e.key === 'C') {
1359
+ e.preventDefault();
1360
+ if (commentMode) exitCommentMode();
1361
+ else setMode('comment');
1362
+ } else if (e.key === 'v' || e.key === 'V') {
1363
+ e.preventDefault();
1364
+ setMode('preview');
1365
+ } else if (e.key === 'e' || e.key === 'E') {
1366
+ e.preventDefault();
1367
+ setMode('edit');
1368
+ }
1326
1369
  };
1327
1370
  window.addEventListener('keydown', onKey);
1328
1371
  return () => window.removeEventListener('keydown', onKey);
1329
- }, [togglePanel]);
1372
+ }, [togglePanel, commentMode, exitCommentMode, setMode]);
1330
1373
 
1331
1374
  const openCrop = useCallback((anchor: HTMLImageElement) => {
1332
1375
  const loc = anchor.dataset.slideLoc;
@@ -1352,7 +1395,12 @@ export function InspectorProvider({
1352
1395
  const value = useMemo<InspectorCtx>(
1353
1396
  () => ({
1354
1397
  slideId,
1398
+ pageIndex,
1399
+ mode,
1400
+ setMode,
1355
1401
  active,
1402
+ commentMode,
1403
+ exitCommentMode,
1356
1404
  toggle,
1357
1405
  panelOpen,
1358
1406
  panelHidden,
@@ -1361,6 +1409,7 @@ export function InspectorProvider({
1361
1409
  comments,
1362
1410
  error,
1363
1411
  add,
1412
+ update,
1364
1413
  remove,
1365
1414
  selection,
1366
1415
  setSelection,
@@ -1387,7 +1436,12 @@ export function InspectorProvider({
1387
1436
  }),
1388
1437
  [
1389
1438
  slideId,
1439
+ pageIndex,
1440
+ mode,
1441
+ setMode,
1390
1442
  active,
1443
+ commentMode,
1444
+ exitCommentMode,
1391
1445
  toggle,
1392
1446
  panelOpen,
1393
1447
  panelHidden,
@@ -1396,6 +1450,7 @@ export function InspectorProvider({
1396
1450
  comments,
1397
1451
  error,
1398
1452
  add,
1453
+ update,
1399
1454
  remove,
1400
1455
  selection,
1401
1456
  setSelection,
@@ -1552,44 +1607,63 @@ function parsePercent(s: string, fallback: number): number {
1552
1607
 
1553
1608
  export function InspectModeSwitcher() {
1554
1609
  const t = useLocale();
1555
- const { active, toggle } = useInspector();
1610
+ const { mode, setMode, comments } = useInspector();
1556
1611
  if (import.meta.env.PROD) return null;
1612
+ const items: { value: InspectorMode; label: string; shortcut: string; icon: ReactNode }[] = [
1613
+ { value: 'preview', label: t.inspector.previewMode, shortcut: 'V', icon: <Eye /> },
1614
+ { value: 'edit', label: t.inspector.editMode, shortcut: 'E', icon: <Pencil /> },
1615
+ { value: 'comment', label: t.inspector.commentMode, shortcut: 'C', icon: <MessageSquare /> },
1616
+ ];
1617
+ const index = Math.max(
1618
+ 0,
1619
+ items.findIndex((item) => item.value === mode),
1620
+ );
1621
+ const pending = comments.length;
1622
+ const accessibleName = (item: (typeof items)[number]) =>
1623
+ item.value === 'comment' && pending > 0
1624
+ ? `${item.label} · ${format(plural(pending, t.inspector.commentsCount), { count: pending })}`
1625
+ : item.label;
1557
1626
  return (
1558
1627
  <ToggleGroup
1559
1628
  size="sm"
1560
1629
  spacing={1}
1561
- value={[active ? 'edit' : 'preview']}
1630
+ value={[mode]}
1562
1631
  onValueChange={(value) => {
1563
- const next = value[0];
1564
- if (next && (next === 'edit') !== active) toggle();
1632
+ const next = value[0] as InspectorMode | undefined;
1633
+ if (next && next !== mode) setMode(next);
1565
1634
  }}
1566
- aria-label={`${t.inspector.previewMode} / ${t.inspector.editMode}`}
1635
+ aria-label={`${t.inspector.previewMode} / ${t.inspector.editMode} / ${t.inspector.commentMode}`}
1567
1636
  data-inspector-ui
1568
1637
  className="relative isolate h-8 gap-0 rounded-lg border border-border/70 bg-muted/70 p-0.5"
1569
1638
  >
1570
- <IconSwitcherIndicator index={active ? 1 : 0} />
1571
- <ToggleGroupItem
1572
- value="preview"
1573
- title={t.inspector.previewMode}
1574
- aria-label={t.inspector.previewMode}
1575
- onClick={(event) => {
1576
- if (event.detail > 0) event.currentTarget.blur();
1577
- }}
1578
- className="relative z-10 h-full w-8 rounded-md px-0 text-muted-foreground hover:bg-transparent data-pressed:bg-transparent data-pressed:text-foreground data-pressed:shadow-none"
1579
- >
1580
- <Eye />
1581
- </ToggleGroupItem>
1582
- <ToggleGroupItem
1583
- value="edit"
1584
- title={t.inspector.editMode}
1585
- aria-label={t.inspector.editMode}
1586
- onClick={(event) => {
1587
- if (event.detail > 0) event.currentTarget.blur();
1588
- }}
1589
- className="relative z-10 h-full w-8 rounded-md px-0 text-muted-foreground hover:bg-transparent data-pressed:bg-transparent data-pressed:text-foreground data-pressed:shadow-none"
1590
- >
1591
- <Pencil />
1592
- </ToggleGroupItem>
1639
+ <IconSwitcherIndicator index={index} />
1640
+ {items.map((item) => (
1641
+ <IconTooltip key={item.value} label={item.label} shortcut={item.shortcut}>
1642
+ <TooltipTrigger
1643
+ render={
1644
+ <ToggleGroupItem
1645
+ value={item.value}
1646
+ aria-label={accessibleName(item)}
1647
+ data-inspector-mode={item.value}
1648
+ onClick={(event) => {
1649
+ if (event.detail > 0) event.currentTarget.blur();
1650
+ }}
1651
+ className="relative z-10 h-full w-8 rounded-md px-0 text-muted-foreground hover:bg-transparent data-pressed:bg-transparent data-pressed:text-foreground data-pressed:shadow-none"
1652
+ />
1653
+ }
1654
+ >
1655
+ {item.icon}
1656
+ {item.value === 'comment' && pending > 0 && (
1657
+ <span
1658
+ aria-hidden
1659
+ className="pointer-events-none absolute -top-1.5 -right-1.5 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-brand px-1 font-mono text-[9px] font-semibold leading-none text-brand-foreground nums"
1660
+ >
1661
+ {pending > 99 ? '99+' : pending}
1662
+ </span>
1663
+ )}
1664
+ </TooltipTrigger>
1665
+ </IconTooltip>
1666
+ ))}
1593
1667
  </ToggleGroup>
1594
1668
  );
1595
1669
  }
@@ -1610,7 +1684,7 @@ export function InspectPanelButton() {
1610
1684
  data-inspector-ui
1611
1685
  >
1612
1686
  <PanelRight className="size-3.5" />
1613
- <span className="hidden md:inline">{t.inspector.format}</span>
1687
+ <span className="hidden lg:inline">{t.inspector.format}</span>
1614
1688
  </Button>
1615
1689
  );
1616
1690
  }
@@ -1,49 +1,118 @@
1
- import { useState } from 'react';
1
+ import { type ReactNode, useState } from 'react';
2
+ import { AGENT_DOCK_W } from '@/components/agent/agent-dock';
2
3
  import { InspectorPanel } from '@/components/inspector/inspector-panel';
3
4
  import { useInspector } from '@/components/inspector/inspector-provider';
4
5
  import { useDesignPanelState } from '@/components/style-panel/design-provider';
5
6
  import { DesignPanel } from '@/components/style-panel/style-panel';
7
+ import { useLocale } from '@/lib/use-locale';
6
8
  import { PANEL_TRANSITION_MS, PANEL_W, usePanelMount } from './panel-shell';
9
+ import { ResizeHandle, useStoredWidth } from './resize-handle';
7
10
 
11
+ type Mode = 'format' | 'design' | 'agent';
12
+
13
+ const AGENT_MIN_W = 300;
14
+ const EDITOR_MAX_W = 480;
15
+ const AGENT_MAX_W = 720;
16
+
17
+ // The agent shares this container with Format and Design so switching between
18
+ // them only retargets one width transition instead of collapsing one column
19
+ // while another grows.
8
20
  export function EditorSidebar({
9
21
  designOpen,
10
22
  onCloseDesign,
23
+ agentOpen = false,
24
+ agent,
11
25
  }: {
12
26
  designOpen: boolean;
13
27
  onCloseDesign: () => void;
28
+ agentOpen?: boolean;
29
+ agent?: ReactNode;
14
30
  }) {
31
+ const t = useLocale();
15
32
  const { active, panelOpen } = useInspector();
33
+ const [editorWidth, setEditorWidth] = useStoredWidth(
34
+ 'open-slide:editor-panel-width',
35
+ PANEL_W,
36
+ PANEL_W,
37
+ EDITOR_MAX_W,
38
+ );
39
+ const [agentWidth, setAgentWidth] = useStoredWidth(
40
+ 'open-slide:agent-panel-width',
41
+ AGENT_DOCK_W,
42
+ AGENT_MIN_W,
43
+ AGENT_MAX_W,
44
+ );
45
+ const [resizing, setResizing] = useState(false);
16
46
  const { loaded, draft } = useDesignPanelState();
17
47
  const [preferredTab, setPreferredTab] = useState<'format' | 'arrange'>('format');
18
- const mode = designOpen
19
- ? loaded && draft
20
- ? 'design'
21
- : null
22
- : active && panelOpen
23
- ? 'format'
24
- : null;
25
- const [lastMode, setLastMode] = useState<'format' | 'design'>('format');
48
+ const mode: Mode | null =
49
+ agentOpen && agent
50
+ ? 'agent'
51
+ : designOpen
52
+ ? loaded && draft
53
+ ? 'design'
54
+ : null
55
+ : active && panelOpen
56
+ ? 'format'
57
+ : null;
58
+ const [lastMode, setLastMode] = useState<Mode>('format');
26
59
  if (mode && mode !== lastMode) setLastMode(mode);
60
+ const shown = mode ?? lastMode;
61
+ const isAgent = shown === 'agent';
62
+ const [lastEditor, setLastEditor] = useState<'format' | 'design'>('format');
63
+ if (!isAgent && shown !== lastEditor) setLastEditor(shown);
64
+ const [agentMounted, setAgentMounted] = useState(false);
65
+ if (isAgent && !agentMounted) setAgentMounted(true);
27
66
  const open = mode !== null;
28
67
  const { mounted, animVisible } = usePanelMount(open);
29
68
 
30
69
  if (!mounted) return null;
31
70
 
71
+ const width = isAgent ? agentWidth : editorWidth;
72
+
32
73
  return (
33
74
  <div
34
75
  data-editor-sidebar
76
+ data-agent-dock={isAgent ? '' : undefined}
35
77
  inert={!open}
36
78
  aria-hidden={!open}
37
- className="flex h-full shrink-0 justify-end overflow-hidden bg-sidebar transition-[width] ease-swift motion-reduce:transition-none"
79
+ className="relative flex h-full max-h-1/2 shrink-0 justify-end overflow-hidden bg-sidebar transition-[width] ease-swift motion-reduce:transition-none md:max-h-none"
38
80
  style={{
39
- width: animVisible ? PANEL_W : 0,
40
- transitionDuration: `${PANEL_TRANSITION_MS}ms`,
81
+ width: animVisible ? width : 0,
82
+ transitionDuration: resizing ? '0ms' : `${PANEL_TRANSITION_MS}ms`,
41
83
  }}
42
84
  >
43
- {(mode ?? lastMode) === 'design' ? (
44
- <DesignPanel onClose={onCloseDesign} />
45
- ) : (
46
- <InspectorPanel preferredTab={preferredTab} onTabChange={setPreferredTab} />
85
+ {open && (
86
+ <ResizeHandle
87
+ key={isAgent ? 'agent' : 'editor'}
88
+ width={width}
89
+ min={isAgent ? AGENT_MIN_W : PANEL_W}
90
+ max={isAgent ? AGENT_MAX_W : EDITOR_MAX_W}
91
+ defaultWidth={isAgent ? AGENT_DOCK_W : PANEL_W}
92
+ edge="left"
93
+ label={t.slide.resizeSidePanel}
94
+ onResize={isAgent ? setAgentWidth : setEditorWidth}
95
+ onResizingChange={setResizing}
96
+ className="left-0 hidden md:block"
97
+ />
98
+ )}
99
+ {agentMounted && (
100
+ <div
101
+ className="sidebar-swap h-full shrink-0"
102
+ style={{ width: agentWidth }}
103
+ hidden={!isAgent}
104
+ >
105
+ {agent}
106
+ </div>
107
+ )}
108
+ {!isAgent && (
109
+ <div className="sidebar-swap flex h-full shrink-0" style={{ width: editorWidth }}>
110
+ {lastEditor === 'design' ? (
111
+ <DesignPanel onClose={onCloseDesign} />
112
+ ) : (
113
+ <InspectorPanel preferredTab={preferredTab} onTabChange={setPreferredTab} />
114
+ )}
115
+ </div>
47
116
  )}
48
117
  </div>
49
118
  );
@@ -45,8 +45,8 @@ type PanelShellProps = {
45
45
  export function PanelShell({ uiAttr, header, banner, footer, children }: PanelShellProps) {
46
46
  const dataAttrs = uiAttr === 'inspector' ? { 'data-inspector-ui': '' } : { 'data-design-ui': '' };
47
47
  return (
48
- <aside {...dataAttrs} className="flex h-full shrink-0 bg-sidebar" style={{ width: PANEL_W }}>
49
- <div style={{ width: PANEL_W }} className="flex h-full shrink-0 flex-col">
48
+ <aside {...dataAttrs} className="flex h-full w-full min-w-0 bg-sidebar">
49
+ <div className="flex h-full w-full min-w-0 flex-col">
50
50
  <header className="flex h-9 shrink-0 items-center justify-between gap-2 border-b border-hairline px-3">
51
51
  {header}
52
52
  </header>