@saadnvd1/agent-os 0.3.799 → 0.3.810

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 (109) hide show
  1. package/app/api/chat-views/[sessionId]/route.ts +19 -0
  2. package/app/api/chat-views/route.test.ts +79 -0
  3. package/app/api/chat-views/route.ts +8 -0
  4. package/app/page.tsx +17 -14
  5. package/components/Chat/ChatPanel.tsx +46 -59
  6. package/components/Chat/NewDivider.tsx +15 -0
  7. package/components/Chat/session-link.ts +7 -4
  8. package/components/Chat/useFind.ts +50 -1
  9. package/components/Chat/usePosition.ts +363 -0
  10. package/components/CodeSearchDialog.tsx +90 -0
  11. package/components/CommandPalette/CommandsBody.tsx +12 -4
  12. package/components/CommandPalette/useAppCommands.ts +29 -11
  13. package/components/DevServers/ServerLogsModal.tsx +24 -21
  14. package/components/Media/MediaViewer.tsx +111 -0
  15. package/components/Media/viewer.test.ts +26 -0
  16. package/components/Media/viewer.ts +23 -0
  17. package/components/Nav/HistoryButtons.tsx +128 -0
  18. package/components/Pane/MobileTabBar.tsx +3 -0
  19. package/components/Pane/index.tsx +2 -2
  20. package/components/SessionList/useSessionSwitching.ts +3 -2
  21. package/components/Shortcuts/PendingKeys.tsx +19 -0
  22. package/components/Shortcuts/ShortcutsButton.tsx +22 -0
  23. package/components/Shortcuts/ShortcutsDialog.tsx +122 -0
  24. package/components/SidebarFooter.tsx +2 -0
  25. package/components/Terminal/hooks/useTerminalConnection.ts +4 -3
  26. package/components/Tips/TipDialog.tsx +1 -1
  27. package/components/Tips/TipMedia.tsx +38 -3
  28. package/components/Updates/{UpdateBanner.test.ts → UpdateToast.test.ts} +158 -8
  29. package/components/Updates/{UpdateBanner.tsx → UpdateToast.tsx} +119 -89
  30. package/components/Updates/index.ts +1 -1
  31. package/components/views/DesktopView.tsx +9 -11
  32. package/components/views/MobileView.tsx +4 -10
  33. package/components/views/lazy.ts +2 -2
  34. package/content/tips.json +146 -61
  35. package/content/whats-new.json +48 -0
  36. package/data/chat-views/index.ts +2 -0
  37. package/data/chat-views/keys.ts +3 -0
  38. package/data/chat-views/queries.ts +34 -0
  39. package/data/sessions/pin.ts +0 -1
  40. package/hooks/useChatViewsSync.ts +12 -0
  41. package/hooks/useNavActions.ts +104 -0
  42. package/hooks/useNavKeys.ts +105 -0
  43. package/hooks/useSessionUrl.ts +23 -5
  44. package/lib/bus/index.ts +4 -2
  45. package/lib/bus/role-alias.ts +4 -2
  46. package/lib/bus/targets.ts +2 -1
  47. package/lib/chat/escape.ts +8 -4
  48. package/lib/chat-views/body.ts +41 -0
  49. package/lib/chat-views/store.ts +67 -0
  50. package/lib/chat-views/types.ts +20 -0
  51. package/lib/db/migrations/20261031T1500_chat_views.ts +30 -0
  52. package/lib/keybindings/focus.ts +17 -0
  53. package/lib/keybindings/keymap.ts +120 -0
  54. package/lib/keybindings/sequence.ts +64 -0
  55. package/lib/live/service.ts +1 -1
  56. package/lib/nav/history-mirror.ts +59 -0
  57. package/lib/nav/history-writer.ts +109 -0
  58. package/lib/nav/position.ts +67 -0
  59. package/lib/nav/recent.ts +34 -0
  60. package/lib/orchestrator/armed.ts +5 -0
  61. package/lib/palette/sections.ts +12 -6
  62. package/lib/session-url.ts +11 -5
  63. package/package.json +1 -1
  64. package/public/tips/chat-access-picker-dark.webp +0 -0
  65. package/public/tips/chat-access-picker.webp +0 -0
  66. package/public/tips/chat-attach-files-poster.webp +0 -0
  67. package/public/tips/chat-attach-files.mp4 +0 -0
  68. package/public/tips/chat-bookmark-dark.webp +0 -0
  69. package/public/tips/chat-bookmark.webp +0 -0
  70. package/public/tips/chat-plan-mode-dark.webp +0 -0
  71. package/public/tips/chat-plan-mode.webp +0 -0
  72. package/public/tips/chat-search-poster.webp +0 -0
  73. package/public/tips/chat-search.mp4 +0 -0
  74. package/public/tips/chat-tab-suggestion-poster.webp +0 -0
  75. package/public/tips/chat-tab-suggestion.mp4 +0 -0
  76. package/public/tips/chat-terminal-handoff-poster.webp +0 -0
  77. package/public/tips/chat-terminal-handoff.mp4 +0 -0
  78. package/public/tips/chat-undo-dark.webp +0 -0
  79. package/public/tips/chat-undo.webp +0 -0
  80. package/public/tips/export-ai-dark.webp +0 -0
  81. package/public/tips/export-ai.webp +0 -0
  82. package/public/tips/net-messages-dark.webp +0 -0
  83. package/public/tips/net-messages.webp +0 -0
  84. package/public/tips/recipe-save-dark.webp +0 -0
  85. package/public/tips/recipe-save.webp +0 -0
  86. package/public/tips/schedule-first-dark.webp +0 -0
  87. package/public/tips/schedule-first.webp +0 -0
  88. package/public/tips/share-check-dark.webp +0 -0
  89. package/public/tips/share-check.webp +0 -0
  90. package/public/tips/sidebar-draft-poster.webp +0 -0
  91. package/public/tips/sidebar-draft.mp4 +0 -0
  92. package/public/tips/sidebar-groups-dark.webp +0 -0
  93. package/public/tips/sidebar-groups.webp +0 -0
  94. package/public/tips/sidebar-load-dark.webp +0 -0
  95. package/public/tips/sidebar-load.webp +0 -0
  96. package/public/tips/sidebar-snooze-dark.webp +0 -0
  97. package/public/tips/sidebar-snooze.webp +0 -0
  98. package/public/tips/tasks-plan-pieces-dark.webp +0 -0
  99. package/public/tips/tasks-plan-pieces.webp +0 -0
  100. package/public/tips/tasks-sign-off-dark.webp +0 -0
  101. package/public/tips/tasks-sign-off.webp +0 -0
  102. package/public/tips/tasks-stacks-dark.webp +0 -0
  103. package/public/tips/tasks-stacks.webp +0 -0
  104. package/scripts/check +2 -2
  105. package/stores/chatJump.ts +2 -6
  106. package/stores/chatPositions.ts +65 -0
  107. package/stores/navHistory.ts +98 -0
  108. package/stores/shortcutsUi.ts +20 -0
  109. package/components/QuickSwitcher.tsx +0 -286
@@ -0,0 +1,111 @@
1
+ "use client";
2
+
3
+ import * as DialogPrimitive from "@radix-ui/react-dialog";
4
+ import { X } from "lucide-react";
5
+ import { useRef, useState, type TouchEvent } from "react";
6
+ import { Dialog, DialogOverlay, DialogPortal } from "@/components/ui/dialog";
7
+ import { useMediaQuery } from "@/hooks/useMediaQuery";
8
+ import { isCloseSwipe, type ViewerMedia } from "./viewer";
9
+
10
+ /** Full-screen image or clip over everything; closes on its own, never its parent dialog. */
11
+ export function MediaViewer({
12
+ media,
13
+ open,
14
+ onOpenChange,
15
+ }: {
16
+ media: ViewerMedia;
17
+ open: boolean;
18
+ onOpenChange: (open: boolean) => void;
19
+ }) {
20
+ const reduced = useMediaQuery("(prefers-reduced-motion: reduce)");
21
+ const start = useRef<{ x: number; y: number } | null>(null);
22
+ const [drag, setDrag] = useState(0);
23
+
24
+ const onTouchStart = (e: TouchEvent) => {
25
+ const t = e.touches[0];
26
+ start.current = { x: t.clientX, y: t.clientY };
27
+ };
28
+ const onTouchMove = (e: TouchEvent) => {
29
+ if (!start.current) return;
30
+ setDrag(Math.max(0, e.touches[0].clientY - start.current.y));
31
+ };
32
+ const onTouchEnd = (e: TouchEvent) => {
33
+ const s = start.current;
34
+ start.current = null;
35
+ setDrag(0);
36
+ if (!s) return;
37
+ const t = e.changedTouches[0];
38
+ if (isCloseSwipe(t.clientX - s.x, t.clientY - s.y)) onOpenChange(false);
39
+ };
40
+
41
+ return (
42
+ <Dialog open={open} onOpenChange={onOpenChange}>
43
+ <DialogPortal>
44
+ <DialogOverlay className="z-[70] bg-black/90" />
45
+ <DialogPrimitive.Content
46
+ aria-label={media.alt}
47
+ aria-describedby={undefined}
48
+ onClick={(e) => {
49
+ // The backdrop is this full-screen box; the media itself isn't a target.
50
+ if (e.target === e.currentTarget) onOpenChange(false);
51
+ }}
52
+ onTouchStart={onTouchStart}
53
+ onTouchMove={onTouchMove}
54
+ onTouchEnd={onTouchEnd}
55
+ onTouchCancel={() => {
56
+ start.current = null;
57
+ setDrag(0);
58
+ }}
59
+ style={{
60
+ transform: drag ? `translateY(${drag}px)` : undefined,
61
+ paddingTop: "max(1rem, env(safe-area-inset-top))",
62
+ paddingRight: "max(1rem, env(safe-area-inset-right))",
63
+ paddingBottom: "max(1rem, env(safe-area-inset-bottom))",
64
+ paddingLeft: "max(1rem, env(safe-area-inset-left))",
65
+ }}
66
+ className="fixed inset-0 z-[70] grid place-items-center outline-none"
67
+ >
68
+ <DialogPrimitive.Title className="sr-only">
69
+ {media.alt}
70
+ </DialogPrimitive.Title>
71
+ {media.kind === "image" ? (
72
+ // eslint-disable-next-line @next/next/no-img-element
73
+ <img
74
+ src={media.src}
75
+ alt={media.alt}
76
+ width={media.width}
77
+ height={media.height}
78
+ decoding="async"
79
+ className="max-h-full max-w-full rounded-lg object-contain"
80
+ />
81
+ ) : (
82
+ <video
83
+ key={media.src}
84
+ src={media.src}
85
+ poster={media.poster}
86
+ aria-label={media.alt}
87
+ width={media.width}
88
+ height={media.height}
89
+ muted
90
+ loop
91
+ playsInline
92
+ autoPlay={!reduced}
93
+ controls={reduced}
94
+ className="max-h-full max-w-full rounded-lg object-contain"
95
+ />
96
+ )}
97
+ <DialogPrimitive.Close
98
+ aria-label="Close"
99
+ className="absolute grid size-11 place-items-center rounded-full bg-black/60 text-white hover:bg-black/80 focus-visible:ring-2 focus-visible:ring-white focus-visible:outline-none"
100
+ style={{
101
+ top: "max(0.75rem, env(safe-area-inset-top))",
102
+ right: "max(0.75rem, env(safe-area-inset-right))",
103
+ }}
104
+ >
105
+ <X className="size-5" />
106
+ </DialogPrimitive.Close>
107
+ </DialogPrimitive.Content>
108
+ </DialogPortal>
109
+ </Dialog>
110
+ );
111
+ }
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isCloseSwipe, pickSrc } from "./viewer";
3
+
4
+ describe("pickSrc", () => {
5
+ const media = { src: "/a.png", srcDark: "/a-dark.png" };
6
+ it("uses the dark image in a dark theme", () => {
7
+ expect(pickSrc(media, true)).toBe("/a-dark.png");
8
+ });
9
+ it("uses the light image otherwise", () => {
10
+ expect(pickSrc(media, false)).toBe("/a.png");
11
+ });
12
+ it("falls back when there is no dark variant", () => {
13
+ expect(pickSrc({ src: "/a.png" }, true)).toBe("/a.png");
14
+ });
15
+ });
16
+
17
+ describe("isCloseSwipe", () => {
18
+ it("closes on a long downward drag", () => {
19
+ expect(isCloseSwipe(5, 120)).toBe(true);
20
+ });
21
+ it("ignores short drags, upward drags and sideways drags", () => {
22
+ expect(isCloseSwipe(0, 30)).toBe(false);
23
+ expect(isCloseSwipe(0, -200)).toBe(false);
24
+ expect(isCloseSwipe(150, 90)).toBe(false);
25
+ });
26
+ });
@@ -0,0 +1,23 @@
1
+ export interface ViewerMedia {
2
+ kind: "image" | "video";
3
+ src: string;
4
+ alt: string;
5
+ width: number;
6
+ height: number;
7
+ poster?: string;
8
+ }
9
+
10
+ /** The dark variant when the theme is dark and one exists. */
11
+ export function pickSrc(
12
+ media: { src: string; srcDark?: string },
13
+ dark: boolean
14
+ ): string {
15
+ return dark && media.srcDark ? media.srcDark : media.src;
16
+ }
17
+
18
+ export const SWIPE_CLOSE_PX = 80;
19
+
20
+ /** A mostly-vertical downward drag past the threshold closes the viewer. */
21
+ export function isCloseSwipe(dx: number, dy: number): boolean {
22
+ return dy >= SWIPE_CLOSE_PX && dy > Math.abs(dx) * 1.2;
23
+ }
@@ -0,0 +1,128 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { useSnapshot } from "valtio";
5
+ import { ArrowLeft, ArrowRight } from "lucide-react";
6
+ import { Button } from "@/components/ui/button";
7
+ import {
8
+ DropdownMenu,
9
+ DropdownMenuContent,
10
+ DropdownMenuItem,
11
+ DropdownMenuLabel,
12
+ DropdownMenuSeparator,
13
+ DropdownMenuTrigger,
14
+ } from "@/components/ui/dropdown-menu";
15
+ import {
16
+ Tooltip,
17
+ TooltipContent,
18
+ TooltipTrigger,
19
+ } from "@/components/ui/tooltip";
20
+ import { useLongPress } from "@/components/Chat/useLongPress";
21
+ import { earlier, later, type MirrorEntry } from "@/lib/nav/history-mirror";
22
+ import { isMac } from "@/lib/keybindings/chord";
23
+ import { navHistory, navHistoryActions } from "@/stores/navHistory";
24
+
25
+ const KEYS_BACK = () => (isMac() ? "⌘[" : "Alt+←");
26
+ const KEYS_FORWARD = () => (isMac() ? "⌘]" : "Alt+→");
27
+
28
+ function useReach() {
29
+ const snap = useSnapshot(navHistory);
30
+ const m = { entries: [...snap.entries], current: snap.current };
31
+ return { back: earlier(m), forward: later(m) };
32
+ }
33
+
34
+ function Entries({ entries }: { entries: MirrorEntry[] }) {
35
+ return entries.slice(0, 15).map((e) => (
36
+ <DropdownMenuItem
37
+ key={e.index}
38
+ className="min-h-11 md:min-h-0"
39
+ onSelect={() => navHistoryActions.goTo(e.index)}
40
+ >
41
+ <span className="truncate">{e.name || "Chat"}</span>
42
+ </DropdownMenuItem>
43
+ ));
44
+ }
45
+
46
+ /** One history button: tap goes a step; hold (touch) or right-click opens the list of chats it would pass. */
47
+ function HistoryButton({
48
+ dir,
49
+ entries,
50
+ className,
51
+ }: {
52
+ dir: "back" | "forward";
53
+ entries: MirrorEntry[];
54
+ className?: string;
55
+ }) {
56
+ const [open, setOpen] = useState(false);
57
+ const hold = useLongPress(entries.length > 0 ? () => setOpen(true) : null);
58
+ const Icon = dir === "back" ? ArrowLeft : ArrowRight;
59
+ const label = dir === "back" ? "Back" : "Forward";
60
+ const hint = dir === "back" ? KEYS_BACK() : KEYS_FORWARD();
61
+ const go = () =>
62
+ dir === "back" ? navHistoryActions.back() : navHistoryActions.forward();
63
+ const button = (
64
+ <Button
65
+ variant="ghost"
66
+ size="icon-sm"
67
+ aria-label={label}
68
+ disabled={entries.length === 0}
69
+ className={className}
70
+ onClick={(e) => {
71
+ e.stopPropagation();
72
+ go();
73
+ }}
74
+ onContextMenu={(e) => {
75
+ if (entries.length === 0) return;
76
+ e.preventDefault();
77
+ setOpen(true);
78
+ }}
79
+ {...hold}
80
+ >
81
+ <Icon className="h-4 w-4" />
82
+ </Button>
83
+ );
84
+ return (
85
+ <DropdownMenu open={open} onOpenChange={setOpen} modal={false}>
86
+ <Tooltip>
87
+ <TooltipTrigger asChild>
88
+ <DropdownMenuTrigger asChild>{button}</DropdownMenuTrigger>
89
+ </TooltipTrigger>
90
+ <TooltipContent>{`${label} (${hint})`}</TooltipContent>
91
+ </Tooltip>
92
+ <DropdownMenuContent align="start" className="max-w-72">
93
+ <DropdownMenuLabel>
94
+ {dir === "back" ? "Earlier" : "Later"}
95
+ </DropdownMenuLabel>
96
+ <DropdownMenuSeparator />
97
+ <Entries entries={entries} />
98
+ </DropdownMenuContent>
99
+ </DropdownMenu>
100
+ );
101
+ }
102
+
103
+ /** Desktop: back and forward through the chats and bookmarks visited. */
104
+ export function HistoryButtons() {
105
+ const { back, forward } = useReach();
106
+ return (
107
+ <>
108
+ <HistoryButton dir="back" entries={back} className="h-7 w-7 shrink-0" />
109
+ <HistoryButton
110
+ dir="forward"
111
+ entries={forward}
112
+ className="h-7 w-7 shrink-0"
113
+ />
114
+ </>
115
+ );
116
+ }
117
+
118
+ /** Phone: the back button beside the menu; hold it for the list. */
119
+ export function PhoneBackButton() {
120
+ const { back } = useReach();
121
+ return (
122
+ <HistoryButton
123
+ dir="back"
124
+ entries={back}
125
+ className="relative h-8 w-8 shrink-0 after:absolute after:-inset-1.5 after:content-['']"
126
+ />
127
+ );
128
+ }
@@ -44,6 +44,7 @@ import { useCanAddAnalyst } from "@/components/Analyst/pair";
44
44
  import { MoveMenuItems } from "@/components/Tasks/MoveMenuItems";
45
45
  import { ChatBookmarksButton } from "@/components/Bookmarks/ChatBookmarksButton";
46
46
  import type { ViewMode } from "@/contexts/PaneContext";
47
+ import { PhoneBackButton } from "@/components/Nav/HistoryButtons";
47
48
  import { MobileViewPicker } from "./MobileViewPicker";
48
49
 
49
50
  interface MobileTabBarProps {
@@ -169,6 +170,8 @@ export function MobileTabBar({
169
170
  </Button>
170
171
  )}
171
172
 
173
+ <PhoneBackButton />
174
+
172
175
  {/* Session/Tab navigation */}
173
176
  <div className="flex min-w-0 flex-1 items-center gap-1">
174
177
  <button
@@ -255,7 +255,7 @@ export const Pane = memo(function Pane({
255
255
  fileOpenActions.clearRequest();
256
256
  // TODO: Scroll to line (requires FileEditor enhancement)
257
257
  }
258
- }, [fileOpenRequest, isFocused, session, fileEditor]);
258
+ }, [fileOpenRequest, isFocused, session, fileEditor, setViewMode]);
259
259
 
260
260
  // An artifact's file, opened in the Files view.
261
261
  const openInFiles = useCallback(
@@ -291,7 +291,7 @@ export const Pane = memo(function Pane({
291
291
 
292
292
  // Create onConnected callback for a specific tab
293
293
  const getTerminalConnectedHandler = useCallback(
294
- (tab: (typeof paneData.tabs)[0]) => () => {
294
+ (tab: TabData) => () => {
295
295
  console.log(
296
296
  `[AgentOS] Terminal connected for pane: ${paneId}, tab: ${tab.id}`
297
297
  );
@@ -4,11 +4,12 @@ import { MessageSquare } from "lucide-react";
4
4
  import { useKeybindings } from "@/hooks/useKeybindings";
5
5
  import { usePaletteCommands } from "@/hooks/usePaletteCommands";
6
6
  import { chordHint } from "@/lib/keybindings/chord";
7
+ import { keysOf } from "@/lib/keybindings/keymap";
7
8
  import { fieldOwnsKey } from "@/lib/keybindings/focus";
8
9
  import { neighbourSession } from "@/lib/sidebar/visible-order";
9
10
 
10
- const PREVIOUS = "mod+up";
11
- const NEXT = "mod+down";
11
+ const PREVIOUS = keysOf("session.previous");
12
+ const NEXT = keysOf("session.next");
12
13
 
13
14
  // ⌘↑ / ⌘↓ (Ctrl off a Mac) select the row above or below the active one, as
14
15
  // a click would, in the order the sidebar shows. Nothing happens at either end.
@@ -0,0 +1,19 @@
1
+ "use client";
2
+
3
+ import { useSnapshot } from "valtio";
4
+ import { shortcutsUi } from "@/stores/shortcutsUi";
5
+
6
+ /** The first key of a sequence, shown until the second comes or time runs out. */
7
+ export function PendingKeys() {
8
+ const { pending } = useSnapshot(shortcutsUi);
9
+ if (!pending) return null;
10
+ return (
11
+ <div
12
+ role="status"
13
+ className="bg-popover text-popover-foreground border-foreground/10 pointer-events-none fixed bottom-4 left-1/2 z-50 -translate-x-1/2 rounded-md border px-3 py-1.5 text-sm shadow-md"
14
+ >
15
+ <kbd className="font-mono font-medium uppercase">{pending}</kbd>
16
+ <span className="text-muted-foreground ml-2">then…</span>
17
+ </div>
18
+ );
19
+ }
@@ -0,0 +1,22 @@
1
+ "use client";
2
+
3
+ import { Keyboard } from "lucide-react";
4
+ import { Button } from "@/components/ui/button";
5
+ import { shortcutsUiActions } from "@/stores/shortcutsUi";
6
+
7
+ /** A footer icon that opens the shortcuts panel; on a phone it is the only way in. */
8
+ export function ShortcutsButton() {
9
+ return (
10
+ <Button
11
+ type="button"
12
+ variant="ghost"
13
+ size="icon"
14
+ title="Keyboard shortcuts (?)"
15
+ aria-label="Keyboard shortcuts"
16
+ onClick={() => shortcutsUiActions.open()}
17
+ className="text-muted-foreground relative h-8 w-8 after:absolute after:-inset-1.5 after:content-[''] md:after:hidden [@media(hover:none)]:md:h-11 [@media(hover:none)]:md:w-11"
18
+ >
19
+ <Keyboard className="h-4 w-4" />
20
+ </Button>
21
+ );
22
+ }
@@ -0,0 +1,122 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { useSnapshot } from "valtio";
5
+ import { Search } from "lucide-react";
6
+ import {
7
+ Dialog,
8
+ DialogContent,
9
+ DialogDescription,
10
+ DialogTitle,
11
+ } from "@/components/ui/dialog";
12
+ import { Switch } from "@/components/ui/switch";
13
+ import { useSingleKeys } from "@/hooks/useNavKeys";
14
+ import { chordHint, isMac } from "@/lib/keybindings/chord";
15
+ import {
16
+ isSequence,
17
+ searchKeymap,
18
+ type KeyEntry,
19
+ type KeyGroup,
20
+ } from "@/lib/keybindings/keymap";
21
+ import { shortcutsUi, shortcutsUiActions } from "@/stores/shortcutsUi";
22
+
23
+ const GROUPS: KeyGroup[] = ["Go to", "Chats", "General", "Browser"];
24
+
25
+ // The browser's own keys for back and forward, per platform.
26
+ function hint(k: KeyEntry): string[] {
27
+ if (isSequence(k.keys)) return k.keys.split(" ").map((x) => x.toUpperCase());
28
+ if (k.browser) {
29
+ if (!isMac()) return [k.id === "history.back" ? "Alt+←" : "Alt+→"];
30
+ const arrow = k.keys.replace("[", "left").replace("]", "right");
31
+ return [chordHint(k.keys, true), chordHint(arrow, true)];
32
+ }
33
+ return [chordHint(k.keys)];
34
+ }
35
+
36
+ function Row({ k }: { k: KeyEntry }) {
37
+ const parts = hint(k);
38
+ return (
39
+ <li className="flex min-h-11 items-center justify-between gap-3 py-1 md:min-h-8">
40
+ <span className="min-w-0 text-sm">{k.label}</span>
41
+ <span className="flex shrink-0 items-center gap-1">
42
+ {isSequence(k.keys) ? (
43
+ parts.map((p, i) => <Kbd key={i}>{p}</Kbd>)
44
+ ) : (
45
+ <Kbd>{parts.join(" · ")}</Kbd>
46
+ )}
47
+ </span>
48
+ </li>
49
+ );
50
+ }
51
+
52
+ const Kbd = ({ children }: { children: React.ReactNode }) => (
53
+ <kbd className="bg-muted text-muted-foreground min-w-6 rounded border px-1.5 py-0.5 text-center font-mono text-xs">
54
+ {children}
55
+ </kbd>
56
+ );
57
+
58
+ /** Every shortcut, searchable, with the switch that turns the bare-key ones off. */
59
+ export function ShortcutsDialog() {
60
+ const { open } = useSnapshot(shortcutsUi);
61
+ const [query, setQuery] = useState("");
62
+ const [singles, setSingles] = useSingleKeys();
63
+ const found = searchKeymap(query);
64
+ return (
65
+ <Dialog
66
+ open={open}
67
+ onOpenChange={(o) => {
68
+ shortcutsUiActions.setOpen(o);
69
+ if (!o) setQuery("");
70
+ }}
71
+ >
72
+ <DialogContent className="flex max-h-[min(80dvh,640px)] flex-col gap-3 sm:max-w-lg">
73
+ <DialogTitle>Keyboard shortcuts</DialogTitle>
74
+ <DialogDescription className="sr-only">
75
+ Search the shortcuts and turn the single-key ones off
76
+ </DialogDescription>
77
+ <label className="border-foreground/10 flex items-center gap-2 rounded-md border px-3">
78
+ <Search className="text-muted-foreground h-4 w-4 shrink-0" />
79
+ <input
80
+ autoFocus
81
+ value={query}
82
+ onChange={(e) => setQuery(e.target.value)}
83
+ placeholder="Search shortcuts…"
84
+ aria-label="Search shortcuts"
85
+ className="placeholder:text-muted-foreground h-11 min-w-0 flex-1 bg-transparent text-base outline-none sm:text-sm"
86
+ />
87
+ </label>
88
+ <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
89
+ {found.length === 0 && (
90
+ <p className="text-muted-foreground py-6 text-center text-sm">
91
+ No shortcut matches.
92
+ </p>
93
+ )}
94
+ {GROUPS.map((g) => {
95
+ const rows = found.filter((k) => k.group === g);
96
+ if (!rows.length) return null;
97
+ return (
98
+ <section key={g} className="mb-3">
99
+ <h3 className="label-mono text-muted-foreground mb-1">{g}</h3>
100
+ <ul>
101
+ {rows.map((k) => (
102
+ <Row key={k.id} k={k} />
103
+ ))}
104
+ </ul>
105
+ </section>
106
+ );
107
+ })}
108
+ </div>
109
+ <label className="border-foreground/10 flex min-h-11 items-center justify-between gap-3 border-t pt-3 text-sm">
110
+ <span>
111
+ Single-key shortcuts
112
+ <span className="text-muted-foreground block text-xs">
113
+ Letters like C and G I, only when you are not typing. This browser
114
+ only.
115
+ </span>
116
+ </span>
117
+ <Switch checked={singles} onCheckedChange={setSingles} />
118
+ </label>
119
+ </DialogContent>
120
+ </Dialog>
121
+ );
122
+ }
@@ -1,6 +1,7 @@
1
1
  import { TipButton } from "@/components/Tips";
2
2
  import { WhatsNewButton } from "@/components/WhatsNew";
3
3
  import { ThemeToggle } from "@/components/ThemeToggle";
4
+ import { ShortcutsButton } from "@/components/Shortcuts/ShortcutsButton";
4
5
  import {
5
6
  Tooltip,
6
7
  TooltipContent,
@@ -13,6 +14,7 @@ export function SidebarFooter() {
13
14
  <div className="flex items-center justify-between">
14
15
  <span className="label-mono text-muted-foreground">Theme</span>
15
16
  <div className="flex items-center">
17
+ <ShortcutsButton />
16
18
  <TipButton />
17
19
  <WhatsNewButton />
18
20
  <ThemeToggle />
@@ -137,7 +137,8 @@ export function useTerminalConnection({
137
137
 
138
138
  // Main setup effect
139
139
  useEffect(() => {
140
- if (!terminalRef.current) return;
140
+ const container = terminalRef.current;
141
+ if (!container) return;
141
142
 
142
143
  let cancelled = false;
143
144
  // Reset intentional close flag (may be true from previous cleanup)
@@ -220,9 +221,9 @@ export function useTerminalConnection({
220
221
 
221
222
  // Save scroll state before unmount
222
223
  const term = xtermRef.current;
223
- if (term && callbacksRef.current.onBeforeUnmount && terminalRef.current) {
224
+ if (term && callbacksRef.current.onBeforeUnmount) {
224
225
  const buffer = term.buffer.active;
225
- const viewport = terminalRef.current.querySelector(
226
+ const viewport = container.querySelector(
226
227
  ".xterm-viewport"
227
228
  ) as HTMLElement;
228
229
  callbacksRef.current.onBeforeUnmount({
@@ -61,7 +61,7 @@ export function TipDialog({
61
61
  <DialogContent
62
62
  showCloseButton={false}
63
63
  placement={wide ? "center" : "sheet"}
64
- className="flex max-h-[85dvh] flex-col gap-0 overflow-hidden p-0 sm:max-w-[26rem]"
64
+ className="flex max-h-[85dvh] flex-col gap-0 overflow-hidden p-0 sm:max-w-[26rem] md:max-w-[36rem]"
65
65
  >
66
66
  <div className="bg-muted-foreground/40 mx-auto mt-2 h-1 w-9 rounded-full md:hidden" />
67
67
  <div className="flex items-center gap-2 px-4 pt-3 pb-1">
@@ -1,9 +1,11 @@
1
1
  "use client";
2
2
 
3
- import { Play } from "lucide-react";
3
+ import { Maximize2, Play } from "lucide-react";
4
4
  import { useTheme } from "next-themes";
5
5
  import { useRef, useState } from "react";
6
6
  import { useMediaQuery } from "@/hooks/useMediaQuery";
7
+ import { MediaViewer } from "@/components/Media/MediaViewer";
8
+ import { pickSrc } from "@/components/Media/viewer";
7
9
  import type { Media } from "@/lib/tips/schema";
8
10
 
9
11
  /** A tip's still or clip, sized by its own dimensions so nothing jumps as it loads. */
@@ -11,18 +13,49 @@ export function TipMedia({ media }: { media: Media }) {
11
13
  const { resolvedTheme } = useTheme();
12
14
  const reduced = useMediaQuery("(prefers-reduced-motion: reduce)");
13
15
  const [played, setPlayed] = useState(false);
16
+ const [expanded, setExpanded] = useState(false);
14
17
  const video = useRef<HTMLVideoElement>(null);
15
18
  const dark = !!resolvedTheme && !resolvedTheme.startsWith("light");
16
19
  const frame =
17
20
  "bg-muted relative max-h-[40dvh] w-full overflow-hidden rounded-lg";
18
21
  const style = { aspectRatio: `${media.width} / ${media.height}` };
22
+ const src = pickSrc(media, dark);
23
+ const expand = (
24
+ <>
25
+ <button
26
+ type="button"
27
+ aria-label={`Expand ${media.kind === "image" ? "image" : "clip"}: ${media.alt}`}
28
+ onClick={() => setExpanded(true)}
29
+ className="focus-visible:ring-ring absolute inset-0 cursor-zoom-in focus-visible:ring-2 focus-visible:outline-none"
30
+ >
31
+ <span
32
+ aria-hidden
33
+ className="absolute top-2 right-2 grid size-7 place-items-center rounded-md bg-black/60 text-white"
34
+ >
35
+ <Maximize2 className="size-3.5" />
36
+ </span>
37
+ </button>
38
+ <MediaViewer
39
+ media={{
40
+ kind: media.kind,
41
+ src,
42
+ alt: media.alt,
43
+ width: media.width,
44
+ height: media.height,
45
+ poster: media.kind === "video" ? media.poster : undefined,
46
+ }}
47
+ open={expanded}
48
+ onOpenChange={setExpanded}
49
+ />
50
+ </>
51
+ );
19
52
 
20
53
  if (media.kind === "image")
21
54
  return (
22
55
  <div className={frame} style={style}>
23
56
  {/* eslint-disable-next-line @next/next/no-img-element */}
24
57
  <img
25
- src={dark && media.srcDark ? media.srcDark : media.src}
58
+ src={src}
26
59
  alt={media.alt}
27
60
  width={media.width}
28
61
  height={media.height}
@@ -30,6 +63,7 @@ export function TipMedia({ media }: { media: Media }) {
30
63
  decoding="async"
31
64
  className="size-full object-contain"
32
65
  />
66
+ {expand}
33
67
  </div>
34
68
  );
35
69
 
@@ -51,6 +85,7 @@ export function TipMedia({ media }: { media: Media }) {
51
85
  preload={reduced ? "none" : "metadata"}
52
86
  className="size-full object-contain"
53
87
  />
88
+ {expand}
54
89
  {still && (
55
90
  <button
56
91
  type="button"
@@ -59,7 +94,7 @@ export function TipMedia({ media }: { media: Media }) {
59
94
  setPlayed(true);
60
95
  void video.current?.play();
61
96
  }}
62
- className="absolute inset-0 grid place-items-center bg-black/20"
97
+ className="absolute top-1/2 left-1/2 z-10 -translate-x-1/2 -translate-y-1/2"
63
98
  >
64
99
  <span className="bg-background/90 grid size-11 place-items-center rounded-full shadow">
65
100
  <Play className="size-5" />