@saadnvd1/agent-os 0.3.585 → 0.3.587

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 (47) hide show
  1. package/README.md +1 -0
  2. package/app/api/bookmarks/[id]/route.ts +35 -0
  3. package/app/api/bookmarks/route.test.ts +103 -0
  4. package/app/api/bookmarks/route.ts +18 -0
  5. package/app/api/sessions/[id]/bookmarks/route.ts +38 -0
  6. package/app/api/tasks/[id]/merge/route.ts +5 -1
  7. package/app/globals.css +1 -0
  8. package/app/page.tsx +16 -0
  9. package/components/Bookmarks/BookmarkButton.tsx +149 -0
  10. package/components/Bookmarks/BookmarkRow.tsx +161 -0
  11. package/components/Bookmarks/BookmarksDialog.tsx +186 -0
  12. package/components/Bookmarks/ChatBookmarks.tsx +58 -0
  13. package/components/Bookmarks/ChatBookmarksButton.tsx +38 -0
  14. package/components/Bookmarks/EmptyBookmarks.tsx +20 -0
  15. package/components/Bookmarks/context.ts +41 -0
  16. package/components/Bookmarks/helpers.test.ts +69 -0
  17. package/components/Bookmarks/helpers.ts +65 -0
  18. package/components/Bookmarks/index.ts +4 -0
  19. package/components/Bookmarks/useBookmarkToggle.ts +51 -0
  20. package/components/Chat/ChatPanel.tsx +73 -32
  21. package/components/Chat/Messages.test.ts +14 -9
  22. package/components/Chat/Messages.tsx +59 -43
  23. package/components/Chat/Tools.tsx +3 -1
  24. package/components/Chat/useLongPress.ts +43 -0
  25. package/components/CommandPalette/useAppCommands.ts +10 -0
  26. package/components/Pane/DesktopTabBar.tsx +10 -0
  27. package/components/Pane/MobileTabBar.tsx +10 -0
  28. package/components/Pane/index.tsx +8 -0
  29. package/components/SessionList/SidebarHeader.tsx +5 -0
  30. package/data/bookmarks/index.ts +2 -0
  31. package/data/bookmarks/keys.ts +7 -0
  32. package/data/bookmarks/queries.ts +146 -0
  33. package/data/chat/useChat.ts +4 -0
  34. package/data/push/topics.ts +3 -0
  35. package/lib/bookmarks/body.ts +23 -0
  36. package/lib/bookmarks/snapshot.ts +33 -0
  37. package/lib/bookmarks/store.ts +333 -0
  38. package/lib/bookmarks/types.ts +38 -0
  39. package/lib/db/changes.ts +3 -0
  40. package/lib/db/migrations.ts +28 -0
  41. package/lib/tasks/finish.ts +26 -4
  42. package/lib/tasks/move-bundle.ts +28 -0
  43. package/lib/tasks/move-chat.ts +10 -1
  44. package/lib/tasks/remote.ts +21 -3
  45. package/package.json +1 -1
  46. package/stores/bookmarksUi.ts +50 -0
  47. 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 el = AssistantMessage({ item, onQuote }) as never as {
26
- props: { children: unknown[] };
27
- };
28
- const group = (
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
- <div className="group flex flex-col items-end gap-1">
98
- {item.from && (
99
- <span data-copy-skip className="label-mono text-muted-foreground">
100
- from {item.from}
101
- </span>
102
- )}
103
- {item.images?.length ? (
104
- <div data-copy-skip className="flex flex-wrap justify-end gap-1.5">
105
- {item.images.map((img, i) => (
106
- <ImageThumb
107
- key={i}
108
- src={`data:${img.mediaType};base64,${img.data}`}
109
- />
110
- ))}
111
- </div>
112
- ) : null}
113
- {item.text && (
114
- <div className="bg-foreground/[0.07] max-w-[85%] rounded-2xl rounded-br-md px-3.5 py-2 text-sm whitespace-pre-wrap">
115
- <UserText text={item.text} />
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
- {onUndo && <UndoButton onClick={onUndo} />}
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
- <div className="group">
132
- <div data-quotable className={cn(item.streaming && "streaming-cursor")}>
133
- <Markdown text={item.text} streaming={item.streaming} />
134
- </div>
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
- </div>
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>
@@ -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,2 @@
1
+ export * from "./keys";
2
+ export * from "./queries";
@@ -0,0 +1,7 @@
1
+ export const bookmarkKeys = {
2
+ all: ["bookmarks"] as const,
3
+ session: (sessionId: string) =>
4
+ [...bookmarkKeys.all, "session", sessionId] as const,
5
+ list: (workspace: string, q: string) =>
6
+ [...bookmarkKeys.all, "list", workspace, q] as const,
7
+ };
@@ -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
+ }
@@ -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,
@@ -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
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The text a bookmark keeps of what it marks: readable, clipped, and with
3
+ * secrets masked so a key in a tool's output doesn't sit in a second table.
4
+ */
5
+
6
+ import type { ChatItem } from "../chat/events";
7
+ import { toRaw } from "../share/snapshot";
8
+ import { NO_CONTEXT, redactForShare } from "../share/redact";
9
+ import { envSecrets } from "../share/context";
10
+ import { MAX_SNAPSHOT_CHARS } from "./types";
11
+
12
+ function plain(item: ChatItem): string {
13
+ const raw = toRaw(item);
14
+ if (!raw) return "";
15
+ return raw.label ? `${raw.label}\n${raw.text}` : raw.text;
16
+ }
17
+
18
+ // A turn reads as what was asked and the start of the answer.
19
+ export function snapshotText(item: ChatItem, reply?: ChatItem): string {
20
+ const text = [plain(item), reply ? plain(reply) : ""]
21
+ .filter((t) => t.trim())
22
+ .join("\n\n")
23
+ .trim();
24
+ // Redacted before the cut, so a secret the cut splits is still caught; the
25
+ // wide first cut only bounds the work on a huge tool output.
26
+ const safe = redactForShare(text.slice(0, MAX_SNAPSHOT_CHARS * 4), {
27
+ ...NO_CONTEXT,
28
+ secrets: envSecrets(),
29
+ }).text;
30
+ return safe.length > MAX_SNAPSHOT_CHARS
31
+ ? safe.slice(0, MAX_SNAPSHOT_CHARS) + "…"
32
+ : safe;
33
+ }