@saadnvd1/agent-os 0.3.794 → 0.3.807
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.
- package/app/api/busy/route.ts +2 -2
- package/app/api/chat-views/[sessionId]/route.ts +19 -0
- package/app/api/chat-views/route.test.ts +79 -0
- package/app/api/chat-views/route.ts +8 -0
- package/app/api/tips/route.test.ts +56 -0
- package/app/api/tips/route.ts +45 -0
- package/app/api/updates/health/route.ts +22 -0
- package/app/api/updates/route.test.ts +53 -0
- package/app/api/updates/settings/route.ts +37 -10
- package/app/page.tsx +17 -14
- package/components/Chat/ChatPanel.tsx +74 -59
- package/components/Chat/NewDivider.tsx +15 -0
- package/components/Chat/needs-more-history.test.ts +28 -0
- package/components/Chat/needs-more-history.ts +14 -0
- package/components/Chat/session-link.ts +7 -4
- package/components/Chat/useFind.ts +50 -1
- package/components/Chat/usePosition.ts +363 -0
- package/components/CodeSearchDialog.tsx +90 -0
- package/components/CommandPalette/CommandsBody.tsx +12 -4
- package/components/CommandPalette/useAppCommands.ts +29 -11
- package/components/DevServers/ServerLogsModal.tsx +24 -21
- package/components/Nav/HistoryButtons.tsx +128 -0
- package/components/Pane/MobileTabBar.tsx +3 -0
- package/components/Pane/index.tsx +2 -2
- package/components/Recipes/TimerFields.tsx +2 -1
- package/components/Schedules/ScheduleForm.tsx +1 -1
- package/components/SessionList/useSessionSwitching.ts +3 -2
- package/components/Settings/SettingsDialog.tsx +1 -1
- package/components/Shortcuts/PendingKeys.tsx +19 -0
- package/components/Shortcuts/ShortcutsButton.tsx +22 -0
- package/components/Shortcuts/ShortcutsDialog.tsx +122 -0
- package/components/SidebarFooter.tsx +4 -0
- package/components/Terminal/hooks/useTerminalConnection.ts +4 -3
- package/components/Tips/TipButton.tsx +39 -0
- package/components/Tips/TipDialog.tsx +148 -0
- package/components/Tips/TipHost.tsx +14 -0
- package/components/Tips/TipMedia.tsx +71 -0
- package/components/Tips/index.ts +2 -0
- package/components/Updates/AutoUpdates.tsx +141 -0
- package/components/Updates/UpdateBanner.test.ts +39 -1
- package/components/Updates/UpdateBanner.tsx +31 -5
- package/components/Updates/UpdatesSection.tsx +24 -3
- package/components/views/DesktopView.tsx +7 -9
- package/components/views/MobileView.tsx +2 -8
- package/components/views/lazy.ts +2 -2
- package/content/tips.json +541 -0
- package/content/whats-new.json +56 -0
- package/data/chat-views/index.ts +2 -0
- package/data/chat-views/keys.ts +3 -0
- package/data/chat-views/queries.ts +34 -0
- package/data/sessions/pin.ts +0 -1
- package/data/tips/index.ts +49 -0
- package/data/updates/index.ts +5 -0
- package/hooks/useChatViewsSync.ts +12 -0
- package/hooks/useNavActions.ts +104 -0
- package/hooks/useNavKeys.ts +105 -0
- package/hooks/useReloadWhenUpdated.ts +5 -1
- package/hooks/useSessionUrl.ts +23 -5
- package/lib/bus/index.ts +4 -2
- package/lib/bus/role-alias.ts +4 -2
- package/lib/bus/targets.ts +2 -1
- package/lib/busy.ts +23 -1
- package/lib/chat/escape.ts +8 -4
- package/lib/chat-views/body.ts +41 -0
- package/lib/chat-views/store.ts +67 -0
- package/lib/chat-views/types.ts +20 -0
- package/lib/db/migrations/20261031T1500_chat_views.ts +30 -0
- package/lib/db/migrations.ts +21 -0
- package/lib/keybindings/focus.ts +17 -0
- package/lib/keybindings/keymap.ts +120 -0
- package/lib/keybindings/sequence.ts +64 -0
- package/lib/live/service.ts +1 -1
- package/lib/nav/history-mirror.ts +59 -0
- package/lib/nav/history-writer.ts +109 -0
- package/lib/nav/position.ts +67 -0
- package/lib/nav/recent.ts +34 -0
- package/lib/orchestrator/armed.ts +5 -0
- package/lib/palette/sections.ts +12 -6
- package/lib/recipes/timer.ts +3 -1
- package/lib/schedules/cron.ts +14 -5
- package/lib/schedules/store.ts +3 -2
- package/lib/security/demo.ts +2 -0
- package/lib/session-url.ts +11 -5
- package/lib/tasks/finish.ts +5 -5
- package/lib/tips/conditions.ts +38 -0
- package/lib/tips/index.ts +2 -0
- package/lib/tips/load.ts +26 -0
- package/lib/tips/media.ts +101 -0
- package/lib/tips/pick.ts +35 -0
- package/lib/tips/schema.ts +82 -0
- package/lib/tips/state.ts +26 -0
- package/lib/tips/view.ts +60 -0
- package/lib/updates/auto.ts +149 -0
- package/lib/updates/banner.ts +78 -3
- package/lib/updates/check.ts +64 -8
- package/lib/updates/history.ts +99 -0
- package/lib/updates/run.ts +334 -23
- package/lib/updates/settings.ts +66 -0
- package/lib/updates/view.ts +14 -3
- package/package.json +2 -2
- package/public/tips/.gitkeep +0 -0
- package/public/tips/chat-access-picker-dark.webp +0 -0
- package/public/tips/chat-access-picker.webp +0 -0
- package/public/tips/chat-attach-files-poster.webp +0 -0
- package/public/tips/chat-attach-files.mp4 +0 -0
- package/public/tips/chat-bookmark-dark.webp +0 -0
- package/public/tips/chat-bookmark.webp +0 -0
- package/public/tips/chat-plan-mode-dark.webp +0 -0
- package/public/tips/chat-plan-mode.webp +0 -0
- package/public/tips/chat-search-poster.webp +0 -0
- package/public/tips/chat-search.mp4 +0 -0
- package/public/tips/chat-slash-menu-poster.webp +0 -0
- package/public/tips/chat-slash-menu.mp4 +0 -0
- package/public/tips/chat-tab-suggestion-poster.webp +0 -0
- package/public/tips/chat-tab-suggestion.mp4 +0 -0
- package/public/tips/chat-terminal-handoff-poster.webp +0 -0
- package/public/tips/chat-terminal-handoff.mp4 +0 -0
- package/public/tips/chat-undo-dark.webp +0 -0
- package/public/tips/chat-undo.webp +0 -0
- package/public/tips/export-ai-dark.webp +0 -0
- package/public/tips/export-ai.webp +0 -0
- package/public/tips/net-messages-dark.webp +0 -0
- package/public/tips/net-messages.webp +0 -0
- package/public/tips/recipe-save-dark.webp +0 -0
- package/public/tips/recipe-save.webp +0 -0
- package/public/tips/schedule-first-dark.webp +0 -0
- package/public/tips/schedule-first.webp +0 -0
- package/public/tips/share-check-dark.webp +0 -0
- package/public/tips/share-check.webp +0 -0
- package/public/tips/sidebar-draft-poster.webp +0 -0
- package/public/tips/sidebar-draft.mp4 +0 -0
- package/public/tips/sidebar-groups-dark.webp +0 -0
- package/public/tips/sidebar-groups.webp +0 -0
- package/public/tips/sidebar-load-dark.webp +0 -0
- package/public/tips/sidebar-load.webp +0 -0
- package/public/tips/sidebar-snooze-dark.webp +0 -0
- package/public/tips/sidebar-snooze.webp +0 -0
- package/public/tips/tasks-plan-pieces-dark.webp +0 -0
- package/public/tips/tasks-plan-pieces.webp +0 -0
- package/public/tips/tasks-sign-off-dark.webp +0 -0
- package/public/tips/tasks-sign-off.webp +0 -0
- package/public/tips/tasks-stacks-dark.webp +0 -0
- package/public/tips/tasks-stacks.webp +0 -0
- package/scripts/autodeploy +4 -0
- package/scripts/autodeploy.test.ts +12 -0
- package/scripts/check +2 -2
- package/scripts/docs-links.test.ts +1 -25
- package/scripts/lib/commands.sh +32 -1
- package/scripts/lib/deps-swap.sh +8 -6
- package/scripts/lib/heading-slugs.ts +26 -0
- package/scripts/lib/update-verify.mjs +90 -0
- package/scripts/redeploy +31 -2
- package/scripts/redeploy.test.ts +94 -3
- package/scripts/tips-lint.test.ts +77 -0
- package/scripts/tips-media.ts +31 -0
- package/scripts/update-e2e.test.ts +215 -0
- package/scripts/update-recover.mjs +130 -0
- package/scripts/update-recover.test.ts +144 -0
- package/scripts/update-run.mjs +182 -40
- package/scripts/update-run.test.ts +208 -2
- package/server.ts +3 -2
- package/stores/chatJump.ts +2 -6
- package/stores/chatPositions.ts +65 -0
- package/stores/navHistory.ts +98 -0
- package/stores/shortcutsUi.ts +20 -0
- package/stores/tips.ts +14 -0
- package/components/QuickSwitcher.tsx +0 -286
|
@@ -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:
|
|
294
|
+
(tab: TabData) => () => {
|
|
295
295
|
console.log(
|
|
296
296
|
`[AgentOS] Terminal connected for pane: ${paneId}, tab: ${tab.id}`
|
|
297
297
|
);
|
|
@@ -77,7 +77,8 @@ export function timerProblem(v: TimerValue): string | null {
|
|
|
77
77
|
if (!isTimezone(v.timezone)) return "Unknown time zone";
|
|
78
78
|
const bad = cronError(cron);
|
|
79
79
|
if (bad) return bad;
|
|
80
|
-
return minGapMinutes(cron, v.timezone) <
|
|
80
|
+
return minGapMinutes(cron, v.timezone, Date.now(), MIN_GAP_MINUTES) <
|
|
81
|
+
MIN_GAP_MINUTES
|
|
81
82
|
? "A recipe runs at most once an hour"
|
|
82
83
|
: null;
|
|
83
84
|
}
|
|
@@ -303,7 +303,7 @@ export function ScheduleForm({
|
|
|
303
303
|
!once &&
|
|
304
304
|
!cronProblem &&
|
|
305
305
|
tzOk &&
|
|
306
|
-
minGapMinutes(cron, timezone, openedAt) < minGap,
|
|
306
|
+
minGapMinutes(cron, timezone, openedAt, minGap) < minGap,
|
|
307
307
|
[minGap, once, cron, cronProblem, timezone, tzOk, openedAt]
|
|
308
308
|
);
|
|
309
309
|
|
|
@@ -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 = "
|
|
11
|
-
const NEXT = "
|
|
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,5 +1,7 @@
|
|
|
1
|
+
import { TipButton } from "@/components/Tips";
|
|
1
2
|
import { WhatsNewButton } from "@/components/WhatsNew";
|
|
2
3
|
import { ThemeToggle } from "@/components/ThemeToggle";
|
|
4
|
+
import { ShortcutsButton } from "@/components/Shortcuts/ShortcutsButton";
|
|
3
5
|
import {
|
|
4
6
|
Tooltip,
|
|
5
7
|
TooltipContent,
|
|
@@ -12,6 +14,8 @@ export function SidebarFooter() {
|
|
|
12
14
|
<div className="flex items-center justify-between">
|
|
13
15
|
<span className="label-mono text-muted-foreground">Theme</span>
|
|
14
16
|
<div className="flex items-center">
|
|
17
|
+
<ShortcutsButton />
|
|
18
|
+
<TipButton />
|
|
15
19
|
<WhatsNewButton />
|
|
16
20
|
<ThemeToggle />
|
|
17
21
|
</div>
|
|
@@ -137,7 +137,8 @@ export function useTerminalConnection({
|
|
|
137
137
|
|
|
138
138
|
// Main setup effect
|
|
139
139
|
useEffect(() => {
|
|
140
|
-
|
|
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
|
|
224
|
+
if (term && callbacksRef.current.onBeforeUnmount) {
|
|
224
225
|
const buffer = term.buffer.active;
|
|
225
|
-
const viewport =
|
|
226
|
+
const viewport = container.querySelector(
|
|
226
227
|
".xterm-viewport"
|
|
227
228
|
) as HTMLElement;
|
|
228
229
|
callbacksRef.current.onBeforeUnmount({
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Lightbulb } from "lucide-react";
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
import { useTips } from "@/data/tips";
|
|
6
|
+
import { cn } from "@/lib/utils";
|
|
7
|
+
import { tipsActions } from "@/stores/tips";
|
|
8
|
+
|
|
9
|
+
/** A small footer icon that opens the tip dialog; a dot marks today's unread tip. */
|
|
10
|
+
export function TipButton() {
|
|
11
|
+
const { data } = useTips();
|
|
12
|
+
if (data && (data.off || !data.deck.length)) return null;
|
|
13
|
+
// Holds the icon's place while the first answer loads, so What's New doesn't jump.
|
|
14
|
+
const fresh = !!data && !data.quiet && data.today?.read === false;
|
|
15
|
+
return (
|
|
16
|
+
<Button
|
|
17
|
+
type="button"
|
|
18
|
+
variant="ghost"
|
|
19
|
+
size="icon"
|
|
20
|
+
title="Tip of the Day"
|
|
21
|
+
aria-label={fresh ? "Tip of the Day (new tip)" : "Tip of the Day"}
|
|
22
|
+
disabled={!data}
|
|
23
|
+
onClick={() => tipsActions.open()}
|
|
24
|
+
// The ::after widens the tap area to 44px on a phone without adding height.
|
|
25
|
+
className={cn(
|
|
26
|
+
"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",
|
|
27
|
+
!data && "invisible"
|
|
28
|
+
)}
|
|
29
|
+
>
|
|
30
|
+
<Lightbulb className="h-4 w-4" />
|
|
31
|
+
{fresh && (
|
|
32
|
+
<span
|
|
33
|
+
data-testid="tip-dot"
|
|
34
|
+
className="bg-primary absolute top-1.5 right-1.5 size-1.5 rounded-full"
|
|
35
|
+
/>
|
|
36
|
+
)}
|
|
37
|
+
</Button>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { ChevronLeft, ChevronRight, Lightbulb, X } from "lucide-react";
|
|
5
|
+
import { toast } from "sonner";
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
import {
|
|
8
|
+
Dialog,
|
|
9
|
+
DialogClose,
|
|
10
|
+
DialogContent,
|
|
11
|
+
DialogDescription,
|
|
12
|
+
DialogTitle,
|
|
13
|
+
} from "@/components/ui/dialog";
|
|
14
|
+
import { useMarkTipSeen, useSetTipsOff, useTips } from "@/data/tips";
|
|
15
|
+
import { useMediaQuery } from "@/hooks/useMediaQuery";
|
|
16
|
+
import { TipMedia } from "./TipMedia";
|
|
17
|
+
|
|
18
|
+
export function TipDialog({
|
|
19
|
+
open,
|
|
20
|
+
onOpenChange,
|
|
21
|
+
}: {
|
|
22
|
+
open: boolean;
|
|
23
|
+
onOpenChange: (open: boolean) => void;
|
|
24
|
+
}) {
|
|
25
|
+
const wide = useMediaQuery("(min-width: 768px)");
|
|
26
|
+
const { data } = useTips();
|
|
27
|
+
const markSeen = useMarkTipSeen();
|
|
28
|
+
const setOff = useSetTipsOff();
|
|
29
|
+
const deck = data?.deck ?? [];
|
|
30
|
+
const [index, setIndex] = useState(() =>
|
|
31
|
+
Math.max(
|
|
32
|
+
0,
|
|
33
|
+
deck.findIndex((t) => t.id === data?.today?.id)
|
|
34
|
+
)
|
|
35
|
+
);
|
|
36
|
+
const tip = deck[index];
|
|
37
|
+
|
|
38
|
+
// Whatever tip is on screen counts as seen; today's one clears the dot.
|
|
39
|
+
const tipId = tip?.id;
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (tipId) markSeen.mutate(tipId);
|
|
42
|
+
// markSeen is a new object each render; the tip is the trigger.
|
|
43
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
44
|
+
}, [tipId]);
|
|
45
|
+
|
|
46
|
+
const step = (by: number) =>
|
|
47
|
+
setIndex((i) => (i + by + deck.length) % deck.length);
|
|
48
|
+
|
|
49
|
+
const turnOff = () => {
|
|
50
|
+
setOff.mutate(true, {
|
|
51
|
+
onSuccess: () =>
|
|
52
|
+
toast("Tips are off. Turn them back on in Settings → Updates.", {
|
|
53
|
+
action: { label: "Undo", onClick: () => setOff.mutate(false) },
|
|
54
|
+
}),
|
|
55
|
+
});
|
|
56
|
+
onOpenChange(false);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
61
|
+
<DialogContent
|
|
62
|
+
showCloseButton={false}
|
|
63
|
+
placement={wide ? "center" : "sheet"}
|
|
64
|
+
className="flex max-h-[85dvh] flex-col gap-0 overflow-hidden p-0 sm:max-w-[26rem]"
|
|
65
|
+
>
|
|
66
|
+
<div className="bg-muted-foreground/40 mx-auto mt-2 h-1 w-9 rounded-full md:hidden" />
|
|
67
|
+
<div className="flex items-center gap-2 px-4 pt-3 pb-1">
|
|
68
|
+
<span className="bg-muted grid size-7 shrink-0 place-items-center rounded-lg">
|
|
69
|
+
<Lightbulb className="size-4" />
|
|
70
|
+
</span>
|
|
71
|
+
<DialogDescription className="label-mono text-muted-foreground min-w-0 flex-1 text-xs">
|
|
72
|
+
Tip of the Day
|
|
73
|
+
{deck.length > 1 && ` · ${index + 1} of ${deck.length}`}
|
|
74
|
+
</DialogDescription>
|
|
75
|
+
<DialogClose asChild>
|
|
76
|
+
<Button
|
|
77
|
+
variant="ghost"
|
|
78
|
+
size="icon-sm"
|
|
79
|
+
aria-label="Close"
|
|
80
|
+
className="-mr-2 size-11 shrink-0 md:size-8"
|
|
81
|
+
>
|
|
82
|
+
<X className="size-4" />
|
|
83
|
+
</Button>
|
|
84
|
+
</DialogClose>
|
|
85
|
+
</div>
|
|
86
|
+
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-4 pt-2 pb-4">
|
|
87
|
+
{tip ? (
|
|
88
|
+
<>
|
|
89
|
+
{tip.media && <TipMedia key={tip.id} media={tip.media} />}
|
|
90
|
+
<DialogTitle className="text-base font-semibold">
|
|
91
|
+
{tip.title}
|
|
92
|
+
</DialogTitle>
|
|
93
|
+
<p className="text-muted-foreground text-sm leading-relaxed">
|
|
94
|
+
{tip.body}
|
|
95
|
+
</p>
|
|
96
|
+
{tip.url && (
|
|
97
|
+
<a
|
|
98
|
+
href={tip.url}
|
|
99
|
+
target="_blank"
|
|
100
|
+
rel="noopener noreferrer"
|
|
101
|
+
className="text-primary w-fit text-sm underline"
|
|
102
|
+
>
|
|
103
|
+
Learn more
|
|
104
|
+
</a>
|
|
105
|
+
)}
|
|
106
|
+
</>
|
|
107
|
+
) : (
|
|
108
|
+
<DialogTitle className="text-sm font-medium">
|
|
109
|
+
No tips right now.
|
|
110
|
+
</DialogTitle>
|
|
111
|
+
)}
|
|
112
|
+
</div>
|
|
113
|
+
<div className="bg-muted/40 flex items-center gap-2 px-4 py-2">
|
|
114
|
+
<Button
|
|
115
|
+
variant="ghost"
|
|
116
|
+
size="sm"
|
|
117
|
+
className="text-muted-foreground h-11 px-2 md:h-8"
|
|
118
|
+
onClick={turnOff}
|
|
119
|
+
>
|
|
120
|
+
Don't show tips
|
|
121
|
+
</Button>
|
|
122
|
+
<div className="ml-auto flex items-center gap-1">
|
|
123
|
+
<Button
|
|
124
|
+
variant="ghost"
|
|
125
|
+
size="icon-sm"
|
|
126
|
+
aria-label="Previous tip"
|
|
127
|
+
disabled={deck.length < 2}
|
|
128
|
+
onClick={() => step(-1)}
|
|
129
|
+
className="size-11 md:size-8"
|
|
130
|
+
>
|
|
131
|
+
<ChevronLeft className="size-4" />
|
|
132
|
+
</Button>
|
|
133
|
+
<Button
|
|
134
|
+
variant="ghost"
|
|
135
|
+
size="icon-sm"
|
|
136
|
+
aria-label="Next tip"
|
|
137
|
+
disabled={deck.length < 2}
|
|
138
|
+
onClick={() => step(1)}
|
|
139
|
+
className="size-11 md:size-8"
|
|
140
|
+
>
|
|
141
|
+
<ChevronRight className="size-4" />
|
|
142
|
+
</Button>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
</DialogContent>
|
|
146
|
+
</Dialog>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useSnapshot } from "valtio";
|
|
4
|
+
import { tipsActions, tipsStore } from "@/stores/tips";
|
|
5
|
+
import { TipDialog } from "./TipDialog";
|
|
6
|
+
|
|
7
|
+
/** Mounted once, near the top of the app: the footer icon opens it. */
|
|
8
|
+
export function TipHost() {
|
|
9
|
+
const { open } = useSnapshot(tipsStore);
|
|
10
|
+
if (!open) return null;
|
|
11
|
+
return (
|
|
12
|
+
<TipDialog open onOpenChange={(next) => !next && tipsActions.close()} />
|
|
13
|
+
);
|
|
14
|
+
}
|