@saadnvd1/agent-os 0.3.799 → 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.
Files changed (101) 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/Nav/HistoryButtons.tsx +128 -0
  15. package/components/Pane/MobileTabBar.tsx +3 -0
  16. package/components/Pane/index.tsx +2 -2
  17. package/components/SessionList/useSessionSwitching.ts +3 -2
  18. package/components/Shortcuts/PendingKeys.tsx +19 -0
  19. package/components/Shortcuts/ShortcutsButton.tsx +22 -0
  20. package/components/Shortcuts/ShortcutsDialog.tsx +122 -0
  21. package/components/SidebarFooter.tsx +2 -0
  22. package/components/Terminal/hooks/useTerminalConnection.ts +4 -3
  23. package/components/views/DesktopView.tsx +7 -9
  24. package/components/views/MobileView.tsx +2 -8
  25. package/components/views/lazy.ts +2 -2
  26. package/content/tips.json +146 -61
  27. package/content/whats-new.json +32 -0
  28. package/data/chat-views/index.ts +2 -0
  29. package/data/chat-views/keys.ts +3 -0
  30. package/data/chat-views/queries.ts +34 -0
  31. package/data/sessions/pin.ts +0 -1
  32. package/hooks/useChatViewsSync.ts +12 -0
  33. package/hooks/useNavActions.ts +104 -0
  34. package/hooks/useNavKeys.ts +105 -0
  35. package/hooks/useSessionUrl.ts +23 -5
  36. package/lib/bus/index.ts +4 -2
  37. package/lib/bus/role-alias.ts +4 -2
  38. package/lib/bus/targets.ts +2 -1
  39. package/lib/chat/escape.ts +8 -4
  40. package/lib/chat-views/body.ts +41 -0
  41. package/lib/chat-views/store.ts +67 -0
  42. package/lib/chat-views/types.ts +20 -0
  43. package/lib/db/migrations/20261031T1500_chat_views.ts +30 -0
  44. package/lib/keybindings/focus.ts +17 -0
  45. package/lib/keybindings/keymap.ts +120 -0
  46. package/lib/keybindings/sequence.ts +64 -0
  47. package/lib/live/service.ts +1 -1
  48. package/lib/nav/history-mirror.ts +59 -0
  49. package/lib/nav/history-writer.ts +109 -0
  50. package/lib/nav/position.ts +67 -0
  51. package/lib/nav/recent.ts +34 -0
  52. package/lib/orchestrator/armed.ts +5 -0
  53. package/lib/palette/sections.ts +12 -6
  54. package/lib/session-url.ts +11 -5
  55. package/package.json +1 -1
  56. package/public/tips/chat-access-picker-dark.webp +0 -0
  57. package/public/tips/chat-access-picker.webp +0 -0
  58. package/public/tips/chat-attach-files-poster.webp +0 -0
  59. package/public/tips/chat-attach-files.mp4 +0 -0
  60. package/public/tips/chat-bookmark-dark.webp +0 -0
  61. package/public/tips/chat-bookmark.webp +0 -0
  62. package/public/tips/chat-plan-mode-dark.webp +0 -0
  63. package/public/tips/chat-plan-mode.webp +0 -0
  64. package/public/tips/chat-search-poster.webp +0 -0
  65. package/public/tips/chat-search.mp4 +0 -0
  66. package/public/tips/chat-tab-suggestion-poster.webp +0 -0
  67. package/public/tips/chat-tab-suggestion.mp4 +0 -0
  68. package/public/tips/chat-terminal-handoff-poster.webp +0 -0
  69. package/public/tips/chat-terminal-handoff.mp4 +0 -0
  70. package/public/tips/chat-undo-dark.webp +0 -0
  71. package/public/tips/chat-undo.webp +0 -0
  72. package/public/tips/export-ai-dark.webp +0 -0
  73. package/public/tips/export-ai.webp +0 -0
  74. package/public/tips/net-messages-dark.webp +0 -0
  75. package/public/tips/net-messages.webp +0 -0
  76. package/public/tips/recipe-save-dark.webp +0 -0
  77. package/public/tips/recipe-save.webp +0 -0
  78. package/public/tips/schedule-first-dark.webp +0 -0
  79. package/public/tips/schedule-first.webp +0 -0
  80. package/public/tips/share-check-dark.webp +0 -0
  81. package/public/tips/share-check.webp +0 -0
  82. package/public/tips/sidebar-draft-poster.webp +0 -0
  83. package/public/tips/sidebar-draft.mp4 +0 -0
  84. package/public/tips/sidebar-groups-dark.webp +0 -0
  85. package/public/tips/sidebar-groups.webp +0 -0
  86. package/public/tips/sidebar-load-dark.webp +0 -0
  87. package/public/tips/sidebar-load.webp +0 -0
  88. package/public/tips/sidebar-snooze-dark.webp +0 -0
  89. package/public/tips/sidebar-snooze.webp +0 -0
  90. package/public/tips/tasks-plan-pieces-dark.webp +0 -0
  91. package/public/tips/tasks-plan-pieces.webp +0 -0
  92. package/public/tips/tasks-sign-off-dark.webp +0 -0
  93. package/public/tips/tasks-sign-off.webp +0 -0
  94. package/public/tips/tasks-stacks-dark.webp +0 -0
  95. package/public/tips/tasks-stacks.webp +0 -0
  96. package/scripts/check +2 -2
  97. package/stores/chatJump.ts +2 -6
  98. package/stores/chatPositions.ts +65 -0
  99. package/stores/navHistory.ts +98 -0
  100. package/stores/shortcutsUi.ts +20 -0
  101. package/components/QuickSwitcher.tsx +0 -286
package/content/tips.json CHANGED
@@ -18,9 +18,13 @@
18
18
  "file": "chat"
19
19
  },
20
20
  "added": "2026-10-31",
21
- "mediaBrief": {
22
- "kind": "crop",
23
- "shot": "Composer bottom bar with Plan switched on, the agent's reply a plan checklist above it."
21
+ "media": {
22
+ "kind": "image",
23
+ "src": "/tips/chat-plan-mode.webp",
24
+ "srcDark": "/tips/chat-plan-mode-dark.webp",
25
+ "alt": "The chat composer with Plan switched on, the agent's plan shown above it.",
26
+ "width": 768,
27
+ "height": 481
24
28
  }
25
29
  },
26
30
  {
@@ -31,9 +35,14 @@
31
35
  "file": "chat"
32
36
  },
33
37
  "added": "2026-10-31",
34
- "mediaBrief": {
35
- "kind": "clip",
36
- "shot": "A PDF dragged onto the composer; its chip appears; one more pasted; send."
38
+ "media": {
39
+ "kind": "video",
40
+ "src": "/tips/chat-attach-files.mp4",
41
+ "poster": "/tips/chat-attach-files-poster.webp",
42
+ "alt": "Two files, a PDF and a CSV, attached to the composer as chips before sending.",
43
+ "width": 768,
44
+ "height": 480,
45
+ "durationMs": 7300
37
46
  }
38
47
  },
39
48
  {
@@ -48,7 +57,7 @@
48
57
  "kind": "video",
49
58
  "src": "/tips/chat-slash-menu.mp4",
50
59
  "poster": "/tips/chat-slash-menu-poster.webp",
51
- "alt": "Typing a slash in the composer opens a menu of commands and skills that narrows as you type.",
60
+ "alt": "Typing a slash in the composer opens a filtered list of commands and skills.",
52
61
  "width": 768,
53
62
  "height": 480,
54
63
  "durationMs": 6367
@@ -62,9 +71,14 @@
62
71
  "file": "chat"
63
72
  },
64
73
  "added": "2026-10-31",
65
- "mediaBrief": {
66
- "kind": "clip",
67
- "shot": "Empty composer shows a grey suggested reply; Tab fills it in."
74
+ "media": {
75
+ "kind": "video",
76
+ "src": "/tips/chat-tab-suggestion.mp4",
77
+ "poster": "/tips/chat-tab-suggestion-poster.webp",
78
+ "alt": "A grey suggested next message appears in the composer and Tab accepts it.",
79
+ "width": 768,
80
+ "height": 480,
81
+ "durationMs": 5233
68
82
  }
69
83
  },
70
84
  {
@@ -75,9 +89,13 @@
75
89
  "file": "chat"
76
90
  },
77
91
  "added": "2026-10-31",
78
- "mediaBrief": {
79
- "kind": "crop",
80
- "shot": "The access picker open, showing Ask first, Accept edits, Full access."
92
+ "media": {
93
+ "kind": "image",
94
+ "src": "/tips/chat-access-picker.webp",
95
+ "srcDark": "/tips/chat-access-picker-dark.webp",
96
+ "alt": "The access picker open in the composer, listing Ask first, Accept edits and Full access.",
97
+ "width": 768,
98
+ "height": 480
81
99
  }
82
100
  },
83
101
  {
@@ -88,9 +106,13 @@
88
106
  "file": "chat"
89
107
  },
90
108
  "added": "2026-10-31",
91
- "mediaBrief": {
92
- "kind": "crop",
93
- "shot": "A user message with its Undo action visible on hover."
109
+ "media": {
110
+ "kind": "image",
111
+ "src": "/tips/chat-undo.webp",
112
+ "srcDark": "/tips/chat-undo-dark.webp",
113
+ "alt": "The undo button on a user message in a chat transcript.",
114
+ "width": 768,
115
+ "height": 480
94
116
  }
95
117
  },
96
118
  {
@@ -101,9 +123,14 @@
101
123
  "file": "chat"
102
124
  },
103
125
  "added": "2026-10-31",
104
- "mediaBrief": {
105
- "kind": "clip",
106
- "shot": "Click Terminal in the Chat / Terminal switch; the same conversation resumes in the terminal."
126
+ "media": {
127
+ "kind": "video",
128
+ "src": "/tips/chat-terminal-handoff.mp4",
129
+ "poster": "/tips/chat-terminal-handoff-poster.webp",
130
+ "alt": "The Chat and Terminal switch flipping a session from the chat view to its terminal.",
131
+ "width": 768,
132
+ "height": 480,
133
+ "durationMs": 5967
107
134
  }
108
135
  },
109
136
  {
@@ -115,9 +142,13 @@
115
142
  },
116
143
  "when": ["no-bookmarks"],
117
144
  "added": "2026-10-31",
118
- "mediaBrief": {
119
- "kind": "crop",
120
- "shot": "Chat header's star list open with three bookmarks."
145
+ "media": {
146
+ "kind": "image",
147
+ "src": "/tips/chat-bookmark.webp",
148
+ "srcDark": "/tips/chat-bookmark-dark.webp",
149
+ "alt": "A bookmarked message with the star filled in a chat transcript.",
150
+ "width": 768,
151
+ "height": 481
121
152
  }
122
153
  },
123
154
  {
@@ -128,9 +159,14 @@
128
159
  "file": "chat"
129
160
  },
130
161
  "added": "2026-10-31",
131
- "mediaBrief": {
132
- "kind": "clip",
133
- "shot": "⌘K, type two words, matching chats appear, Tab switches to the full search."
162
+ "media": {
163
+ "kind": "video",
164
+ "src": "/tips/chat-search.mp4",
165
+ "poster": "/tips/chat-search-poster.webp",
166
+ "alt": "The command palette searching conversations for a phrase and listing matching sessions.",
167
+ "width": 768,
168
+ "height": 480,
169
+ "durationMs": 8000
134
170
  }
135
171
  },
136
172
  {
@@ -142,9 +178,14 @@
142
178
  "anchor": "new-sessions"
143
179
  },
144
180
  "added": "2026-10-31",
145
- "mediaBrief": {
146
- "kind": "clip",
147
- "shot": "⌥N opens a draft with project, agent and worktree chips; type a line."
181
+ "media": {
182
+ "kind": "video",
183
+ "src": "/tips/sidebar-draft.mp4",
184
+ "poster": "/tips/sidebar-draft-poster.webp",
185
+ "alt": "A new draft session opened from the New menu with a prompt typed into its composer.",
186
+ "width": 768,
187
+ "height": 480,
188
+ "durationMs": 7667
148
189
  }
149
190
  },
150
191
  {
@@ -176,9 +217,13 @@
176
217
  "anchor": "the-sidebar"
177
218
  },
178
219
  "added": "2026-10-31",
179
- "mediaBrief": {
180
- "kind": "crop",
181
- "shot": "A row's ⋯ menu open on Snooze with Tomorrow and Next week."
220
+ "media": {
221
+ "kind": "image",
222
+ "src": "/tips/sidebar-snooze.webp",
223
+ "srcDark": "/tips/sidebar-snooze-dark.webp",
224
+ "alt": "A session row's menu open with the snooze options in the sidebar.",
225
+ "width": 768,
226
+ "height": 480
182
227
  }
183
228
  },
184
229
  {
@@ -191,9 +236,13 @@
191
236
  },
192
237
  "when": ["no-groups"],
193
238
  "added": "2026-10-31",
194
- "mediaBrief": {
195
- "kind": "screenshot",
196
- "shot": "Sidebar with a named group shelf holding three sessions."
239
+ "media": {
240
+ "kind": "image",
241
+ "src": "/tips/sidebar-groups.webp",
242
+ "srcDark": "/tips/sidebar-groups-dark.webp",
243
+ "alt": "The sidebar with sessions grouped under a named group header.",
244
+ "width": 768,
245
+ "height": 480
197
246
  }
198
247
  },
199
248
  {
@@ -205,9 +254,13 @@
205
254
  "anchor": "machine-load"
206
255
  },
207
256
  "added": "2026-10-31",
208
- "mediaBrief": {
209
- "kind": "crop",
210
- "shot": "The machine load popover: the dot amber, the top CPU sessions listed."
257
+ "media": {
258
+ "kind": "image",
259
+ "src": "/tips/sidebar-load.webp",
260
+ "srcDark": "/tips/sidebar-load-dark.webp",
261
+ "alt": "The sidebar load indicator showing which sessions use the most CPU and memory.",
262
+ "width": 768,
263
+ "height": 480
211
264
  }
212
265
  },
213
266
  {
@@ -218,9 +271,13 @@
218
271
  "file": "tasks"
219
272
  },
220
273
  "added": "2026-10-31",
221
- "mediaBrief": {
222
- "kind": "crop",
223
- "shot": "A task in review with CI green and the Sign off & merge button."
274
+ "media": {
275
+ "kind": "image",
276
+ "src": "/tips/tasks-sign-off.webp",
277
+ "srcDark": "/tips/tasks-sign-off-dark.webp",
278
+ "alt": "A task in review with checks passing and a Sign off action.",
279
+ "width": 768,
280
+ "height": 480
224
281
  }
225
282
  },
226
283
  {
@@ -250,9 +307,13 @@
250
307
  "anchor": "plans-in-pieces"
251
308
  },
252
309
  "added": "2026-10-31",
253
- "mediaBrief": {
254
- "kind": "screenshot",
255
- "shot": "A plan awaiting approval with two pieces, Merge into ① and Drop on the second."
310
+ "media": {
311
+ "kind": "image",
312
+ "src": "/tips/tasks-plan-pieces.webp",
313
+ "srcDark": "/tips/tasks-plan-pieces-dark.webp",
314
+ "alt": "A task plan split into pieces awaiting approval.",
315
+ "width": 768,
316
+ "height": 480
256
317
  }
257
318
  },
258
319
  {
@@ -274,9 +335,13 @@
274
335
  "anchor": "stacks"
275
336
  },
276
337
  "added": "2026-10-31",
277
- "mediaBrief": {
278
- "kind": "screenshot",
279
- "shot": "A stack of three tasks with their PR states and Land."
338
+ "media": {
339
+ "kind": "image",
340
+ "src": "/tips/tasks-stacks.webp",
341
+ "srcDark": "/tips/tasks-stacks-dark.webp",
342
+ "alt": "A stack of pull requests listed in order under one stack.",
343
+ "width": 768,
344
+ "height": 480
280
345
  }
281
346
  },
282
347
  {
@@ -289,9 +354,13 @@
289
354
  },
290
355
  "when": ["no-schedules"],
291
356
  "added": "2026-10-31",
292
- "mediaBrief": {
293
- "kind": "screenshot",
294
- "shot": "The Schedules form filled in: daily at 9 AM, a prompt, a project."
357
+ "media": {
358
+ "kind": "image",
359
+ "src": "/tips/schedule-first.webp",
360
+ "srcDark": "/tips/schedule-first-dark.webp",
361
+ "alt": "The schedule form with a prompt, a repeat interval and a time zone.",
362
+ "width": 768,
363
+ "height": 480
295
364
  }
296
365
  },
297
366
  {
@@ -314,9 +383,13 @@
314
383
  },
315
384
  "when": ["no-recipes"],
316
385
  "added": "2026-10-31",
317
- "mediaBrief": {
318
- "kind": "crop",
319
- "shot": "The recipe form prefilled from a finished session."
386
+ "media": {
387
+ "kind": "image",
388
+ "src": "/tips/recipe-save.webp",
389
+ "srcDark": "/tips/recipe-save-dark.webp",
390
+ "alt": "The menu action that saves a conversation as a recipe.",
391
+ "width": 768,
392
+ "height": 480
320
393
  }
321
394
  },
322
395
  {
@@ -346,9 +419,13 @@
346
419
  "file": "agent-network"
347
420
  },
348
421
  "added": "2026-10-31",
349
- "mediaBrief": {
350
- "kind": "screenshot",
351
- "shot": "Messages with two sessions talking, one waiting on a reply."
422
+ "media": {
423
+ "kind": "image",
424
+ "src": "/tips/net-messages.webp",
425
+ "srcDark": "/tips/net-messages-dark.webp",
426
+ "alt": "A session sending and receiving messages from another session.",
427
+ "width": 768,
428
+ "height": 480
352
429
  }
353
430
  },
354
431
  {
@@ -394,9 +471,13 @@
394
471
  },
395
472
  "when": ["no-shares"],
396
473
  "added": "2026-10-31",
397
- "mediaBrief": {
398
- "kind": "screenshot",
399
- "shot": "The Share dialog preview with masked values highlighted, before Create link."
474
+ "media": {
475
+ "kind": "image",
476
+ "src": "/tips/share-check.webp",
477
+ "srcDark": "/tips/share-check-dark.webp",
478
+ "alt": "The share dialog preview showing secrets, addresses and emails masked.",
479
+ "width": 768,
480
+ "height": 481
400
481
  }
401
482
  },
402
483
  {
@@ -417,9 +498,13 @@
417
498
  "file": "export"
418
499
  },
419
500
  "added": "2026-10-31",
420
- "mediaBrief": {
421
- "kind": "crop",
422
- "shot": "The Export for AI sheet: format, Compact selected, the size estimate."
501
+ "media": {
502
+ "kind": "image",
503
+ "src": "/tips/export-ai.webp",
504
+ "srcDark": "/tips/export-ai-dark.webp",
505
+ "alt": "The export menu with an option to copy the conversation for another AI.",
506
+ "width": 768,
507
+ "height": 481
423
508
  }
424
509
  },
425
510
  {
@@ -1817,6 +1817,38 @@
1817
1817
  "area": "Chat",
1818
1818
  "text": "Chats now fill the screen with earlier messages on open instead of showing a nearly empty view with a \"Show earlier messages\" button.",
1819
1819
  "url": "https://github.com/saadnvd1/agent-os/pull/533"
1820
+ },
1821
+ {
1822
+ "id": 536,
1823
+ "date": "2026-10-31",
1824
+ "kind": "new",
1825
+ "area": "AgentOS",
1826
+ "text": "Tip of the Day now shows pictures and short clips that demonstrate each tip.",
1827
+ "url": "https://github.com/saadnvd1/agent-os/pull/536"
1828
+ },
1829
+ {
1830
+ "id": 537,
1831
+ "date": "2026-10-31",
1832
+ "kind": "new",
1833
+ "area": "AgentOS",
1834
+ "text": "Chats now reopen where you left off, with back and forward buttons, a quick chat switcher and a keyboard shortcuts list.",
1835
+ "url": "https://github.com/saadnvd1/agent-os/pull/537"
1836
+ },
1837
+ {
1838
+ "id": 539,
1839
+ "date": "2026-10-31",
1840
+ "kind": "fixed",
1841
+ "area": "AgentOS",
1842
+ "text": "Holding Shift with a letter no longer triggers the shortcut meant for that letter on its own.",
1843
+ "url": "https://github.com/saadnvd1/agent-os/pull/539"
1844
+ },
1845
+ {
1846
+ "id": 541,
1847
+ "date": "2026-10-31",
1848
+ "kind": "new",
1849
+ "area": "Chat",
1850
+ "text": "Pressing Esc now takes you out of the message box when there is nothing else for it to do.",
1851
+ "url": "https://github.com/saadnvd1/agent-os/pull/541"
1820
1852
  }
1821
1853
  ]
1822
1854
  }
@@ -0,0 +1,2 @@
1
+ export * from "./keys";
2
+ export * from "./queries";
@@ -0,0 +1,3 @@
1
+ export const chatViewKeys = {
2
+ all: ["chat-views"] as const,
3
+ };
@@ -0,0 +1,34 @@
1
+ import { useQuery } from "@tanstack/react-query";
2
+ import type { ChatView, ChatViewInput } from "@/lib/chat-views/types";
3
+ import { chatViewKeys } from "./keys";
4
+
5
+ export type { ChatView } from "@/lib/chat-views/types";
6
+
7
+ /** Where each chat was last read and when, newest first. Another device's
8
+ * reading shows up when this tab is focused again. */
9
+ export function useChatViews() {
10
+ return useQuery({
11
+ queryKey: chatViewKeys.all,
12
+ queryFn: async (): Promise<ChatView[]> => {
13
+ const res = await fetch("/api/chat-views");
14
+ if (!res.ok) throw new Error("Failed to fetch chat views");
15
+ return (await res.json()).views;
16
+ },
17
+ staleTime: 30_000,
18
+ refetchOnWindowFocus: true,
19
+ });
20
+ }
21
+
22
+ /** Saves where the reader is, or (null) that they just opened the chat.
23
+ * keepalive lets it finish while the page unloads. */
24
+ export function saveChatView(
25
+ sessionId: string,
26
+ input: (Omit<ChatViewInput, "advance"> & { advance?: boolean }) | null
27
+ ): void {
28
+ void fetch(`/api/chat-views/${encodeURIComponent(sessionId)}`, {
29
+ method: "PUT",
30
+ headers: { "Content-Type": "application/json" },
31
+ body: JSON.stringify(input ?? { viewed: true }),
32
+ keepalive: true,
33
+ }).catch(() => {});
34
+ }
@@ -1,5 +1,4 @@
1
1
  import { useMutation, useQueryClient } from "@tanstack/react-query";
2
- import type { Session } from "@/lib/db";
3
2
  import { sessionKeys } from "./keys";
4
3
  import type { SessionsResponse } from "./queries";
5
4
 
@@ -0,0 +1,12 @@
1
+ import { useEffect } from "react";
2
+ import { useChatViews } from "@/data/chat-views";
3
+ import { chatPositionsActions } from "@/stores/chatPositions";
4
+
5
+ /** Hands the server's reading positions to the page; chats wait for them to open where they were left. */
6
+ export function useChatViewsSync(): void {
7
+ const { data, isError } = useChatViews();
8
+ useEffect(() => {
9
+ if (data) chatPositionsActions.hydrate(data);
10
+ else if (isError) chatPositionsActions.giveUp();
11
+ }, [data, isError]);
12
+ }
@@ -0,0 +1,104 @@
1
+ "use client";
2
+
3
+ import { Keyboard, MessageSquare, Repeat } from "lucide-react";
4
+ import type { Session } from "@/lib/db";
5
+ import { chordHint } from "@/lib/keybindings/chord";
6
+ import { keysOf } from "@/lib/keybindings/keymap";
7
+ import { previousChatId } from "@/lib/nav/recent";
8
+ import { useNavKeys, useSingleKeys } from "@/hooks/useNavKeys";
9
+ import { usePaletteCommands } from "@/hooks/usePaletteCommands";
10
+ import { bookmarksUiActions } from "@/stores/bookmarksUi";
11
+ import { chatOpened } from "@/stores/chatPositions";
12
+ import { newDraft } from "@/stores/drafts";
13
+ import { inboxUiActions } from "@/stores/inboxUi";
14
+ import { paletteActions, paletteUi } from "@/stores/palette";
15
+ import { settingsUiActions } from "@/stores/settingsUi";
16
+ import { shortcutsUiActions } from "@/stores/shortcutsUi";
17
+
18
+ /** The chats ⌘P, O C and the like switch between, as the palette's Sessions tab. */
19
+ function openSwitcher() {
20
+ if (paletteUi.open && paletteUi.tab === "sessions")
21
+ return paletteActions.setOpen(false);
22
+ paletteActions.open();
23
+ paletteActions.setTab("sessions");
24
+ }
25
+
26
+ /**
27
+ * What each shortcut in keymap.ts does, and the palette commands that go
28
+ * with them. Opening a chat goes through `select`, so history, position and
29
+ * the address follow as they do for a click.
30
+ */
31
+ export function useNavActions({
32
+ sessions,
33
+ activeSessionId,
34
+ select,
35
+ }: {
36
+ sessions: Session[];
37
+ activeSessionId: string | null | undefined;
38
+ select: (session: Session) => void;
39
+ }) {
40
+ const [singles, setSingles] = useSingleKeys();
41
+ const previous = () => {
42
+ const open = sessions.filter((s) => !s.archived_at);
43
+ const id = previousChatId(
44
+ open.map((s) => s.id),
45
+ chatOpened,
46
+ activeSessionId
47
+ );
48
+ const session = open.find((s) => s.id === id);
49
+ if (session) select(session);
50
+ };
51
+ const actions = {
52
+ "palette.open": () =>
53
+ paletteUi.open ? paletteActions.setOpen(false) : paletteActions.open(),
54
+ "switcher.open": openSwitcher,
55
+ "chat.open": openSwitcher,
56
+ "chat.previous": previous,
57
+ "chat.new": () => newDraft({ kind: "current" }),
58
+ "go.inbox": () => inboxUiActions.open(),
59
+ "go.messages": () => inboxUiActions.open("messages"),
60
+ "go.bookmarks": () => bookmarksUiActions.open(activeSessionId ?? null),
61
+ "go.settings": () => settingsUiActions.open(),
62
+ "help.open": shortcutsUiActions.open,
63
+ };
64
+ useNavKeys(actions);
65
+ usePaletteCommands("nav", [
66
+ {
67
+ id: "nav.switch",
68
+ title: "Switch chat",
69
+ group: "Go to",
70
+ hint: chordHint(keysOf("switcher.open")),
71
+ keywords: ["recent", "open chat", "session", "switcher"],
72
+ icon: MessageSquare,
73
+ run: openSwitcher,
74
+ },
75
+ {
76
+ id: "nav.previous",
77
+ title: "Previous chat",
78
+ group: "Go to",
79
+ hint: "G P",
80
+ keywords: ["flip", "alternate", "last", "back"],
81
+ icon: Repeat,
82
+ run: previous,
83
+ },
84
+ {
85
+ id: "nav.shortcuts",
86
+ title: "Keyboard shortcuts",
87
+ group: "Go to",
88
+ hint: "?",
89
+ keywords: ["keys", "help", "hotkeys"],
90
+ icon: Keyboard,
91
+ run: shortcutsUiActions.open,
92
+ },
93
+ {
94
+ id: "nav.single-keys",
95
+ title: singles
96
+ ? "Turn off single-key shortcuts"
97
+ : "Turn on single-key shortcuts",
98
+ group: "Settings",
99
+ keywords: ["keyboard", "letters", "vim", "linear"],
100
+ icon: Keyboard,
101
+ run: () => setSingles(!singles),
102
+ },
103
+ ]);
104
+ }
@@ -0,0 +1,105 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef } from "react";
4
+ import { useKeybindings } from "@/hooks/useKeybindings";
5
+ import { useStoredFlag } from "@/hooks/useStoredFlag";
6
+ import { isFreeTarget } from "@/lib/keybindings/focus";
7
+ import { KEYMAP, isSequence, keysOf } from "@/lib/keybindings/keymap";
8
+ import {
9
+ SEQUENCE_MS,
10
+ SequenceMatcher,
11
+ plainKey,
12
+ } from "@/lib/keybindings/sequence";
13
+ import { shortcutsUiActions } from "@/stores/shortcutsUi";
14
+
15
+ export const SINGLE_KEYS_FLAG = "agentos-single-key-shortcuts";
16
+
17
+ /** The single-key shortcuts' on/off switch, kept per browser. */
18
+ export const useSingleKeys = () => useStoredFlag(SINGLE_KEYS_FLAG, true);
19
+
20
+ export type NavActions = Record<string, () => void>;
21
+
22
+ const SEQUENCES = Object.fromEntries(
23
+ KEYMAP.filter((k) => k.single && isSequence(k.keys)).map((k) => [
24
+ k.keys,
25
+ k.id,
26
+ ])
27
+ );
28
+ const BARE = Object.fromEntries(
29
+ KEYMAP.filter((k) => k.single && !isSequence(k.keys)).map((k) => [
30
+ k.keys,
31
+ k.id,
32
+ ])
33
+ );
34
+
35
+ const CHORD_IDS = ["switcher.open", "palette.open"];
36
+
37
+ const dialogOpen = () =>
38
+ !!document.querySelector('[role="dialog"][data-state="open"]');
39
+
40
+ /**
41
+ * The app's keys by id (see keymap.ts): chords through the registry, bare
42
+ * keys and two-key sequences here, only while nothing is being typed into
43
+ * and the single-key switch is on.
44
+ */
45
+ export function useNavKeys(actions: NavActions) {
46
+ const latest = useRef(actions);
47
+ useEffect(() => {
48
+ latest.current = actions;
49
+ });
50
+ const run = (id: string) => latest.current[id]?.();
51
+ const [singles] = useSingleKeys();
52
+ const singlesOn = useRef(singles);
53
+ useEffect(() => {
54
+ singlesOn.current = singles;
55
+ });
56
+
57
+ // The sidebar's ⌘↑/⌘↓ are bound by useSessionSwitching, from the same table.
58
+ useKeybindings(
59
+ "nav",
60
+ CHORD_IDS.map((id) => ({ id, keys: keysOf(id), run: () => run(id) }))
61
+ );
62
+
63
+ useEffect(() => {
64
+ const matcher = new SequenceMatcher(SEQUENCES);
65
+ let timer: ReturnType<typeof setTimeout> | undefined;
66
+ const clear = () => {
67
+ clearTimeout(timer);
68
+ matcher.reset();
69
+ shortcutsUiActions.setPending(null);
70
+ };
71
+ const onKey = (e: KeyboardEvent) => {
72
+ if (!singlesOn.current) return clear();
73
+ if (e.metaKey || e.ctrlKey || e.altKey || e.isComposing) return clear();
74
+ if (!isFreeTarget(e.target) || dialogOpen()) return clear();
75
+ if (e.key === "Escape") return clear();
76
+ if (e.key === "Shift") return;
77
+ const bare = plainKey(e.key, e.shiftKey);
78
+ if (bare === null) return clear();
79
+ const waiting = matcher.pending() !== null;
80
+ const step = matcher.feed(bare);
81
+ if (step.kind === "match") {
82
+ e.preventDefault();
83
+ clear();
84
+ run(step.id);
85
+ } else if (step.kind === "pending") {
86
+ e.preventDefault();
87
+ shortcutsUiActions.setPending(step.first);
88
+ clearTimeout(timer);
89
+ timer = setTimeout(clear, SEQUENCE_MS);
90
+ } else {
91
+ clear();
92
+ const id = BARE[bare.toLowerCase()];
93
+ if (id && !waiting && !e.repeat) {
94
+ e.preventDefault();
95
+ run(id);
96
+ }
97
+ }
98
+ };
99
+ window.addEventListener("keydown", onKey, true);
100
+ return () => {
101
+ window.removeEventListener("keydown", onKey, true);
102
+ clear();
103
+ };
104
+ }, []);
105
+ }