@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
@@ -14,6 +14,7 @@ import {
14
14
  MoreHorizontal,
15
15
  Play,
16
16
  Presentation,
17
+ Sparkles,
17
18
  Terminal,
18
19
  } from 'lucide-react';
19
20
  import {
@@ -21,15 +22,19 @@ import {
21
22
  type RefObject,
22
23
  useCallback,
23
24
  useEffect,
25
+ useLayoutEffect,
24
26
  useMemo,
25
27
  useRef,
26
28
  useState,
27
29
  } from 'react';
28
30
  import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom';
29
31
  import { toast } from 'sonner';
32
+ import { AgentToggleButton } from '@/components/agent/agent-dock';
33
+ import { AgentPanel } from '@/components/agent/agent-panel';
34
+ import { AgentPresence } from '@/components/agent/agent-presence';
30
35
  import { AssetView } from '@/components/asset-view';
31
36
  import { HistoryProvider } from '@/components/history-provider';
32
- import { CommentWidget } from '@/components/inspector/comment-widget';
37
+ import { CommentLayer } from '@/components/inspector/comment-layer';
33
38
  import { InlineEditLayer } from '@/components/inspector/inline-text-editor';
34
39
  import { InspectOverlay } from '@/components/inspector/inspect-overlay';
35
40
  import {
@@ -40,19 +45,25 @@ import {
40
45
  } from '@/components/inspector/inspector-provider';
41
46
  import { SaveBar } from '@/components/inspector/save-bar';
42
47
  import { EditorSidebar } from '@/components/panel/editor-sidebar';
48
+ import { ResizeHandle, useStoredWidth } from '@/components/panel/resize-handle';
43
49
  import { DesignProvider } from '@/components/style-panel/design-provider';
44
50
  import { DesignToggleButton } from '@/components/style-panel/style-panel';
45
51
  import { Button, buttonVariants } from '@/components/ui/button';
46
52
  import {
47
53
  DropdownMenu,
54
+ DropdownMenuCheckboxItem,
48
55
  DropdownMenuContent,
56
+ DropdownMenuGroup,
49
57
  DropdownMenuItem,
58
+ DropdownMenuRadioGroup,
59
+ DropdownMenuRadioItem,
50
60
  DropdownMenuSeparator,
51
61
  DropdownMenuShortcut,
52
62
  DropdownMenuTrigger,
53
63
  } from '@/components/ui/dropdown-menu';
54
64
  import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
55
65
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
66
+ import { setDockOpen, useAgentActivity, useAgentDock } from '@/lib/agent/use-agent';
56
67
  import { useFolders } from '@/lib/folders';
57
68
  import {
58
69
  hasModifier,
@@ -67,8 +78,10 @@ import { useClickPageNavigation } from '@/lib/use-click-page-navigation';
67
78
  import { useDocumentTitle } from '@/lib/use-document-title';
68
79
  import { useIsMobile } from '@/lib/use-is-mobile';
69
80
  import { format, useLocale } from '@/lib/use-locale';
81
+ import { useMediaQuery } from '@/lib/use-media-query';
70
82
  import { useWheelPageNavigation } from '@/lib/use-wheel-page-navigation';
71
83
  import { cn } from '@/lib/utils';
84
+ import type { AgentTurnContext } from '../../agent/types';
72
85
  import { SlideCommandMenu } from '../components/command/slide-command-menu';
73
86
  import { PdfProgressToast, PptxProgressToast } from '../components/export-progress-toast';
74
87
  import { NotesDrawer } from '../components/notes-drawer';
@@ -127,6 +140,13 @@ export function Slide() {
127
140
  const [designOpen, setDesignOpen] = useState(false);
128
141
  const [overviewOpen, setOverviewOpen] = useState(false);
129
142
  const [commandOpen, setCommandOpen] = useState(false);
143
+ const { open: agentOpen, toggle: toggleAgent } = useAgentDock();
144
+ useLayoutEffect(() => {
145
+ if (agentOpen) setDesignOpen(false);
146
+ }, [agentOpen]);
147
+ useLayoutEffect(() => {
148
+ if (designOpen) setDockOpen(false);
149
+ }, [designOpen]);
130
150
  const [, setWarmedTick] = useState(0);
131
151
  const handleAssetsWarmed = useCallback(() => {
132
152
  markDeckWarmed(slideId);
@@ -142,6 +162,8 @@ export function Slide() {
142
162
  const slideViewportRef = useRef<HTMLElement>(null);
143
163
  const t = useLocale();
144
164
  const isMobile = useIsMobile();
165
+ const compactToolbar = useMediaQuery('(max-width: 1023.98px)');
166
+ const narrowToolbar = useMediaQuery('(max-width: 639.98px)');
145
167
  const prefersReducedMotion = usePrefersReducedMotion();
146
168
 
147
169
  const modulePages = useMemo(() => slide?.default ?? [], [slide]);
@@ -188,6 +210,16 @@ export function Slide() {
188
210
  [pageCount, setSearchParams],
189
211
  );
190
212
 
213
+ // While an agent writes this deck page by page, jump to each page as it
214
+ // lands so the user can follow along.
215
+ const agentActivity = useAgentActivity(slideId);
216
+ const agentWriting = agentActivity !== null;
217
+ const seenPageCount = useRef(pageCount);
218
+ useEffect(() => {
219
+ if (agentWriting && pageCount > seenPageCount.current) goTo(pageCount - 1);
220
+ seenPageCount.current = pageCount;
221
+ }, [agentWriting, pageCount, goTo]);
222
+
191
223
  const reorderPage = useCallback(
192
224
  async (from: number, to: number) => {
193
225
  if (from === to) return;
@@ -349,11 +381,13 @@ export function Slide() {
349
381
  setPlayMode('window');
350
382
  } else if (import.meta.env.DEV && (e.key === 'd' || e.key === 'D')) {
351
383
  setDesignOpen((v) => !v);
384
+ } else if (import.meta.env.DEV && (e.key === 'a' || e.key === 'A')) {
385
+ toggleAgent();
352
386
  }
353
387
  };
354
388
  window.addEventListener('keydown', onKey);
355
389
  return () => window.removeEventListener('keydown', onKey);
356
- }, [index, goTo, playMode, slideId, overviewOpen, navigate]);
390
+ }, [index, goTo, playMode, slideId, overviewOpen, navigate, toggleAgent]);
357
391
 
358
392
  if (error) {
359
393
  return (
@@ -591,7 +625,7 @@ export function Slide() {
591
625
  };
592
626
 
593
627
  const exportMenuItems = (
594
- <>
628
+ <DropdownMenuGroup>
595
629
  <DropdownMenuItem disabled={exporting} onClick={exportHtml}>
596
630
  <FileCode2 />
597
631
  {t.slide.exportAsHtml}
@@ -609,7 +643,7 @@ export function Slide() {
609
643
  <FileImage />
610
644
  {t.slide.exportAsImagePptx}
611
645
  </DropdownMenuItem>
612
- </>
646
+ </DropdownMenuGroup>
613
647
  );
614
648
 
615
649
  return (
@@ -617,14 +651,16 @@ export function Slide() {
617
651
  <InspectorProvider
618
652
  slideId={slideId}
619
653
  pageIndex={index}
620
- panelHidden={designOpen}
621
- onPanelOpen={() => setDesignOpen(false)}
654
+ panelHidden={designOpen || agentOpen}
655
+ onPanelOpen={() => {
656
+ setDesignOpen(false);
657
+ setDockOpen(false);
658
+ }}
622
659
  >
623
660
  <SelectionReporter />
624
661
  <div className="flex h-dvh flex-col overflow-hidden bg-sidebar text-foreground">
625
- {/* Toolbar sits directly on the chrome ground — three zones, mono-folio center */}
626
- <header className="relative flex h-12 shrink-0 items-center gap-2 px-2 md:px-3">
627
- <div className="flex flex-1 items-center gap-1.5 md:flex-none md:gap-2">
662
+ <header className="grid min-h-12 shrink-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-x-2 gap-y-1 px-2 py-2 md:px-3">
663
+ <div className="col-start-1 row-start-1 flex min-w-0 items-center gap-1.5 md:gap-2">
628
664
  {showSlideBrowser && (
629
665
  <button
630
666
  type="button"
@@ -661,29 +697,23 @@ export function Slide() {
661
697
  {import.meta.env.DEV && <AgentConnectedBadge />}
662
698
  </div>
663
699
 
664
- {/* On md+ the title centers to the viewport via absolute positioning. On mobile the
665
- two side groups each flex-1, so the in-flow title lands at the viewport center too —
666
- and min-w-0 lets it truncate instead of overlapping the icons on narrow widths. */}
667
- <div className="pointer-events-none relative flex min-w-0 justify-center px-2 md:absolute md:inset-x-0">
668
- <div className="pointer-events-auto min-w-0 max-w-[34rem]">
700
+ <div className="col-span-3 row-start-2 flex min-w-0 justify-center sm:col-span-1 sm:col-start-2 sm:row-start-1">
701
+ <div className="min-w-0 max-w-[34rem]">
669
702
  <InlineTitleEditor title={title} onSubmit={(next) => renameSlide(slideId, next)} />
670
703
  </div>
671
704
  </div>
672
705
 
673
- <div className="flex flex-1 items-center justify-end gap-1 md:ml-auto md:flex-none">
674
- {view === 'slides' && <InspectModeSwitcher />}
675
- {import.meta.env.DEV && view === 'slides' && (
706
+ <div className="col-start-3 row-start-1 flex items-center justify-end gap-1">
707
+ {view === 'slides' && !narrowToolbar && <InspectModeSwitcher />}
708
+ {import.meta.env.DEV && view === 'slides' && !compactToolbar && (
676
709
  <span aria-hidden className="mx-0.5 hidden h-5 w-px bg-hairline md:block" />
677
710
  )}
678
- {view === 'slides' && (
711
+ {view === 'slides' && !compactToolbar && (
679
712
  <button
680
713
  type="button"
681
714
  aria-label={t.slide.copyLink}
682
715
  title={t.slide.copyLink}
683
- className={cn(
684
- buttonVariants({ variant: 'ghost', size: 'icon-sm' }),
685
- 'hidden md:inline-flex',
686
- )}
716
+ className={buttonVariants({ variant: 'ghost', size: 'icon-sm' })}
687
717
  onClick={copyLink}
688
718
  >
689
719
  <span className="relative grid size-4 place-items-center">
@@ -702,17 +732,14 @@ export function Slide() {
702
732
  </span>
703
733
  </button>
704
734
  )}
705
- {view === 'slides' && allowHtmlDownload && (
735
+ {view === 'slides' && allowHtmlDownload && !compactToolbar && (
706
736
  <DropdownMenu>
707
737
  <DropdownMenuTrigger
708
738
  type="button"
709
739
  disabled={exporting}
710
740
  aria-label={t.slide.download}
711
741
  title={t.slide.download}
712
- className={cn(
713
- buttonVariants({ variant: 'ghost', size: 'icon-sm' }),
714
- 'hidden md:inline-flex',
715
- )}
742
+ className={buttonVariants({ variant: 'ghost', size: 'icon-sm' })}
716
743
  >
717
744
  {exporting ? (
718
745
  <Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
@@ -725,17 +752,13 @@ export function Slide() {
725
752
  </DropdownMenuContent>
726
753
  </DropdownMenu>
727
754
  )}
728
- {view === 'slides' && (
729
- <DropdownMenu>
755
+ {view === 'slides' && compactToolbar && (
756
+ <DropdownMenu key={narrowToolbar ? 'narrow' : 'compact'}>
730
757
  <DropdownMenuTrigger
731
758
  type="button"
732
- disabled={exporting}
733
759
  aria-label={t.slide.moreActions}
734
760
  title={t.slide.moreActions}
735
- className={cn(
736
- buttonVariants({ variant: 'ghost', size: 'icon-sm' }),
737
- 'inline-flex md:hidden',
738
- )}
761
+ className={buttonVariants({ variant: 'ghost', size: 'icon-sm' })}
739
762
  >
740
763
  {exporting ? (
741
764
  <Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
@@ -744,34 +767,50 @@ export function Slide() {
744
767
  )}
745
768
  </DropdownMenuTrigger>
746
769
  <DropdownMenuContent align="end" className="min-w-[200px]">
747
- <DropdownMenuItem onClick={() => setCommandOpen(true)}>
748
- <Terminal />
749
- {t.commandMenu.trigger}
750
- </DropdownMenuItem>
751
- <DropdownMenuItem onClick={copyLink}>
752
- <Link2 />
753
- {t.slide.copyLink}
754
- </DropdownMenuItem>
770
+ {narrowToolbar && import.meta.env.DEV && (
771
+ <EditorMenuItems
772
+ designOpen={designOpen}
773
+ onDesignToggle={() => setDesignOpen((v) => !v)}
774
+ />
775
+ )}
776
+ <DropdownMenuGroup>
777
+ {narrowToolbar && import.meta.env.DEV && (
778
+ <DropdownMenuItem onClick={toggleAgent}>
779
+ <Sparkles />
780
+ {t.agent.openTitle}
781
+ </DropdownMenuItem>
782
+ )}
783
+ <DropdownMenuItem onClick={() => setCommandOpen(true)}>
784
+ <Terminal />
785
+ {t.commandMenu.trigger}
786
+ </DropdownMenuItem>
787
+ <DropdownMenuItem onClick={copyLink}>
788
+ <Link2 />
789
+ {t.slide.copyLink}
790
+ </DropdownMenuItem>
791
+ </DropdownMenuGroup>
755
792
  {allowHtmlDownload && <DropdownMenuSeparator />}
756
793
  {allowHtmlDownload && exportMenuItems}
757
794
  </DropdownMenuContent>
758
795
  </DropdownMenu>
759
796
  )}
760
- {view === 'slides' && (
797
+ {import.meta.env.DEV && view === 'slides' && !narrowToolbar && <AgentToggleButton />}
798
+ {view === 'slides' && !narrowToolbar && (
761
799
  <DesignToggleButton active={designOpen} onToggle={() => setDesignOpen((v) => !v)} />
762
800
  )}
763
- {view === 'slides' && <InspectPanelButton />}
801
+ {view === 'slides' && !narrowToolbar && <InspectPanelButton />}
764
802
  <span aria-hidden className="mx-0.5 hidden h-5 w-px bg-hairline md:block" />
765
803
  {view === 'slides' && (
766
804
  <div className="inline-flex items-stretch">
767
805
  <Button
768
806
  size="sm"
769
807
  variant="brand"
808
+ aria-label={t.slide.present}
770
809
  onClick={() => setPlayMode(isMobile ? 'window' : 'fullscreen')}
771
- className="px-2.5 md:rounded-r-none md:px-3"
810
+ className="rounded-r-none px-2.5 lg:px-3"
772
811
  >
773
812
  <Play className="size-3.5 fill-current" />
774
- <span className="hidden md:inline">{t.slide.present}</span>
813
+ <span className="hidden lg:inline">{t.slide.present}</span>
775
814
  </Button>
776
815
  <DropdownMenu>
777
816
  <DropdownMenuTrigger
@@ -780,32 +819,34 @@ export function Slide() {
780
819
  title={t.slide.presentMenuAria}
781
820
  className={cn(
782
821
  buttonVariants({ variant: 'brand', size: 'sm' }),
783
- 'hidden rounded-l-none px-1.5 shadow-[inset_1px_0_0_oklch(0_0_0/0.12),inset_0_1px_0_oklch(1_0_0/0.18),0_1px_0_oklch(0_0_0/0.16)] md:inline-flex',
822
+ 'rounded-l-none px-1.5 shadow-[inset_1px_0_0_oklch(0_0_0/0.12),inset_0_1px_0_oklch(1_0_0/0.18),0_1px_0_oklch(0_0_0/0.16)]',
784
823
  )}
785
824
  >
786
825
  <ChevronDown className="size-3.5" />
787
826
  </DropdownMenuTrigger>
788
827
  <DropdownMenuContent align="end" className="min-w-[200px]">
789
- <DropdownMenuItem onClick={() => setPlayMode('window')}>
790
- <Play />
791
- {t.slide.presentInWindow}
792
- <DropdownMenuShortcut>↵</DropdownMenuShortcut>
793
- </DropdownMenuItem>
794
- <DropdownMenuItem onClick={() => setPlayMode('fullscreen')}>
795
- <Maximize />
796
- {t.slide.presentFullscreen}
797
- <DropdownMenuShortcut>F</DropdownMenuShortcut>
798
- </DropdownMenuItem>
799
- <DropdownMenuItem
800
- onClick={() => {
801
- if (slideId) openPresenterWindow(slideId);
802
- setPlayMode('window');
803
- }}
804
- >
805
- <MonitorSpeaker />
806
- {t.slide.presentPresenter}
807
- <DropdownMenuShortcut>P</DropdownMenuShortcut>
808
- </DropdownMenuItem>
828
+ <DropdownMenuGroup>
829
+ <DropdownMenuItem onClick={() => setPlayMode('window')}>
830
+ <Play />
831
+ {t.slide.presentInWindow}
832
+ <DropdownMenuShortcut>↵</DropdownMenuShortcut>
833
+ </DropdownMenuItem>
834
+ <DropdownMenuItem onClick={() => setPlayMode('fullscreen')}>
835
+ <Maximize />
836
+ {t.slide.presentFullscreen}
837
+ <DropdownMenuShortcut>F</DropdownMenuShortcut>
838
+ </DropdownMenuItem>
839
+ <DropdownMenuItem
840
+ onClick={() => {
841
+ if (slideId) openPresenterWindow(slideId);
842
+ setPlayMode('window');
843
+ }}
844
+ >
845
+ <MonitorSpeaker />
846
+ {t.slide.presentPresenter}
847
+ <DropdownMenuShortcut>P</DropdownMenuShortcut>
848
+ </DropdownMenuItem>
849
+ </DropdownMenuGroup>
809
850
  </DropdownMenuContent>
810
851
  </DropdownMenu>
811
852
  </div>
@@ -840,6 +881,7 @@ export function Slide() {
840
881
  ref={slideViewportRef}
841
882
  data-inspector-root
842
883
  data-slide-id={slideId}
884
+ data-agent-provider={agentActivity?.provider}
843
885
  className="relative min-h-0 min-w-0 flex-1 bg-background p-2 md:mx-2 md:mb-2 md:rounded-[10px] md:p-10 md:shadow-edge md:ring-1 md:ring-foreground/[0.06]"
844
886
  >
845
887
  <SlideViewportNavigation
@@ -861,7 +903,8 @@ export function Slide() {
861
903
  <InspectOverlay />
862
904
  <InlineEditLayer />
863
905
  <SaveBar />
864
- {import.meta.env.DEV && <CommentWidget />}
906
+ {import.meta.env.DEV && <CommentLayer />}
907
+ {agentActivity && <AgentPresence activity={agentActivity} revision={pages} />}
865
908
  </main>
866
909
  {/* Mobile-only horizontal rail. Sits below the canvas and
867
910
  pads its bottom for the iOS home indicator / Safari URL bar. */}
@@ -881,6 +924,17 @@ export function Slide() {
881
924
  <EditorSidebar
882
925
  designOpen={designOpen}
883
926
  onCloseDesign={() => setDesignOpen(false)}
927
+ agentOpen={agentOpen}
928
+ agent={
929
+ import.meta.env.DEV && (
930
+ <SlideAgentDock
931
+ slideId={slideId}
932
+ slideTitle={title}
933
+ pageNumber={index + 1}
934
+ totalPages={pageCount}
935
+ />
936
+ )
937
+ }
884
938
  />
885
939
  </div>
886
940
  {import.meta.env.DEV && (
@@ -922,6 +976,7 @@ export function Slide() {
922
976
  onCopyLink: copyLink,
923
977
  onOverview: () => setOverviewOpen(true),
924
978
  onToggleDesignPanel: () => setDesignOpen((v) => !v),
979
+ onToggleAgentPanel: toggleAgent,
925
980
  onExportHtml: exportHtml,
926
981
  onExportPdf: exportPdf,
927
982
  onExportPptx: exportPptx,
@@ -941,14 +996,6 @@ const DEFAULT_RAIL_WIDTH = 264;
941
996
  const MIN_RAIL_WIDTH = 200;
942
997
  const MAX_RAIL_WIDTH = 480;
943
998
 
944
- function readStoredRailWidth(): number {
945
- if (typeof window === 'undefined') return DEFAULT_RAIL_WIDTH;
946
- const raw = window.localStorage.getItem(RAIL_WIDTH_STORAGE_KEY);
947
- const parsed = raw == null ? Number.NaN : Number(raw);
948
- if (!Number.isFinite(parsed)) return DEFAULT_RAIL_WIDTH;
949
- return Math.min(MAX_RAIL_WIDTH, Math.max(MIN_RAIL_WIDTH, parsed));
950
- }
951
-
952
999
  function ResizableRail(props: {
953
1000
  pages: SlideModule['default'];
954
1001
  design?: SlideModule['design'];
@@ -961,107 +1008,104 @@ function ResizableRail(props: {
961
1008
  onBoard?: () => void;
962
1009
  }) {
963
1010
  const t = useLocale();
964
- const [width, setWidth] = useState<number>(readStoredRailWidth);
965
- const [resizing, setResizing] = useState(false);
966
- const dragRef = useRef<{ startX: number; startWidth: number } | null>(null);
967
-
968
- useEffect(() => {
969
- if (typeof window === 'undefined') return;
970
- window.localStorage.setItem(RAIL_WIDTH_STORAGE_KEY, String(width));
971
- }, [width]);
972
-
973
- useEffect(() => {
974
- if (!resizing) return;
975
- const prev = {
976
- cursor: document.body.style.cursor,
977
- userSelect: document.body.style.userSelect,
978
- };
979
- document.body.style.cursor = 'col-resize';
980
- document.body.style.userSelect = 'none';
981
- return () => {
982
- document.body.style.cursor = prev.cursor;
983
- document.body.style.userSelect = prev.userSelect;
984
- };
985
- }, [resizing]);
986
-
987
- const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
988
- e.preventDefault();
989
- e.currentTarget.setPointerCapture(e.pointerId);
990
- dragRef.current = { startX: e.clientX, startWidth: width };
991
- setResizing(true);
992
- };
993
-
994
- const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
995
- if (!dragRef.current) return;
996
- const delta = e.clientX - dragRef.current.startX;
997
- const next = Math.min(
998
- MAX_RAIL_WIDTH,
999
- Math.max(MIN_RAIL_WIDTH, dragRef.current.startWidth + delta),
1000
- );
1001
- setWidth(next);
1002
- };
1003
-
1004
- const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
1005
- if (e.currentTarget.hasPointerCapture(e.pointerId)) {
1006
- e.currentTarget.releasePointerCapture(e.pointerId);
1007
- }
1008
- dragRef.current = null;
1009
- setResizing(false);
1010
- };
1011
-
1012
- const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
1013
- const step = e.shiftKey ? 32 : 8;
1014
- if (e.key === 'ArrowLeft') {
1015
- e.preventDefault();
1016
- e.stopPropagation();
1017
- setWidth((w) => Math.max(MIN_RAIL_WIDTH, w - step));
1018
- } else if (e.key === 'ArrowRight') {
1019
- e.preventDefault();
1020
- e.stopPropagation();
1021
- setWidth((w) => Math.min(MAX_RAIL_WIDTH, w + step));
1022
- } else if (e.key === 'Home') {
1023
- e.preventDefault();
1024
- e.stopPropagation();
1025
- setWidth(DEFAULT_RAIL_WIDTH);
1026
- }
1027
- };
1011
+ const [width, setWidth] = useStoredWidth(
1012
+ RAIL_WIDTH_STORAGE_KEY,
1013
+ DEFAULT_RAIL_WIDTH,
1014
+ MIN_RAIL_WIDTH,
1015
+ MAX_RAIL_WIDTH,
1016
+ );
1028
1017
 
1029
1018
  return (
1030
1019
  <div className="relative hidden shrink-0 md:block" style={{ width }}>
1031
1020
  <ThumbnailRail width={width} {...props} />
1032
- {/* biome-ignore lint/a11y/useSemanticElements: focusable resize handle (splitter pattern), not a static <hr> */}
1033
- <div
1034
- role="separator"
1035
- aria-orientation="vertical"
1036
- aria-label={t.thumbnailRail.resizeRail}
1037
- aria-valuenow={width}
1038
- aria-valuemin={MIN_RAIL_WIDTH}
1039
- aria-valuemax={MAX_RAIL_WIDTH}
1040
- tabIndex={0}
1041
- onPointerDown={onPointerDown}
1042
- onPointerMove={onPointerMove}
1043
- onPointerUp={onPointerUp}
1044
- onPointerCancel={onPointerUp}
1045
- onKeyDown={onKeyDown}
1046
- onDoubleClick={() => setWidth(DEFAULT_RAIL_WIDTH)}
1047
- className={cn(
1048
- 'group/resize absolute inset-y-0 right-0 z-20 w-1.5 translate-x-1/2 cursor-col-resize touch-none outline-none',
1049
- 'focus-visible:bg-brand/20',
1050
- )}
1051
- >
1052
- <span
1053
- aria-hidden
1054
- className={cn(
1055
- 'pointer-events-none absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-brand opacity-0 transition-opacity duration-150',
1056
- 'group-hover/resize:opacity-100 group-focus-visible/resize:opacity-100',
1057
- resizing && 'opacity-100',
1058
- )}
1059
- />
1060
- </div>
1021
+ <ResizeHandle
1022
+ width={width}
1023
+ min={MIN_RAIL_WIDTH}
1024
+ max={MAX_RAIL_WIDTH}
1025
+ defaultWidth={DEFAULT_RAIL_WIDTH}
1026
+ edge="right"
1027
+ label={t.thumbnailRail.resizeRail}
1028
+ onResize={setWidth}
1029
+ className="right-0 translate-x-1/2"
1030
+ />
1061
1031
  </div>
1062
1032
  );
1063
1033
  }
1064
1034
 
1035
+ function EditorMenuItems({
1036
+ designOpen,
1037
+ onDesignToggle,
1038
+ }: {
1039
+ designOpen: boolean;
1040
+ onDesignToggle: () => void;
1041
+ }) {
1042
+ const t = useLocale();
1043
+ const { active, toggle, panelOpen, panelHidden, togglePanel } = useInspector();
1044
+ return (
1045
+ <>
1046
+ <DropdownMenuGroup>
1047
+ <DropdownMenuRadioGroup
1048
+ value={active ? 'edit' : 'preview'}
1049
+ onValueChange={(value) => {
1050
+ if ((value === 'edit') !== active) toggle();
1051
+ }}
1052
+ >
1053
+ <DropdownMenuRadioItem value="preview">{t.inspector.previewMode}</DropdownMenuRadioItem>
1054
+ <DropdownMenuRadioItem value="edit">{t.inspector.editMode}</DropdownMenuRadioItem>
1055
+ </DropdownMenuRadioGroup>
1056
+ <DropdownMenuCheckboxItem
1057
+ checked={designOpen}
1058
+ onCheckedChange={onDesignToggle}
1059
+ closeOnClick
1060
+ >
1061
+ {t.stylePanel.designToggle}
1062
+ </DropdownMenuCheckboxItem>
1063
+ <DropdownMenuCheckboxItem
1064
+ checked={active && panelOpen && !panelHidden}
1065
+ onCheckedChange={togglePanel}
1066
+ closeOnClick
1067
+ >
1068
+ {t.inspector.format}
1069
+ </DropdownMenuCheckboxItem>
1070
+ </DropdownMenuGroup>
1071
+ <DropdownMenuSeparator />
1072
+ </>
1073
+ );
1074
+ }
1075
+
1076
+ function SlideAgentDock({
1077
+ slideId,
1078
+ slideTitle,
1079
+ pageNumber,
1080
+ totalPages,
1081
+ }: {
1082
+ slideId: string;
1083
+ slideTitle: string;
1084
+ pageNumber: number;
1085
+ totalPages: number;
1086
+ }) {
1087
+ const { selected, comments } = useInspector();
1088
+ const context = useMemo<AgentTurnContext>(
1089
+ () => ({
1090
+ slideId,
1091
+ slideTitle,
1092
+ pageNumber,
1093
+ totalPages,
1094
+ pendingComments: comments.length,
1095
+ selection: selected
1096
+ ? {
1097
+ line: selected.line,
1098
+ column: selected.column,
1099
+ tagName: selected.anchor.tagName.toLowerCase(),
1100
+ text: (selected.anchor.textContent ?? '').replace(/\s+/g, ' ').trim().slice(0, 120),
1101
+ }
1102
+ : null,
1103
+ }),
1104
+ [slideId, slideTitle, pageNumber, totalPages, comments.length, selected],
1105
+ );
1106
+ return <AgentPanel context={context} onClose={() => setDockOpen(false)} />;
1107
+ }
1108
+
1065
1109
  function AgentConnectedBadge() {
1066
1110
  const t = useLocale();
1067
1111
  const connected = useAgentSocketConnected();
@@ -1072,6 +1116,7 @@ function AgentConnectedBadge() {
1072
1116
  render={
1073
1117
  <button
1074
1118
  type="button"
1119
+ aria-label={connected ? t.slide.agentConnected : t.slide.agentDisconnected}
1075
1120
  className="ml-1 flex shrink-0 cursor-help items-center gap-1.5 rounded-[3px] border border-hairline bg-card px-1.5 py-0.5 text-[10.5px] text-foreground/85 outline-none transition-colors duration-150 hover:bg-muted/50 focus-visible:ring-2 focus-visible:ring-ring/30"
1076
1121
  >
1077
1122
  <span aria-hidden className="relative flex size-1.5 items-center justify-center">
@@ -1084,7 +1129,9 @@ function AgentConnectedBadge() {
1084
1129
  <span className="relative inline-flex size-1.5 rounded-full bg-rose-500" />
1085
1130
  )}
1086
1131
  </span>
1087
- {connected ? t.slide.agentConnected : t.slide.agentDisconnected}
1132
+ <span className="hidden xl:inline">
1133
+ {connected ? t.slide.agentConnected : t.slide.agentDisconnected}
1134
+ </span>
1088
1135
  </button>
1089
1136
  }
1090
1137
  />
@@ -1130,13 +1177,14 @@ function SlideViewportNavigation({
1130
1177
  canPrev: boolean;
1131
1178
  canNext: boolean;
1132
1179
  }) {
1133
- const { active, inlineEdit } = useInspector();
1180
+ const { mode, inlineEdit } = useInspector();
1134
1181
  const isMobile = useIsMobile();
1135
1182
  const editing = !!inlineEdit;
1183
+ const browsing = mode === 'preview';
1136
1184
 
1137
1185
  useWheelPageNavigation({
1138
1186
  ref: targetRef,
1139
- enabled: !active && !editing,
1187
+ enabled: browsing && !editing,
1140
1188
  canPrev,
1141
1189
  canNext,
1142
1190
  onPrev,
@@ -1148,7 +1196,7 @@ function SlideViewportNavigation({
1148
1196
  // zones). Interactive slide content keeps its tap via the hook's passthrough.
1149
1197
  useClickPageNavigation({
1150
1198
  ref: targetRef,
1151
- enabled: isMobile && !active && !editing,
1199
+ enabled: isMobile && browsing && !editing,
1152
1200
  edgeRatio: 0.18,
1153
1201
  canPrev,
1154
1202
  canNext,