@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.
- 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/page.tsx +17 -14
- package/components/Chat/ChatPanel.tsx +46 -59
- package/components/Chat/NewDivider.tsx +15 -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/SessionList/useSessionSwitching.ts +3 -2
- 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 +2 -0
- package/components/Terminal/hooks/useTerminalConnection.ts +4 -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 +146 -61
- package/content/whats-new.json +32 -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/hooks/useChatViewsSync.ts +12 -0
- package/hooks/useNavActions.ts +104 -0
- package/hooks/useNavKeys.ts +105 -0
- 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/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/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/session-url.ts +11 -5
- package/package.json +1 -1
- 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-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/check +2 -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/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
|
-
|
|
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(
|
|
83
|
+
setActive(null);
|
|
82
84
|
}
|
|
83
|
-
|
|
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(
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
.
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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 =
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
23
|
+
const fetchLogs = useCallback(
|
|
24
|
+
async (isRefresh = false) => {
|
|
25
|
+
if (isRefresh) {
|
|
26
|
+
setRefreshing(true);
|
|
27
|
+
} else {
|
|
28
|
+
setLoading(true);
|
|
29
|
+
}
|
|
29
30
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
}
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
}, [
|
|
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
|
-
}, [
|
|
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">
|