@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.
Files changed (167) hide show
  1. package/app/api/busy/route.ts +2 -2
  2. package/app/api/chat-views/[sessionId]/route.ts +19 -0
  3. package/app/api/chat-views/route.test.ts +79 -0
  4. package/app/api/chat-views/route.ts +8 -0
  5. package/app/api/tips/route.test.ts +56 -0
  6. package/app/api/tips/route.ts +45 -0
  7. package/app/api/updates/health/route.ts +22 -0
  8. package/app/api/updates/route.test.ts +53 -0
  9. package/app/api/updates/settings/route.ts +37 -10
  10. package/app/page.tsx +17 -14
  11. package/components/Chat/ChatPanel.tsx +74 -59
  12. package/components/Chat/NewDivider.tsx +15 -0
  13. package/components/Chat/needs-more-history.test.ts +28 -0
  14. package/components/Chat/needs-more-history.ts +14 -0
  15. package/components/Chat/session-link.ts +7 -4
  16. package/components/Chat/useFind.ts +50 -1
  17. package/components/Chat/usePosition.ts +363 -0
  18. package/components/CodeSearchDialog.tsx +90 -0
  19. package/components/CommandPalette/CommandsBody.tsx +12 -4
  20. package/components/CommandPalette/useAppCommands.ts +29 -11
  21. package/components/DevServers/ServerLogsModal.tsx +24 -21
  22. package/components/Nav/HistoryButtons.tsx +128 -0
  23. package/components/Pane/MobileTabBar.tsx +3 -0
  24. package/components/Pane/index.tsx +2 -2
  25. package/components/Recipes/TimerFields.tsx +2 -1
  26. package/components/Schedules/ScheduleForm.tsx +1 -1
  27. package/components/SessionList/useSessionSwitching.ts +3 -2
  28. package/components/Settings/SettingsDialog.tsx +1 -1
  29. package/components/Shortcuts/PendingKeys.tsx +19 -0
  30. package/components/Shortcuts/ShortcutsButton.tsx +22 -0
  31. package/components/Shortcuts/ShortcutsDialog.tsx +122 -0
  32. package/components/SidebarFooter.tsx +4 -0
  33. package/components/Terminal/hooks/useTerminalConnection.ts +4 -3
  34. package/components/Tips/TipButton.tsx +39 -0
  35. package/components/Tips/TipDialog.tsx +148 -0
  36. package/components/Tips/TipHost.tsx +14 -0
  37. package/components/Tips/TipMedia.tsx +71 -0
  38. package/components/Tips/index.ts +2 -0
  39. package/components/Updates/AutoUpdates.tsx +141 -0
  40. package/components/Updates/UpdateBanner.test.ts +39 -1
  41. package/components/Updates/UpdateBanner.tsx +31 -5
  42. package/components/Updates/UpdatesSection.tsx +24 -3
  43. package/components/views/DesktopView.tsx +7 -9
  44. package/components/views/MobileView.tsx +2 -8
  45. package/components/views/lazy.ts +2 -2
  46. package/content/tips.json +541 -0
  47. package/content/whats-new.json +56 -0
  48. package/data/chat-views/index.ts +2 -0
  49. package/data/chat-views/keys.ts +3 -0
  50. package/data/chat-views/queries.ts +34 -0
  51. package/data/sessions/pin.ts +0 -1
  52. package/data/tips/index.ts +49 -0
  53. package/data/updates/index.ts +5 -0
  54. package/hooks/useChatViewsSync.ts +12 -0
  55. package/hooks/useNavActions.ts +104 -0
  56. package/hooks/useNavKeys.ts +105 -0
  57. package/hooks/useReloadWhenUpdated.ts +5 -1
  58. package/hooks/useSessionUrl.ts +23 -5
  59. package/lib/bus/index.ts +4 -2
  60. package/lib/bus/role-alias.ts +4 -2
  61. package/lib/bus/targets.ts +2 -1
  62. package/lib/busy.ts +23 -1
  63. package/lib/chat/escape.ts +8 -4
  64. package/lib/chat-views/body.ts +41 -0
  65. package/lib/chat-views/store.ts +67 -0
  66. package/lib/chat-views/types.ts +20 -0
  67. package/lib/db/migrations/20261031T1500_chat_views.ts +30 -0
  68. package/lib/db/migrations.ts +21 -0
  69. package/lib/keybindings/focus.ts +17 -0
  70. package/lib/keybindings/keymap.ts +120 -0
  71. package/lib/keybindings/sequence.ts +64 -0
  72. package/lib/live/service.ts +1 -1
  73. package/lib/nav/history-mirror.ts +59 -0
  74. package/lib/nav/history-writer.ts +109 -0
  75. package/lib/nav/position.ts +67 -0
  76. package/lib/nav/recent.ts +34 -0
  77. package/lib/orchestrator/armed.ts +5 -0
  78. package/lib/palette/sections.ts +12 -6
  79. package/lib/recipes/timer.ts +3 -1
  80. package/lib/schedules/cron.ts +14 -5
  81. package/lib/schedules/store.ts +3 -2
  82. package/lib/security/demo.ts +2 -0
  83. package/lib/session-url.ts +11 -5
  84. package/lib/tasks/finish.ts +5 -5
  85. package/lib/tips/conditions.ts +38 -0
  86. package/lib/tips/index.ts +2 -0
  87. package/lib/tips/load.ts +26 -0
  88. package/lib/tips/media.ts +101 -0
  89. package/lib/tips/pick.ts +35 -0
  90. package/lib/tips/schema.ts +82 -0
  91. package/lib/tips/state.ts +26 -0
  92. package/lib/tips/view.ts +60 -0
  93. package/lib/updates/auto.ts +149 -0
  94. package/lib/updates/banner.ts +78 -3
  95. package/lib/updates/check.ts +64 -8
  96. package/lib/updates/history.ts +99 -0
  97. package/lib/updates/run.ts +334 -23
  98. package/lib/updates/settings.ts +66 -0
  99. package/lib/updates/view.ts +14 -3
  100. package/package.json +2 -2
  101. package/public/tips/.gitkeep +0 -0
  102. package/public/tips/chat-access-picker-dark.webp +0 -0
  103. package/public/tips/chat-access-picker.webp +0 -0
  104. package/public/tips/chat-attach-files-poster.webp +0 -0
  105. package/public/tips/chat-attach-files.mp4 +0 -0
  106. package/public/tips/chat-bookmark-dark.webp +0 -0
  107. package/public/tips/chat-bookmark.webp +0 -0
  108. package/public/tips/chat-plan-mode-dark.webp +0 -0
  109. package/public/tips/chat-plan-mode.webp +0 -0
  110. package/public/tips/chat-search-poster.webp +0 -0
  111. package/public/tips/chat-search.mp4 +0 -0
  112. package/public/tips/chat-slash-menu-poster.webp +0 -0
  113. package/public/tips/chat-slash-menu.mp4 +0 -0
  114. package/public/tips/chat-tab-suggestion-poster.webp +0 -0
  115. package/public/tips/chat-tab-suggestion.mp4 +0 -0
  116. package/public/tips/chat-terminal-handoff-poster.webp +0 -0
  117. package/public/tips/chat-terminal-handoff.mp4 +0 -0
  118. package/public/tips/chat-undo-dark.webp +0 -0
  119. package/public/tips/chat-undo.webp +0 -0
  120. package/public/tips/export-ai-dark.webp +0 -0
  121. package/public/tips/export-ai.webp +0 -0
  122. package/public/tips/net-messages-dark.webp +0 -0
  123. package/public/tips/net-messages.webp +0 -0
  124. package/public/tips/recipe-save-dark.webp +0 -0
  125. package/public/tips/recipe-save.webp +0 -0
  126. package/public/tips/schedule-first-dark.webp +0 -0
  127. package/public/tips/schedule-first.webp +0 -0
  128. package/public/tips/share-check-dark.webp +0 -0
  129. package/public/tips/share-check.webp +0 -0
  130. package/public/tips/sidebar-draft-poster.webp +0 -0
  131. package/public/tips/sidebar-draft.mp4 +0 -0
  132. package/public/tips/sidebar-groups-dark.webp +0 -0
  133. package/public/tips/sidebar-groups.webp +0 -0
  134. package/public/tips/sidebar-load-dark.webp +0 -0
  135. package/public/tips/sidebar-load.webp +0 -0
  136. package/public/tips/sidebar-snooze-dark.webp +0 -0
  137. package/public/tips/sidebar-snooze.webp +0 -0
  138. package/public/tips/tasks-plan-pieces-dark.webp +0 -0
  139. package/public/tips/tasks-plan-pieces.webp +0 -0
  140. package/public/tips/tasks-sign-off-dark.webp +0 -0
  141. package/public/tips/tasks-sign-off.webp +0 -0
  142. package/public/tips/tasks-stacks-dark.webp +0 -0
  143. package/public/tips/tasks-stacks.webp +0 -0
  144. package/scripts/autodeploy +4 -0
  145. package/scripts/autodeploy.test.ts +12 -0
  146. package/scripts/check +2 -2
  147. package/scripts/docs-links.test.ts +1 -25
  148. package/scripts/lib/commands.sh +32 -1
  149. package/scripts/lib/deps-swap.sh +8 -6
  150. package/scripts/lib/heading-slugs.ts +26 -0
  151. package/scripts/lib/update-verify.mjs +90 -0
  152. package/scripts/redeploy +31 -2
  153. package/scripts/redeploy.test.ts +94 -3
  154. package/scripts/tips-lint.test.ts +77 -0
  155. package/scripts/tips-media.ts +31 -0
  156. package/scripts/update-e2e.test.ts +215 -0
  157. package/scripts/update-recover.mjs +130 -0
  158. package/scripts/update-recover.test.ts +144 -0
  159. package/scripts/update-run.mjs +182 -40
  160. package/scripts/update-run.test.ts +208 -2
  161. package/server.ts +3 -2
  162. package/stores/chatJump.ts +2 -6
  163. package/stores/chatPositions.ts +65 -0
  164. package/stores/navHistory.ts +98 -0
  165. package/stores/shortcutsUi.ts +20 -0
  166. package/stores/tips.ts +14 -0
  167. package/components/QuickSwitcher.tsx +0 -286
@@ -0,0 +1,363 @@
1
+ "use client";
2
+
3
+ import {
4
+ type RefObject,
5
+ useCallback,
6
+ useEffect,
7
+ useLayoutEffect,
8
+ useRef,
9
+ useState,
10
+ } from "react";
11
+ import { useSnapshot } from "valtio";
12
+ import { saveChatView } from "@/data/chat-views";
13
+ import type { ChatItem } from "@/lib/chat/events";
14
+ import { locate } from "@/lib/chat/find";
15
+ import {
16
+ anchorOf,
17
+ decideOpen,
18
+ type Position,
19
+ type RowRect,
20
+ } from "@/lib/nav/position";
21
+ import { bookmarksUi, bookmarksUiActions } from "@/stores/bookmarksUi";
22
+ import { chatPositions, chatPositionsActions } from "@/stores/chatPositions";
23
+ import { navHistoryActions } from "@/stores/navHistory";
24
+ import { NEAR_BOTTOM_PX, distanceFromBottom } from "./stick-to-bottom";
25
+
26
+ // A position is saved this long after the reader stops scrolling.
27
+ const SAVE_IDLE_MS = 3000;
28
+ // Older pages loaded to find where the new items begin, at most.
29
+ const MAX_PAGES_FOR_NEW = 5;
30
+
31
+ interface Deps {
32
+ sessionId: string;
33
+ active: boolean;
34
+ loadedFor: string | null;
35
+ items: ChatItem[];
36
+ hasMore: boolean;
37
+ loadingOlder: boolean;
38
+ loadOlder: () => void;
39
+ listRef: RefObject<HTMLDivElement | null>;
40
+ release: () => void;
41
+ toEnd: () => void;
42
+ foldEvents: boolean;
43
+ // Takes the reader to a message by a rule over the items (bookmarks).
44
+ goTo: (match: (items: ChatItem[]) => string | null) => void;
45
+ }
46
+
47
+ type Job =
48
+ | { sessionId: string; kind: "block"; block: string; offsetPx: number }
49
+ | { sessionId: string; kind: "new"; viewedAt: number; pages: number };
50
+
51
+ const afterLayout = (fn: () => void) =>
52
+ requestAnimationFrame(() => requestAnimationFrame(fn));
53
+
54
+ /**
55
+ * Where a chat is when it opens, and remembering where the reader leaves it.
56
+ *
57
+ * Tracking: the first row in view and its offset are kept as the reader
58
+ * scrolls, written into the current history entry (so back lands there) and
59
+ * saved to the server when they leave, hide the page, or stop scrolling.
60
+ * Only after the reader's own scrolling or tapping: an idle tab left at the
61
+ * end can't overwrite the place they are reading on another device.
62
+ *
63
+ * Opening (lib/nav/position.decideOpen): a place a history entry or a
64
+ * bookmark asked for, else the first item newer than the last time it was
65
+ * open (with a "New" divider above it), else where it was left, else the
66
+ * latest message.
67
+ */
68
+ export function usePosition(d: Deps) {
69
+ const { sessionId, active, loadedFor, listRef } = d;
70
+ const latest = useRef(d);
71
+ useEffect(() => {
72
+ latest.current = d;
73
+ });
74
+ const st = useRef({
75
+ sessionId: "",
76
+ decided: false,
77
+ settled: false,
78
+ touched: false,
79
+ dirty: false,
80
+ pos: null as Position | null,
81
+ idle: undefined as ReturnType<typeof setTimeout> | undefined,
82
+ });
83
+ // The placement under way; moved on by `advance` below.
84
+ const jobRef = useRef<Job | null>(null);
85
+ const advanceRef = useRef<() => void>(() => {});
86
+ const [divider, setDivider] = useState<{
87
+ sessionId: string;
88
+ block: string;
89
+ } | null>(null);
90
+ const { loaded, target } = useSnapshot(chatPositions);
91
+ const { jump } = useSnapshot(bookmarksUi);
92
+
93
+ // A conversation starts at the end, until the open below says otherwise.
94
+ useLayoutEffect(() => {
95
+ const el = listRef.current;
96
+ if (el) el.scrollTop = el.scrollHeight;
97
+ }, [sessionId, listRef]);
98
+
99
+ const stamp = useCallback((id: string) => {
100
+ saveChatView(id, null);
101
+ const mine = chatPositions.views[id];
102
+ chatPositionsActions.record({
103
+ sessionId: id,
104
+ itemId: mine?.itemId ?? null,
105
+ offsetPx: mine?.offsetPx ?? 0,
106
+ atEnd: mine?.atEnd ?? true,
107
+ viewedAt: Date.now(),
108
+ });
109
+ }, []);
110
+
111
+ // Saves where the reader is. Left at the end, everything up to now counts
112
+ // as seen; left higher up, what arrived below does not.
113
+ const save = useCallback(() => {
114
+ const s = st.current;
115
+ if (!s.settled || !s.sessionId) return;
116
+ clearTimeout(s.idle);
117
+ navHistoryActions.flush();
118
+ if (!s.touched || !s.pos) {
119
+ // Not moved by the reader: the place they were given stands. Only a
120
+ // chat watched at its end has seen what arrived since.
121
+ if (!s.pos || s.pos.atEnd) stamp(s.sessionId);
122
+ return;
123
+ }
124
+ const { block, offsetPx, atEnd } = s.pos;
125
+ s.dirty = false;
126
+ saveChatView(s.sessionId, {
127
+ itemId: block,
128
+ offsetPx,
129
+ atEnd,
130
+ advance: atEnd,
131
+ });
132
+ const mine = chatPositions.views[s.sessionId];
133
+ chatPositionsActions.record({
134
+ sessionId: s.sessionId,
135
+ itemId: block,
136
+ offsetPx,
137
+ atEnd,
138
+ viewedAt: atEnd ? Date.now() : (mine?.viewedAt ?? Date.now()),
139
+ });
140
+ }, [stamp]);
141
+
142
+ // Track the reader.
143
+ useEffect(() => {
144
+ const el = listRef.current;
145
+ const s = st.current;
146
+ Object.assign(s, {
147
+ sessionId,
148
+ decided: false,
149
+ settled: false,
150
+ touched: false,
151
+ dirty: false,
152
+ pos: null,
153
+ });
154
+ jobRef.current = null;
155
+ if (!el) return;
156
+ let frame = 0;
157
+ const measure = () => {
158
+ frame = 0;
159
+ if (!s.settled || el.offsetParent === null) return;
160
+ const rows: RowRect[] = [];
161
+ for (const r of el.querySelectorAll<HTMLElement>("[data-block]")) {
162
+ const b = r.getBoundingClientRect();
163
+ rows.push({ key: r.dataset.block!, top: b.top, bottom: b.bottom });
164
+ }
165
+ const atEnd =
166
+ distanceFromBottom({
167
+ scrollTop: el.scrollTop,
168
+ scrollHeight: el.scrollHeight,
169
+ clientHeight: el.clientHeight,
170
+ }) <= NEAR_BOTTOM_PX;
171
+ s.pos = anchorOf(el.getBoundingClientRect().top, rows, atEnd);
172
+ navHistoryActions.setPosition(sessionId, s.pos);
173
+ if (s.touched) {
174
+ s.dirty = true;
175
+ clearTimeout(s.idle);
176
+ s.idle = setTimeout(save, SAVE_IDLE_MS);
177
+ }
178
+ };
179
+ const onScroll = () => {
180
+ frame ||= requestAnimationFrame(measure);
181
+ };
182
+ const touch = () => {
183
+ s.touched = true;
184
+ };
185
+ el.addEventListener("scroll", onScroll, { passive: true });
186
+ const hands = ["wheel", "touchmove", "keydown", "pointerdown"] as const;
187
+ for (const e of hands) el.addEventListener(e, touch, { passive: true });
188
+ return () => {
189
+ el.removeEventListener("scroll", onScroll);
190
+ for (const e of hands) el.removeEventListener(e, touch);
191
+ cancelAnimationFrame(frame);
192
+ save();
193
+ s.settled = false;
194
+ };
195
+ }, [sessionId, listRef, save]);
196
+
197
+ // The page going away, or the tab going to the background.
198
+ useEffect(() => {
199
+ const onHide = () => {
200
+ if (document.visibilityState === "hidden") save();
201
+ };
202
+ document.addEventListener("visibilitychange", onHide);
203
+ window.addEventListener("pagehide", save);
204
+ return () => {
205
+ document.removeEventListener("visibilitychange", onHide);
206
+ window.removeEventListener("pagehide", save);
207
+ };
208
+ }, [save]);
209
+
210
+ // Another tab of the pane taking over, or coming back.
211
+ const wasActive = useRef(active);
212
+ useEffect(() => {
213
+ const was = wasActive.current;
214
+ wasActive.current = active;
215
+ const s = st.current;
216
+ if (was && !active) save();
217
+ else if (!was && active && s.settled) stamp(sessionId);
218
+ }, [active, save, stamp, sessionId]);
219
+
220
+ const place = useCallback(
221
+ (block: string, offsetPx: number) => {
222
+ latest.current.release();
223
+ afterLayout(() => {
224
+ const el = listRef.current;
225
+ const row = el?.querySelector<HTMLElement>(
226
+ `[data-block="${CSS.escape(block)}"]`
227
+ );
228
+ if (!el || !row) return;
229
+ el.scrollTop +=
230
+ row.getBoundingClientRect().top -
231
+ el.getBoundingClientRect().top -
232
+ offsetPx;
233
+ // The view held its anchors from before the jump: take new ones, or
234
+ // the next layout change would carry the view back to them.
235
+ latest.current.release();
236
+ st.current.settled = true;
237
+ st.current.pos = null;
238
+ el.dispatchEvent(new Event("scroll"));
239
+ });
240
+ },
241
+ [listRef]
242
+ );
243
+
244
+ // Open: a place asked for, else the rule above.
245
+ useEffect(() => {
246
+ const el = listRef.current;
247
+ const s = st.current;
248
+ if (loadedFor !== sessionId || !loaded || !el || el.offsetParent === null)
249
+ return;
250
+ const asked = chatPositionsActions.takeTarget(sessionId);
251
+ if (asked) {
252
+ s.decided = true;
253
+ if (asked.atEnd || !asked.block) {
254
+ s.settled = true;
255
+ latest.current.toEnd();
256
+ } else {
257
+ jobRef.current = {
258
+ sessionId,
259
+ kind: "block",
260
+ block: asked.block,
261
+ offsetPx: asked.offsetPx,
262
+ };
263
+ advanceRef.current();
264
+ }
265
+ stamp(sessionId);
266
+ return;
267
+ }
268
+ if (jump && jump.sessionId === sessionId) {
269
+ const itemId = bookmarksUiActions.takeJump(sessionId);
270
+ s.decided = true;
271
+ s.settled = true;
272
+ stamp(sessionId);
273
+ if (itemId)
274
+ latest.current.goTo((all) =>
275
+ all.some((i) => i.id === itemId) ? itemId : null
276
+ );
277
+ return;
278
+ }
279
+ if (s.decided) return;
280
+ s.decided = true;
281
+ const view = chatPositions.views[sessionId] ?? null;
282
+ const open = decideOpen({
283
+ view,
284
+ items: latest.current.items,
285
+ latest: chatPositionsActions.takeLatest(sessionId),
286
+ });
287
+ stamp(sessionId);
288
+ if (open.kind === "latest") s.settled = true;
289
+ else {
290
+ jobRef.current =
291
+ open.kind === "position"
292
+ ? {
293
+ sessionId,
294
+ kind: "block",
295
+ block: open.block,
296
+ offsetPx: open.offsetPx,
297
+ }
298
+ : { sessionId, kind: "new", viewedAt: view!.viewedAt, pages: 0 };
299
+ advanceRef.current();
300
+ }
301
+ }, [loadedFor, sessionId, loaded, target, jump, active, listRef, stamp]);
302
+
303
+ // Finish a placement that may need older pages first; runs again whenever
304
+ // the list changes.
305
+ const { items, hasMore, loadingOlder, loadOlder, foldEvents } = d;
306
+ const advance = useCallback(() => {
307
+ const el = listRef.current;
308
+ const job = jobRef.current;
309
+ if (!job || job.sessionId !== sessionId || !el || loadingOlder) return;
310
+ const give = () => {
311
+ jobRef.current = null;
312
+ st.current.settled = true;
313
+ latest.current.toEnd();
314
+ };
315
+ if (job.kind === "block") {
316
+ const there = el.querySelector(`[data-block="${CSS.escape(job.block)}"]`);
317
+ if (there) {
318
+ jobRef.current = null;
319
+ place(job.block, job.offsetPx);
320
+ } else if (hasMore) loadOlder();
321
+ else give();
322
+ return;
323
+ }
324
+ const first = items.findIndex((i) => i.createdAt > job.viewedAt);
325
+ if (first === -1) return give();
326
+ if (first === 0 && hasMore && job.pages < MAX_PAGES_FOR_NEW) {
327
+ jobRef.current = { ...job, pages: job.pages + 1 };
328
+ loadOlder();
329
+ return;
330
+ }
331
+ const where = locate(items, items[first].id, { foldEvents });
332
+ if (!where) return give();
333
+ jobRef.current = null;
334
+ if (first > 0) setDivider({ sessionId, block: where.block });
335
+ latest.current.release();
336
+ afterLayout(() => {
337
+ const mark = el.querySelector<HTMLElement>("[data-new-divider]");
338
+ (
339
+ mark ?? el.querySelector(`[data-block="${CSS.escape(where.block)}"]`)
340
+ )?.scrollIntoView({
341
+ block: "start",
342
+ });
343
+ latest.current.release();
344
+ st.current.settled = true;
345
+ el.dispatchEvent(new Event("scroll"));
346
+ });
347
+ }, [
348
+ items,
349
+ hasMore,
350
+ loadingOlder,
351
+ loadOlder,
352
+ foldEvents,
353
+ sessionId,
354
+ listRef,
355
+ place,
356
+ ]);
357
+ useEffect(() => {
358
+ advanceRef.current = advance;
359
+ advance();
360
+ }, [advance]);
361
+
362
+ return { newFrom: divider?.sessionId === sessionId ? divider.block : null };
363
+ }
@@ -0,0 +1,90 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+ import {
5
+ Dialog,
6
+ DialogContent,
7
+ DialogDescription,
8
+ DialogHeader,
9
+ DialogTitle,
10
+ } from "@/components/ui/dialog";
11
+ import { Input } from "@/components/ui/input";
12
+ import { CodeSearchResults } from "@/components/CodeSearch/CodeSearchResults";
13
+
14
+ interface CodeSearchDialogProps {
15
+ open: boolean;
16
+ onOpenChange: (open: boolean) => void;
17
+ onSelectFile?: (file: string, line: number) => void;
18
+ activeSessionWorkingDir?: string;
19
+ }
20
+
21
+ /** Search the code of the session on show (switching chats is the palette's Sessions tab). */
22
+ export function CodeSearchDialog({
23
+ open,
24
+ onOpenChange,
25
+ onSelectFile,
26
+ activeSessionWorkingDir,
27
+ }: CodeSearchDialogProps) {
28
+ const [query, setQuery] = useState("");
29
+ const inputRef = useRef<HTMLInputElement>(null);
30
+
31
+ const [wasOpen, setWasOpen] = useState(open);
32
+ if (open !== wasOpen) {
33
+ setWasOpen(open);
34
+ if (open) setQuery("");
35
+ }
36
+
37
+ useEffect(() => {
38
+ if (!open) return;
39
+ const t = setTimeout(() => inputRef.current?.focus(), 50);
40
+ return () => clearTimeout(t);
41
+ }, [open]);
42
+
43
+ const handleSelectFile = useCallback(
44
+ (file: string, line: number) => {
45
+ onOpenChange(false);
46
+ onSelectFile?.(file, line);
47
+ },
48
+ [onOpenChange, onSelectFile]
49
+ );
50
+
51
+ return (
52
+ <Dialog open={open} onOpenChange={onOpenChange}>
53
+ <DialogContent
54
+ showCloseButton={false}
55
+ className="gap-0 overflow-hidden p-0 sm:max-w-md"
56
+ >
57
+ <DialogHeader className="sr-only">
58
+ <DialogTitle>Search code</DialogTitle>
59
+ <DialogDescription>
60
+ Search the files of this session
61
+ </DialogDescription>
62
+ </DialogHeader>
63
+ <div className="border-border border-b p-3">
64
+ <Input
65
+ ref={inputRef}
66
+ placeholder="Search code (min 3 chars)..."
67
+ value={query}
68
+ onChange={(e) => setQuery(e.target.value)}
69
+ className="h-10"
70
+ />
71
+ </div>
72
+ <div className="max-h-[300px] overflow-y-auto py-2">
73
+ <CodeSearchResults
74
+ workingDirectory={activeSessionWorkingDir || "~"}
75
+ query={query}
76
+ onSelectFile={handleSelectFile}
77
+ />
78
+ </div>
79
+ <div className="border-border text-muted-foreground flex items-center gap-4 border-t px-4 py-2 text-xs">
80
+ <span>
81
+ <kbd className="bg-muted rounded px-1.5 py-0.5">↵</kbd> select
82
+ </span>
83
+ <span>
84
+ <kbd className="bg-muted rounded px-1.5 py-0.5">esc</kbd> close
85
+ </span>
86
+ </div>
87
+ </DialogContent>
88
+ </Dialog>
89
+ );
90
+ }
@@ -9,6 +9,7 @@ import {
9
9
  emptySections,
10
10
  withSeeAll,
11
11
  tabItems,
12
+ CURRENT,
12
13
  type Section,
13
14
  } from "@/lib/palette/sections";
14
15
  import { cycleTab, parsePrefix } from "@/lib/palette/tabs";
@@ -37,7 +38,8 @@ export function CommandsBody() {
37
38
  const tab = paletteUi.tab;
38
39
  const all = picker?.items ?? commands;
39
40
  const [query, setQuery] = useState(paletteUi.query);
40
- const [active, setActive] = useState(0);
41
+ // Null until the reader moves: the list then picks where to start.
42
+ const [active, setActive] = useState<number | null>(null);
41
43
  // The tab a `>` or `@` chose, which Backspace on an empty query undoes.
42
44
  const [prefixTab, setPrefixTab] = useState<PaletteTab | null>(null);
43
45
  const [usage, setUsage] = useState(loadUsage);
@@ -78,9 +80,15 @@ export function CommandsBody() {
78
80
  const [shownTab, setShownTab] = useState(tab);
79
81
  if (shownTab !== tab) {
80
82
  setShownTab(tab);
81
- setActive(0);
83
+ setActive(null);
82
84
  }
83
- const highlighted = Math.min(active, Math.max(results.length - 1, 0));
85
+ // A switcher opens on the chat before this one, so Enter flips back to it.
86
+ const flipBack =
87
+ tab === "sessions" && !q && !picker && results[0]?.hint === CURRENT;
88
+ const highlighted = Math.min(
89
+ active ?? (flipBack ? 1 : 0),
90
+ Math.max(results.length - 1, 0)
91
+ );
84
92
 
85
93
  useEffect(() => {
86
94
  listRef.current
@@ -104,7 +112,7 @@ export function CommandsBody() {
104
112
 
105
113
  const onChange = (value: string) => {
106
114
  const prefix = !picker && !query ? parsePrefix(value) : null;
107
- setActive(0);
115
+ setActive(null);
108
116
  if (prefix) {
109
117
  setQuery(prefix.text);
110
118
  setPrefixTab(prefix.tab);
@@ -40,6 +40,9 @@ import { useAddExistingProject } from "@/components/Projects/AddProject/useAddEx
40
40
  import { usageUiActions } from "@/stores/usageUi";
41
41
  import { settingsCommands } from "@/components/Settings/commands";
42
42
  import { DRAFT_KEYS } from "@/lib/drafts";
43
+ import { chatOpened } from "@/stores/chatPositions";
44
+ import { previousChatId, recentFirst } from "@/lib/nav/recent";
45
+ import { CURRENT } from "@/lib/palette/sections";
43
46
  import { useAwayCommands } from "@/components/Away/useAwayCommands";
44
47
 
45
48
  const LAST_THEME = "agentOS-last-theme-";
@@ -72,10 +75,12 @@ function sessionState(s: SessionStatus | undefined) {
72
75
  export function useAppCommands({
73
76
  sessions,
74
77
  statuses,
78
+ activeSessionId,
75
79
  onSelectSession,
76
80
  onSearchCode,
77
81
  }: {
78
82
  sessions: Session[];
83
+ activeSessionId?: string | null;
79
84
  statuses: Record<string, SessionStatus>;
80
85
  onSelectSession: (session: Session) => void;
81
86
  onSearchCode: () => void;
@@ -83,6 +88,7 @@ export function useAppCommands({
83
88
  const { theme, resolvedTheme, setTheme } = useTheme();
84
89
  const { data: workspaces = [] } = useWorkspacesQuery();
85
90
  const { workspaceId } = useSnapshot(sidebarUi);
91
+ const opened = useSnapshot(chatOpened);
86
92
  const current = workspaces.find((w) => w.id === workspaceId) ?? null;
87
93
  const dark = resolvedTheme?.startsWith("dark") ?? false;
88
94
  const { data: recipes = [] } = useRecipesQuery(workspaceId ?? null);
@@ -263,18 +269,30 @@ export function useAppCommands({
263
269
  usePaletteCommands("app", app);
264
270
  useAwayCommands();
265
271
 
272
+ const open = sessions.filter(
273
+ (s) => !s.archived_at && !s.conductor_session_id
274
+ );
275
+ const previousId = previousChatId(
276
+ open.map((s) => s.id),
277
+ opened,
278
+ activeSessionId
279
+ );
266
280
  usePaletteCommands(
267
281
  "sessions",
268
- sessions
269
- .filter((s) => !s.archived_at && !s.conductor_session_id)
270
- .map((s) => ({
271
- id: `session.${s.id}`,
272
- title: s.name,
273
- group: "Sessions",
274
- keywords: [s.working_directory, s.branch_name ?? ""].filter(Boolean),
275
- icon: MessageSquare,
276
- ...sessionState(statuses[s.id]),
277
- run: () => onSelectSession(s),
278
- }))
282
+ recentFirst(open, opened).map((s) => ({
283
+ id: `session.${s.id}`,
284
+ title: s.name,
285
+ group: "Sessions",
286
+ keywords: [s.working_directory, s.branch_name ?? ""].filter(Boolean),
287
+ hint:
288
+ s.id === activeSessionId
289
+ ? CURRENT
290
+ : s.id === previousId
291
+ ? "Previous"
292
+ : undefined,
293
+ icon: MessageSquare,
294
+ ...sessionState(statuses[s.id]),
295
+ run: () => onSelectSession(s),
296
+ }))
279
297
  );
280
298
  }
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { useState, useEffect, useRef } from "react";
3
+ import { useState, useEffect, useRef, useCallback } from "react";
4
4
  import { X, RefreshCw, Loader2 } from "lucide-react";
5
5
  import { cn } from "@/lib/utils";
6
6
 
@@ -20,31 +20,34 @@ export function ServerLogsModal({
20
20
  const [refreshing, setRefreshing] = useState(false);
21
21
  const logsRef = useRef<HTMLDivElement>(null);
22
22
 
23
- const fetchLogs = async (isRefresh = false) => {
24
- if (isRefresh) {
25
- setRefreshing(true);
26
- } else {
27
- setLoading(true);
28
- }
23
+ const fetchLogs = useCallback(
24
+ async (isRefresh = false) => {
25
+ if (isRefresh) {
26
+ setRefreshing(true);
27
+ } else {
28
+ setLoading(true);
29
+ }
29
30
 
30
- try {
31
- const res = await fetch(`/api/dev-servers/${serverId}/logs?lines=200`);
32
- if (res.ok) {
33
- const data = await res.json();
34
- setLogs(data.logs || []);
31
+ try {
32
+ const res = await fetch(`/api/dev-servers/${serverId}/logs?lines=200`);
33
+ if (res.ok) {
34
+ const data = await res.json();
35
+ setLogs(data.logs || []);
36
+ }
37
+ } catch (err) {
38
+ console.error("Failed to fetch logs:", err);
39
+ } finally {
40
+ setLoading(false);
41
+ setRefreshing(false);
35
42
  }
36
- } catch (err) {
37
- console.error("Failed to fetch logs:", err);
38
- } finally {
39
- setLoading(false);
40
- setRefreshing(false);
41
- }
42
- };
43
+ },
44
+ [serverId]
45
+ );
43
46
 
44
47
  // Initial fetch
45
48
  useEffect(() => {
46
49
  fetchLogs();
47
- }, [serverId]);
50
+ }, [fetchLogs]);
48
51
 
49
52
  // Auto-scroll to bottom when logs update
50
53
  useEffect(() => {
@@ -59,7 +62,7 @@ export function ServerLogsModal({
59
62
  fetchLogs(true);
60
63
  }, 3000);
61
64
  return () => clearInterval(interval);
62
- }, [serverId]);
65
+ }, [fetchLogs]);
63
66
 
64
67
  return (
65
68
  <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">