@saadnvd1/agent-os 0.3.585 → 0.3.588
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/README.md +1 -0
- package/app/api/bookmarks/[id]/route.ts +35 -0
- package/app/api/bookmarks/route.test.ts +103 -0
- package/app/api/bookmarks/route.ts +18 -0
- package/app/api/sessions/[id]/bookmarks/route.ts +38 -0
- package/app/api/tasks/[id]/merge/route.ts +5 -1
- package/app/globals.css +1 -0
- package/app/page.tsx +16 -0
- package/components/Bookmarks/BookmarkButton.tsx +149 -0
- package/components/Bookmarks/BookmarkRow.tsx +161 -0
- package/components/Bookmarks/BookmarksDialog.tsx +186 -0
- package/components/Bookmarks/ChatBookmarks.tsx +58 -0
- package/components/Bookmarks/ChatBookmarksButton.tsx +38 -0
- package/components/Bookmarks/EmptyBookmarks.tsx +20 -0
- package/components/Bookmarks/context.ts +41 -0
- package/components/Bookmarks/helpers.test.ts +69 -0
- package/components/Bookmarks/helpers.ts +65 -0
- package/components/Bookmarks/index.ts +4 -0
- package/components/Bookmarks/useBookmarkToggle.ts +51 -0
- package/components/Chat/ChatPanel.tsx +73 -32
- package/components/Chat/Messages.test.ts +14 -9
- package/components/Chat/Messages.tsx +59 -43
- package/components/Chat/Tools.tsx +3 -1
- package/components/Chat/composer/ComposerBody.tsx +13 -5
- package/components/Chat/useLongPress.ts +43 -0
- package/components/CommandPalette/useAppCommands.ts +10 -0
- package/components/Pane/DesktopTabBar.tsx +10 -0
- package/components/Pane/MobileTabBar.tsx +10 -0
- package/components/Pane/index.tsx +8 -0
- package/components/SessionList/SidebarHeader.tsx +5 -0
- package/data/bookmarks/index.ts +2 -0
- package/data/bookmarks/keys.ts +7 -0
- package/data/bookmarks/queries.ts +146 -0
- package/data/chat/useChat.ts +4 -0
- package/data/push/topics.ts +3 -0
- package/lib/bookmarks/body.ts +23 -0
- package/lib/bookmarks/snapshot.ts +33 -0
- package/lib/bookmarks/store.ts +333 -0
- package/lib/bookmarks/types.ts +38 -0
- package/lib/chat/commands.ts +9 -5
- package/lib/db/changes.ts +3 -0
- package/lib/db/migrations.ts +28 -0
- package/lib/tasks/finish.ts +26 -4
- package/lib/tasks/move-bundle.ts +28 -0
- package/lib/tasks/move-chat.ts +10 -1
- package/lib/tasks/remote.ts +21 -3
- package/package.json +1 -1
- package/stores/bookmarksUi.ts +50 -0
- package/styles/themes.css +3 -0
|
@@ -10,6 +10,16 @@ const item = {
|
|
|
10
10
|
streaming: false,
|
|
11
11
|
} as never;
|
|
12
12
|
|
|
13
|
+
// The element with this aria-label, anywhere in an unrendered tree.
|
|
14
|
+
function findByLabel(node: unknown, label: string): unknown {
|
|
15
|
+
if (Array.isArray(node))
|
|
16
|
+
return node.map((n) => findByLabel(n, label)).find(Boolean);
|
|
17
|
+
const props = (node as { props?: Record<string, unknown> } | null)?.props;
|
|
18
|
+
if (!props) return undefined;
|
|
19
|
+
if (props["aria-label"] === label) return node;
|
|
20
|
+
return findByLabel(props.children, label);
|
|
21
|
+
}
|
|
22
|
+
|
|
13
23
|
describe("AssistantMessage quote action", () => {
|
|
14
24
|
it("shows a labelled Quote button next to Copy", () => {
|
|
15
25
|
const html = renderToStaticMarkup(
|
|
@@ -22,15 +32,10 @@ describe("AssistantMessage quote action", () => {
|
|
|
22
32
|
|
|
23
33
|
it("hands the markdown source to onQuote on click", () => {
|
|
24
34
|
const onQuote = vi.fn();
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
el.props.children as { props: { children: unknown[] } }[]
|
|
30
|
-
)[1];
|
|
31
|
-
const button = group.props.children[1] as {
|
|
32
|
-
props: { onClick: () => void };
|
|
33
|
-
};
|
|
35
|
+
const button = findByLabel(
|
|
36
|
+
AssistantMessage({ item, onQuote }),
|
|
37
|
+
"Quote message"
|
|
38
|
+
) as { props: { onClick: () => void } };
|
|
34
39
|
button.props.onClick();
|
|
35
40
|
expect(onQuote).toHaveBeenCalledWith("Hello\n\n```ts\nx\n```");
|
|
36
41
|
});
|
|
@@ -21,6 +21,11 @@ import { Markdown } from "./Markdown";
|
|
|
21
21
|
import { openSessionLink } from "./session-link";
|
|
22
22
|
import { UndoButton } from "./Undo";
|
|
23
23
|
import { useRowState } from "./rowState";
|
|
24
|
+
import {
|
|
25
|
+
BookmarkButton,
|
|
26
|
+
TurnBookmark,
|
|
27
|
+
BookmarkPress,
|
|
28
|
+
} from "@/components/Bookmarks/BookmarkButton";
|
|
24
29
|
|
|
25
30
|
type Of<K extends ChatItem["kind"]> = Extract<ChatItem, { kind: K }>;
|
|
26
31
|
|
|
@@ -94,29 +99,34 @@ export function UserMessage({
|
|
|
94
99
|
onUndo?: () => void;
|
|
95
100
|
}) {
|
|
96
101
|
return (
|
|
97
|
-
<
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
<
|
|
102
|
+
<BookmarkPress itemId={item.id}>
|
|
103
|
+
<div className="group flex flex-col items-end gap-1">
|
|
104
|
+
{item.from && (
|
|
105
|
+
<span data-copy-skip className="label-mono text-muted-foreground">
|
|
106
|
+
from {item.from}
|
|
107
|
+
</span>
|
|
108
|
+
)}
|
|
109
|
+
{item.images?.length ? (
|
|
110
|
+
<div data-copy-skip className="flex flex-wrap justify-end gap-1.5">
|
|
111
|
+
{item.images.map((img, i) => (
|
|
112
|
+
<ImageThumb
|
|
113
|
+
key={i}
|
|
114
|
+
src={`data:${img.mediaType};base64,${img.data}`}
|
|
115
|
+
/>
|
|
116
|
+
))}
|
|
117
|
+
</div>
|
|
118
|
+
) : null}
|
|
119
|
+
{item.text && (
|
|
120
|
+
<div className="bg-foreground/[0.07] max-w-[85%] rounded-2xl rounded-br-md px-3.5 py-2 text-sm whitespace-pre-wrap">
|
|
121
|
+
<UserText text={item.text} />
|
|
122
|
+
</div>
|
|
123
|
+
)}
|
|
124
|
+
<div className="-mr-1.5 flex items-center">
|
|
125
|
+
{onUndo && <UndoButton onClick={onUndo} />}
|
|
126
|
+
<BookmarkButton itemId={item.id} />
|
|
116
127
|
</div>
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
</div>
|
|
128
|
+
</div>
|
|
129
|
+
</BookmarkPress>
|
|
120
130
|
);
|
|
121
131
|
}
|
|
122
132
|
|
|
@@ -128,27 +138,32 @@ export function AssistantMessage({
|
|
|
128
138
|
onQuote?: (text: string) => void;
|
|
129
139
|
}) {
|
|
130
140
|
return (
|
|
131
|
-
<
|
|
132
|
-
<div
|
|
133
|
-
<
|
|
134
|
-
|
|
135
|
-
{!item.streaming && (
|
|
136
|
-
<div className="-ml-1.5 flex items-center opacity-40 group-hover:opacity-100 md:opacity-0">
|
|
137
|
-
<CopyButton text={item.text} label="Copy message" />
|
|
138
|
-
{onQuote && (
|
|
139
|
-
<button
|
|
140
|
-
type="button"
|
|
141
|
-
aria-label="Quote message"
|
|
142
|
-
title="Quote"
|
|
143
|
-
onClick={() => onQuote(item.text)}
|
|
144
|
-
className="text-muted-foreground hover:text-foreground hover:bg-foreground/[0.06] flex h-9 w-9 items-center justify-center rounded-md md:h-7 md:w-7"
|
|
145
|
-
>
|
|
146
|
-
<Quote className="h-3.5 w-3.5" />
|
|
147
|
-
</button>
|
|
148
|
-
)}
|
|
141
|
+
<BookmarkPress itemId={item.id}>
|
|
142
|
+
<div className="group">
|
|
143
|
+
<div data-quotable className={cn(item.streaming && "streaming-cursor")}>
|
|
144
|
+
<Markdown text={item.text} streaming={item.streaming} />
|
|
149
145
|
</div>
|
|
150
|
-
|
|
151
|
-
|
|
146
|
+
{!item.streaming && (
|
|
147
|
+
<div className="-ml-1.5 flex items-center">
|
|
148
|
+
<div className="flex items-center opacity-40 group-hover:opacity-100 md:opacity-0">
|
|
149
|
+
<CopyButton text={item.text} label="Copy message" />
|
|
150
|
+
{onQuote && (
|
|
151
|
+
<button
|
|
152
|
+
type="button"
|
|
153
|
+
aria-label="Quote message"
|
|
154
|
+
title="Quote"
|
|
155
|
+
onClick={() => onQuote(item.text)}
|
|
156
|
+
className="text-muted-foreground hover:text-foreground hover:bg-foreground/[0.06] flex h-9 w-9 items-center justify-center rounded-md md:h-7 md:w-7"
|
|
157
|
+
>
|
|
158
|
+
<Quote className="h-3.5 w-3.5" />
|
|
159
|
+
</button>
|
|
160
|
+
)}
|
|
161
|
+
</div>
|
|
162
|
+
<BookmarkButton itemId={item.id} />
|
|
163
|
+
</div>
|
|
164
|
+
)}
|
|
165
|
+
</div>
|
|
166
|
+
</BookmarkPress>
|
|
152
167
|
);
|
|
153
168
|
}
|
|
154
169
|
|
|
@@ -207,9 +222,10 @@ export function TurnEnd({ item }: { item: Of<"turn_end"> }) {
|
|
|
207
222
|
? `Done in ${secs}s`
|
|
208
223
|
: "Done";
|
|
209
224
|
return (
|
|
210
|
-
<div className="text-muted-foreground/60 flex items-center gap-3 font-mono text-[11px]">
|
|
225
|
+
<div className="group text-muted-foreground/60 flex items-center gap-3 font-mono text-[11px]">
|
|
211
226
|
<span className="bg-foreground/[0.06] h-px flex-1" />
|
|
212
227
|
{label}
|
|
228
|
+
<TurnBookmark turnEndId={item.id} />
|
|
213
229
|
<span className="bg-foreground/[0.06] h-px flex-1" />
|
|
214
230
|
</div>
|
|
215
231
|
);
|
|
@@ -12,6 +12,7 @@ import { Highlighted } from "./Code";
|
|
|
12
12
|
import { BackgroundButton, BackgroundState, useBackground } from "./background";
|
|
13
13
|
import { useNow } from "./useNow";
|
|
14
14
|
import { useRowState } from "./rowState";
|
|
15
|
+
import { BookmarkButton } from "@/components/Bookmarks/BookmarkButton";
|
|
15
16
|
|
|
16
17
|
// Output and diffs of tool calls sent without them (lib/chat/page), loaded
|
|
17
18
|
// when one is opened.
|
|
@@ -152,7 +153,7 @@ function ToolRow({ tool }: { tool: ToolItem }) {
|
|
|
152
153
|
);
|
|
153
154
|
return (
|
|
154
155
|
<div data-item={tool.id}>
|
|
155
|
-
<div className="hover:bg-foreground/[0.04] flex items-center gap-1 rounded-lg pr-1">
|
|
156
|
+
<div className="group hover:bg-foreground/[0.04] flex items-center gap-1 rounded-lg pr-1">
|
|
156
157
|
<button
|
|
157
158
|
type="button"
|
|
158
159
|
onClick={() => setOpen(!open)}
|
|
@@ -169,6 +170,7 @@ function ToolRow({ tool }: { tool: ToolItem }) {
|
|
|
169
170
|
)}
|
|
170
171
|
</button>
|
|
171
172
|
<BackgroundButton tool={tool} />
|
|
173
|
+
<BookmarkButton itemId={tool.id} />
|
|
172
174
|
</div>
|
|
173
175
|
{open && <ToolDetail tool={tool} />}
|
|
174
176
|
</div>
|
|
@@ -163,7 +163,8 @@ export function ComposerBody({
|
|
|
163
163
|
insertMarkdown(editor, content, insert.append);
|
|
164
164
|
}, [insert, editor]);
|
|
165
165
|
|
|
166
|
-
const
|
|
166
|
+
const slash = slashQuery(caret.before);
|
|
167
|
+
const query = slash?.query ?? null;
|
|
167
168
|
const mentions = useMentions(editor, caret, query === null, requestFiles);
|
|
168
169
|
const matches = useMemo(() => {
|
|
169
170
|
if (query === null) return [];
|
|
@@ -173,8 +174,9 @@ export function ComposerBody({
|
|
|
173
174
|
];
|
|
174
175
|
return rankCommands(query, all);
|
|
175
176
|
}, [query, commands, onSetModel]);
|
|
176
|
-
// One menu at a time: "/" commands
|
|
177
|
-
const menuKey =
|
|
177
|
+
// One menu at a time: "/" commands or "@" files at the caret.
|
|
178
|
+
const menuKey =
|
|
179
|
+
query !== null ? `/${caret.pos}:${caret.before}` : mentions.key;
|
|
178
180
|
const menuOpen = menuKey !== null && dismissed !== menuKey;
|
|
179
181
|
const options: MenuOption[] =
|
|
180
182
|
query !== null
|
|
@@ -205,12 +207,18 @@ export function ComposerBody({
|
|
|
205
207
|
);
|
|
206
208
|
|
|
207
209
|
const pickCommand = (command: ChatCommand) => {
|
|
210
|
+
if (!editor || !slash) return;
|
|
211
|
+
// The "/" and what follows it, up to the caret.
|
|
212
|
+
const from = caret.pos - (caret.before.length - slash.start);
|
|
213
|
+
const chain = editor.chain().focus().deleteRange({ from, to: caret.pos });
|
|
208
214
|
if (command === MODEL_COMMAND) {
|
|
209
|
-
|
|
215
|
+
chain.run();
|
|
210
216
|
setModelOpen(true);
|
|
211
217
|
return;
|
|
212
218
|
}
|
|
213
|
-
|
|
219
|
+
chain
|
|
220
|
+
.insertContent({ type: "text", text: insertCommand(command.name) })
|
|
221
|
+
.run();
|
|
214
222
|
};
|
|
215
223
|
const pick = (index: number) => {
|
|
216
224
|
if (query !== null) {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, type PointerEvent } from "react";
|
|
4
|
+
|
|
5
|
+
const HOLD_MS = 500;
|
|
6
|
+
const SLOP_PX = 10;
|
|
7
|
+
|
|
8
|
+
// A touch held still on an element. Moving the finger (a scroll) cancels it;
|
|
9
|
+
// a mouse never triggers it.
|
|
10
|
+
export function useLongPress(onLongPress: (() => void) | null) {
|
|
11
|
+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
12
|
+
const start = useRef<{ x: number; y: number } | null>(null);
|
|
13
|
+
const cancel = () => {
|
|
14
|
+
if (timer.current) clearTimeout(timer.current);
|
|
15
|
+
timer.current = null;
|
|
16
|
+
start.current = null;
|
|
17
|
+
};
|
|
18
|
+
useEffect(
|
|
19
|
+
() => () => {
|
|
20
|
+
if (timer.current) clearTimeout(timer.current);
|
|
21
|
+
},
|
|
22
|
+
[]
|
|
23
|
+
);
|
|
24
|
+
if (!onLongPress) return {};
|
|
25
|
+
return {
|
|
26
|
+
onPointerDown: (e: PointerEvent) => {
|
|
27
|
+
if (e.pointerType !== "touch") return;
|
|
28
|
+
cancel();
|
|
29
|
+
start.current = { x: e.clientX, y: e.clientY };
|
|
30
|
+
timer.current = setTimeout(() => {
|
|
31
|
+
cancel();
|
|
32
|
+
navigator.vibrate?.(10);
|
|
33
|
+
onLongPress();
|
|
34
|
+
}, HOLD_MS);
|
|
35
|
+
},
|
|
36
|
+
onPointerMove: (e: PointerEvent) => {
|
|
37
|
+
const s = start.current;
|
|
38
|
+
if (s && Math.hypot(e.clientX - s.x, e.clientY - s.y) > SLOP_PX) cancel();
|
|
39
|
+
},
|
|
40
|
+
onPointerUp: cancel,
|
|
41
|
+
onPointerCancel: cancel,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -4,6 +4,7 @@ import { useSnapshot } from "valtio";
|
|
|
4
4
|
import { useTheme } from "next-themes";
|
|
5
5
|
import {
|
|
6
6
|
Archive,
|
|
7
|
+
Star,
|
|
7
8
|
ChefHat,
|
|
8
9
|
Code,
|
|
9
10
|
FolderGit2,
|
|
@@ -27,6 +28,7 @@ import { useRecipesQuery } from "@/data/recipes";
|
|
|
27
28
|
import { recipesUiActions } from "@/stores/recipesUi";
|
|
28
29
|
import { usePaletteCommands } from "@/hooks/usePaletteCommands";
|
|
29
30
|
import { archivedUiActions } from "@/stores/archivedUi";
|
|
31
|
+
import { bookmarksUiActions } from "@/stores/bookmarksUi";
|
|
30
32
|
import { busUiActions } from "@/stores/busUi";
|
|
31
33
|
import { orchestratorOpenActions } from "@/stores/orchestratorOpen";
|
|
32
34
|
import { sidebarUi, sidebarUiActions } from "@/stores/sidebarUi";
|
|
@@ -209,6 +211,14 @@ export function useAppCommands({
|
|
|
209
211
|
icon: Archive,
|
|
210
212
|
run: () => archivedUiActions.open(),
|
|
211
213
|
},
|
|
214
|
+
{
|
|
215
|
+
id: "app.bookmarks",
|
|
216
|
+
title: "Bookmarks",
|
|
217
|
+
group: "Go to",
|
|
218
|
+
keywords: ["starred", "saved", "favourite", "favorite"],
|
|
219
|
+
icon: Star,
|
|
220
|
+
run: () => bookmarksUiActions.open(current?.id ?? null),
|
|
221
|
+
},
|
|
212
222
|
...settingsCommands(current?.id ?? null),
|
|
213
223
|
{
|
|
214
224
|
id: "app.theme",
|
|
@@ -16,6 +16,7 @@ import { PaneViewToggle } from "./PaneViewToggle";
|
|
|
16
16
|
import { PaneMenu } from "./PaneMenu";
|
|
17
17
|
import { HostBadge } from "@/components/Hosts/HostBadge";
|
|
18
18
|
import { usePaneBarSlots } from "./PaneBarSlots";
|
|
19
|
+
import { ChatBookmarksButton } from "@/components/Bookmarks/ChatBookmarksButton";
|
|
19
20
|
|
|
20
21
|
type ViewMode = "terminal" | "files" | "git" | "workers" | "log";
|
|
21
22
|
|
|
@@ -52,6 +53,8 @@ interface DesktopTabBarProps {
|
|
|
52
53
|
onSplitVertical: () => void;
|
|
53
54
|
onClose: () => void;
|
|
54
55
|
onDetach: () => void;
|
|
56
|
+
// The chat on show, for its bookmarks; null for a terminal or another view.
|
|
57
|
+
bookmarksSessionId?: string | null;
|
|
55
58
|
}
|
|
56
59
|
|
|
57
60
|
export function DesktopTabBar({
|
|
@@ -80,6 +83,7 @@ export function DesktopTabBar({
|
|
|
80
83
|
onSplitVertical,
|
|
81
84
|
onClose,
|
|
82
85
|
onDetach,
|
|
86
|
+
bookmarksSessionId,
|
|
83
87
|
}: DesktopTabBarProps) {
|
|
84
88
|
const { leading, trailing } = usePaneBarSlots(paneId);
|
|
85
89
|
|
|
@@ -160,6 +164,12 @@ export function DesktopTabBar({
|
|
|
160
164
|
<ContextMeter sessionId={session?.id} />
|
|
161
165
|
<SessionPrStage sessionId={session?.id} />
|
|
162
166
|
<ViewSwitch session={session} />
|
|
167
|
+
{bookmarksSessionId && (
|
|
168
|
+
<ChatBookmarksButton
|
|
169
|
+
sessionId={bookmarksSessionId}
|
|
170
|
+
className="h-7 min-w-7"
|
|
171
|
+
/>
|
|
172
|
+
)}
|
|
163
173
|
{session?.working_directory && (
|
|
164
174
|
<PaneViewToggle
|
|
165
175
|
viewMode={viewMode}
|
|
@@ -37,6 +37,7 @@ import type { LucideIcon } from "lucide-react";
|
|
|
37
37
|
import { HostBadge } from "@/components/Hosts/HostBadge";
|
|
38
38
|
import { shareUiActions } from "@/stores/shareUi";
|
|
39
39
|
import { MoveMenuItems } from "@/components/Tasks/MoveMenuItems";
|
|
40
|
+
import { ChatBookmarksButton } from "@/components/Bookmarks/ChatBookmarksButton";
|
|
40
41
|
|
|
41
42
|
type ViewMode = "terminal" | "files" | "git" | "workers" | "log";
|
|
42
43
|
|
|
@@ -90,6 +91,8 @@ interface MobileTabBarProps {
|
|
|
90
91
|
onSelectSession?: (sessionId: string) => void;
|
|
91
92
|
// Opens find in the chat on show (there's no ⌘F on a phone).
|
|
92
93
|
onFind?: () => void;
|
|
94
|
+
// The chat on show, for its bookmarks.
|
|
95
|
+
bookmarksSessionId?: string | null;
|
|
93
96
|
}
|
|
94
97
|
|
|
95
98
|
export function MobileTabBar({
|
|
@@ -105,6 +108,7 @@ export function MobileTabBar({
|
|
|
105
108
|
onViewModeChange,
|
|
106
109
|
onSelectSession,
|
|
107
110
|
onFind,
|
|
111
|
+
bookmarksSessionId,
|
|
108
112
|
}: MobileTabBarProps) {
|
|
109
113
|
const away = useAwayQuery().data?.away ?? null;
|
|
110
114
|
// Find current session index and calculate prev/next
|
|
@@ -301,6 +305,12 @@ export function MobileTabBar({
|
|
|
301
305
|
</button>
|
|
302
306
|
</div>
|
|
303
307
|
|
|
308
|
+
{bookmarksSessionId && (
|
|
309
|
+
<ChatBookmarksButton
|
|
310
|
+
sessionId={bookmarksSessionId}
|
|
311
|
+
className="-my-1.5 h-11 min-w-11"
|
|
312
|
+
/>
|
|
313
|
+
)}
|
|
304
314
|
{onFind && (
|
|
305
315
|
<Button
|
|
306
316
|
variant="ghost"
|
|
@@ -173,6 +173,12 @@ export const Pane = memo(function Pane({
|
|
|
173
173
|
return s?.view === "chat" ? s : null;
|
|
174
174
|
};
|
|
175
175
|
|
|
176
|
+
// The chat on show in the main view, whose bookmarks the header lists.
|
|
177
|
+
const bookmarksSessionId =
|
|
178
|
+
activeTab && viewMode === "terminal"
|
|
179
|
+
? (chatSessionFor(activeTab)?.id ?? null)
|
|
180
|
+
: null;
|
|
181
|
+
|
|
176
182
|
// File editor state - lifted here so it persists across view switches
|
|
177
183
|
const fileEditor = useFileEditor();
|
|
178
184
|
|
|
@@ -432,6 +438,7 @@ export const Pane = memo(function Pane({
|
|
|
432
438
|
onMenuClick={onMenuClick}
|
|
433
439
|
onViewModeChange={setViewMode}
|
|
434
440
|
onSelectSession={onSelectSession}
|
|
441
|
+
bookmarksSessionId={bookmarksSessionId}
|
|
435
442
|
onFind={
|
|
436
443
|
activeTab && viewMode === "terminal" && chatSessionFor(activeTab)
|
|
437
444
|
? () => openFind(activeTab.sessionId as string)
|
|
@@ -465,6 +472,7 @@ export const Pane = memo(function Pane({
|
|
|
465
472
|
onSplitVertical={() => splitVertical(paneId)}
|
|
466
473
|
onClose={() => close(paneId)}
|
|
467
474
|
onDetach={handleDetach}
|
|
475
|
+
bookmarksSessionId={bookmarksSessionId}
|
|
468
476
|
/>
|
|
469
477
|
)}
|
|
470
478
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
Archive,
|
|
5
|
+
Star,
|
|
5
6
|
BookOpen,
|
|
6
7
|
ChefHat,
|
|
7
8
|
Clock,
|
|
@@ -29,6 +30,7 @@ import {
|
|
|
29
30
|
import { Button } from "@/components/ui/button";
|
|
30
31
|
import type { Workspace } from "@/lib/db";
|
|
31
32
|
import { archivedUiActions } from "@/stores/archivedUi";
|
|
33
|
+
import { bookmarksUiActions } from "@/stores/bookmarksUi";
|
|
32
34
|
import { tasksUiActions } from "@/stores/tasksUi";
|
|
33
35
|
import { newDraft } from "@/stores/drafts";
|
|
34
36
|
import { schedulesUiActions } from "@/stores/schedulesUi";
|
|
@@ -120,6 +122,9 @@ export function SidebarHeader(props: SidebarHeaderProps) {
|
|
|
120
122
|
menuItem("Archived", () => archivedUiActions.open(), {
|
|
121
123
|
icon: Archive,
|
|
122
124
|
}),
|
|
125
|
+
menuItem("Bookmarks", () => bookmarksUiActions.open(workspaceId), {
|
|
126
|
+
icon: Star,
|
|
127
|
+
}),
|
|
123
128
|
menuItem("Usage", () => usageUiActions.open(), { icon: Gauge }),
|
|
124
129
|
menuItem("Model comparison", () => usageUiActions.open("compare"), {
|
|
125
130
|
icon: Scale,
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useInfiniteQuery,
|
|
3
|
+
useMutation,
|
|
4
|
+
useQuery,
|
|
5
|
+
useQueryClient,
|
|
6
|
+
} from "@tanstack/react-query";
|
|
7
|
+
import {
|
|
8
|
+
bookmarkKey,
|
|
9
|
+
type Bookmark,
|
|
10
|
+
type BookmarkScope,
|
|
11
|
+
type BookmarksPage,
|
|
12
|
+
} from "@/lib/bookmarks/types";
|
|
13
|
+
import { bookmarkKeys } from "./keys";
|
|
14
|
+
|
|
15
|
+
export type {
|
|
16
|
+
Bookmark,
|
|
17
|
+
BookmarkScope,
|
|
18
|
+
BookmarkState,
|
|
19
|
+
BookmarksPage,
|
|
20
|
+
} from "@/lib/bookmarks/types";
|
|
21
|
+
|
|
22
|
+
async function json<T>(res: Response): Promise<T> {
|
|
23
|
+
const data = await res.json().catch(() => ({}));
|
|
24
|
+
if (!res.ok) throw new Error(data.error || "Request failed");
|
|
25
|
+
return data;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const send = (url: string, method: string, body?: unknown) =>
|
|
29
|
+
fetch(url, {
|
|
30
|
+
method,
|
|
31
|
+
headers: { "Content-Type": "application/json" },
|
|
32
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/** One chat's bookmarks, in chat order: what its stars and list read. */
|
|
36
|
+
export function useSessionBookmarks(sessionId: string | null | undefined) {
|
|
37
|
+
return useQuery({
|
|
38
|
+
queryKey: bookmarkKeys.session(sessionId ?? ""),
|
|
39
|
+
queryFn: async () =>
|
|
40
|
+
(
|
|
41
|
+
await json<{ bookmarks: Bookmark[] }>(
|
|
42
|
+
await fetch(`/api/sessions/${sessionId}/bookmarks`)
|
|
43
|
+
)
|
|
44
|
+
).bookmarks,
|
|
45
|
+
enabled: !!sessionId,
|
|
46
|
+
staleTime: 30_000,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Bookmarks across chats, a page at a time. `workspace`: an id or "all". */
|
|
51
|
+
export function useBookmarks(workspace: string, q: string, enabled = true) {
|
|
52
|
+
return useInfiniteQuery({
|
|
53
|
+
queryKey: bookmarkKeys.list(workspace, q),
|
|
54
|
+
queryFn: async ({ pageParam }) => {
|
|
55
|
+
const p = new URLSearchParams({ workspace, q });
|
|
56
|
+
if (pageParam) p.set("before", pageParam);
|
|
57
|
+
return json<BookmarksPage>(await fetch(`/api/bookmarks?${p}`));
|
|
58
|
+
},
|
|
59
|
+
initialPageParam: null as string | null,
|
|
60
|
+
getNextPageParam: (last) => last.next,
|
|
61
|
+
enabled,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Stars or unstars an item. The star flips at once; the server's answer
|
|
67
|
+
* (or the change topic) settles it.
|
|
68
|
+
*/
|
|
69
|
+
export function useToggleBookmark(sessionId: string) {
|
|
70
|
+
const qc = useQueryClient();
|
|
71
|
+
const key = bookmarkKeys.session(sessionId);
|
|
72
|
+
return useMutation({
|
|
73
|
+
mutationFn: async (v: {
|
|
74
|
+
itemId: string;
|
|
75
|
+
scope: BookmarkScope;
|
|
76
|
+
existing: Bookmark | null;
|
|
77
|
+
}) => {
|
|
78
|
+
if (v.existing) {
|
|
79
|
+
await json(await send(`/api/bookmarks/${v.existing.id}`, "DELETE"));
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
return (
|
|
83
|
+
await json<{ bookmark: Bookmark }>(
|
|
84
|
+
await send(`/api/sessions/${sessionId}/bookmarks`, "POST", {
|
|
85
|
+
itemId: v.itemId,
|
|
86
|
+
scope: v.scope,
|
|
87
|
+
})
|
|
88
|
+
)
|
|
89
|
+
).bookmark;
|
|
90
|
+
},
|
|
91
|
+
onMutate: async (v) => {
|
|
92
|
+
await qc.cancelQueries({ queryKey: key });
|
|
93
|
+
const before = qc.getQueryData<Bookmark[]>(key);
|
|
94
|
+
const k = bookmarkKey(v.itemId, v.scope);
|
|
95
|
+
qc.setQueryData<Bookmark[]>(key, (list = []) =>
|
|
96
|
+
v.existing
|
|
97
|
+
? list.filter((b) => bookmarkKey(b.itemId, b.scope) !== k)
|
|
98
|
+
: [...list, placeholder(sessionId, v.itemId, v.scope)]
|
|
99
|
+
);
|
|
100
|
+
return { before };
|
|
101
|
+
},
|
|
102
|
+
onError: (_e, _v, ctx) => qc.setQueryData(key, ctx?.before),
|
|
103
|
+
onSettled: () => qc.invalidateQueries({ queryKey: bookmarkKeys.all }),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Stands in for a new bookmark until the server's comes back.
|
|
108
|
+
const placeholder = (
|
|
109
|
+
sessionId: string,
|
|
110
|
+
itemId: string,
|
|
111
|
+
scope: BookmarkScope
|
|
112
|
+
): Bookmark => ({
|
|
113
|
+
id: `pending:${itemId}:${scope}`,
|
|
114
|
+
sessionId,
|
|
115
|
+
itemId,
|
|
116
|
+
scope,
|
|
117
|
+
kind: scope === "turn" ? "turn" : "user",
|
|
118
|
+
text: "",
|
|
119
|
+
note: null,
|
|
120
|
+
sessionName: "",
|
|
121
|
+
workspaceId: null,
|
|
122
|
+
createdAt: new Date().toISOString(),
|
|
123
|
+
state: "live",
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
export function useSetBookmarkNote() {
|
|
127
|
+
const qc = useQueryClient();
|
|
128
|
+
return useMutation({
|
|
129
|
+
mutationFn: async (v: { id: string; note: string | null }) =>
|
|
130
|
+
(
|
|
131
|
+
await json<{ bookmark: Bookmark }>(
|
|
132
|
+
await send(`/api/bookmarks/${v.id}`, "PATCH", { note: v.note })
|
|
133
|
+
)
|
|
134
|
+
).bookmark,
|
|
135
|
+
onSettled: () => qc.invalidateQueries({ queryKey: bookmarkKeys.all }),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function useRemoveBookmark() {
|
|
140
|
+
const qc = useQueryClient();
|
|
141
|
+
return useMutation({
|
|
142
|
+
mutationFn: async (id: string) =>
|
|
143
|
+
json<{ ok: true }>(await send(`/api/bookmarks/${id}`, "DELETE")),
|
|
144
|
+
onSettled: () => qc.invalidateQueries({ queryKey: bookmarkKeys.all }),
|
|
145
|
+
});
|
|
146
|
+
}
|
package/data/chat/useChat.ts
CHANGED
|
@@ -143,6 +143,8 @@ export function useChat(
|
|
|
143
143
|
const [state, setState] = useState<ChatState>("idle");
|
|
144
144
|
const [compacting, setCompacting] = useState<Compacting | null>(null);
|
|
145
145
|
const [connected, setConnected] = useState(false);
|
|
146
|
+
// The chat whose snapshot the items are, once one has landed.
|
|
147
|
+
const [loadedFor, setLoadedFor] = useState<string | null>(null);
|
|
146
148
|
const [commands, setCommands] = useState<ChatCommand[]>([]);
|
|
147
149
|
const [models, setModels] = useState<ChatModel[]>([]);
|
|
148
150
|
const [model, setModelState] = useState("");
|
|
@@ -232,6 +234,7 @@ export function useChat(
|
|
|
232
234
|
setCompacting(m.compacting ?? null);
|
|
233
235
|
setQueue(m.queue ?? []);
|
|
234
236
|
setSuggestion(m.suggestion ?? null);
|
|
237
|
+
setLoadedFor(sessionId);
|
|
235
238
|
} else if (m.type === "queue") {
|
|
236
239
|
setQueue(m.queue);
|
|
237
240
|
} else if (m.type === "suggestion") {
|
|
@@ -509,6 +512,7 @@ export function useChat(
|
|
|
509
512
|
|
|
510
513
|
return {
|
|
511
514
|
items,
|
|
515
|
+
loadedFor,
|
|
512
516
|
hasMore,
|
|
513
517
|
loadingOlder,
|
|
514
518
|
loadOlder,
|
package/data/push/topics.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { recipeKeys } from "../recipes";
|
|
|
16
16
|
import { featureKeys } from "../features";
|
|
17
17
|
import { archivedKeys } from "../done";
|
|
18
18
|
import { awayKeys } from "../away";
|
|
19
|
+
import { bookmarkKeys } from "../bookmarks/keys";
|
|
19
20
|
|
|
20
21
|
// What each pushed topic (a table that changed, lib/db/changes.ts, or a
|
|
21
22
|
// pushed view) means the browser should refetch. Only queries on screen
|
|
@@ -72,6 +73,8 @@ function keysFor(topic: string): QueryKey[] {
|
|
|
72
73
|
return [awayKeys.all];
|
|
73
74
|
case "dev_servers":
|
|
74
75
|
return [devServerKeys.all];
|
|
76
|
+
case "chat_bookmarks":
|
|
77
|
+
return [bookmarkKeys.all];
|
|
75
78
|
default:
|
|
76
79
|
return [];
|
|
77
80
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { readCapped } from "../security/read-capped";
|
|
3
|
+
|
|
4
|
+
// An item id, a scope and a note of at most MAX_NOTE_CHARS, with room over.
|
|
5
|
+
export const MAX_BOOKMARK_BODY_BYTES = 8192;
|
|
6
|
+
|
|
7
|
+
/** A bookmark route's JSON body, or the response that refuses it. */
|
|
8
|
+
export async function readBookmarkBody(
|
|
9
|
+
request: Request
|
|
10
|
+
): Promise<Record<string, unknown> | NextResponse> {
|
|
11
|
+
const raw = await readCapped(request, MAX_BOOKMARK_BODY_BYTES);
|
|
12
|
+
if (raw === null)
|
|
13
|
+
return NextResponse.json({ error: "Too large" }, { status: 413 });
|
|
14
|
+
let body: unknown = null;
|
|
15
|
+
try {
|
|
16
|
+
body = JSON.parse(raw);
|
|
17
|
+
} catch {
|
|
18
|
+
// Not JSON: refused below.
|
|
19
|
+
}
|
|
20
|
+
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
21
|
+
return NextResponse.json({ error: "Bad request" }, { status: 400 });
|
|
22
|
+
return body as Record<string, unknown>;
|
|
23
|
+
}
|