@saadnvd1/agent-os 0.3.584 → 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 (54) 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/orchestrator/act.ts +47 -1
  42. package/lib/orchestrator/brief.ts +2 -1
  43. package/lib/orchestrator/serve.ts +8 -0
  44. package/lib/orchestrator/tool-names.ts +1 -0
  45. package/lib/orchestrator/tool-schemas.ts +22 -0
  46. package/lib/orchestrator/tools.ts +3 -1
  47. package/lib/tasks/finish.ts +26 -4
  48. package/lib/tasks/move-bundle.ts +28 -0
  49. package/lib/tasks/move-chat.ts +10 -1
  50. package/lib/tasks/queue.ts +133 -10
  51. package/lib/tasks/remote.ts +21 -3
  52. package/package.json +1 -1
  53. package/stores/bookmarksUi.ts +50 -0
  54. package/styles/themes.css +3 -0
@@ -0,0 +1,186 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { Loader2, Search } from "lucide-react";
5
+ import { useSnapshot } from "valtio";
6
+ import { cn } from "@/lib/utils";
7
+ import {
8
+ Dialog,
9
+ DialogContent,
10
+ DialogDescription,
11
+ DialogHeader,
12
+ DialogTitle,
13
+ } from "@/components/ui/dialog";
14
+ import { useBookmarks, type Bookmark } from "@/data/bookmarks";
15
+ import { useWorkspacesQuery } from "@/data/workspaces";
16
+ import { useUnarchiveSession } from "@/data/done";
17
+ import { toast } from "sonner";
18
+ import { hrefWithSession } from "@/lib/session-url";
19
+ import { bookmarksUi, bookmarksUiActions } from "@/stores/bookmarksUi";
20
+ import { BookmarkRow } from "./BookmarkRow";
21
+ import { EmptyBookmarks } from "./EmptyBookmarks";
22
+ import { groupByDay } from "./helpers";
23
+
24
+ // Opens the chat by its address, then takes it to the message.
25
+ function openChat(b: Bookmark) {
26
+ bookmarksUiActions.setOpen(false);
27
+ window.history.pushState(
28
+ null,
29
+ "",
30
+ hrefWithSession(location.href, b.sessionId)
31
+ );
32
+ window.dispatchEvent(new PopStateEvent("popstate"));
33
+ bookmarksUiActions.jump(b.sessionId, b.itemId);
34
+ }
35
+
36
+ // Every bookmark: one workspace's or all of them, searched on the server.
37
+ export function BookmarksDialog() {
38
+ const snap = useSnapshot(bookmarksUi);
39
+ // Widened to every workspace by hand; opening it again starts narrow.
40
+ const [all, setAll] = useState(false);
41
+ const [openedFor, setOpenedFor] = useState(snap.open);
42
+ if (snap.open !== openedFor) {
43
+ setOpenedFor(snap.open);
44
+ if (snap.open) setAll(false);
45
+ }
46
+ const [text, setText] = useState("");
47
+ const [q, setQ] = useState("");
48
+ useEffect(() => {
49
+ const t = setTimeout(() => setQ(text.trim()), 250);
50
+ return () => clearTimeout(t);
51
+ }, [text]);
52
+ const scope = all || !snap.workspaceId ? "all" : snap.workspaceId;
53
+ const list = useBookmarks(scope, q, snap.open);
54
+ const bookmarks = list.data?.pages.flatMap((p) => p.bookmarks) ?? [];
55
+ const { data: workspaces = [] } = useWorkspacesQuery();
56
+ const unarchive = useUnarchiveSession();
57
+ // An archived chat only opens from the sidebar, so it comes back first.
58
+ const openBookmark = (b: Bookmark) => {
59
+ if (b.state !== "archived") return openChat(b);
60
+ unarchive.mutate(b.sessionId, {
61
+ onSuccess: () => {
62
+ toast.success(`${b.sessionName} is back`);
63
+ openChat(b);
64
+ },
65
+ onError: (err) => toast.error(err.message),
66
+ });
67
+ };
68
+ const where = workspaces.find((w) => w.id === snap.workspaceId)?.name;
69
+
70
+ return (
71
+ <Dialog open={snap.open} onOpenChange={bookmarksUiActions.setOpen}>
72
+ <DialogContent className="max-h-[90vh] max-w-xl overflow-y-auto [&>*]:min-w-0">
73
+ <DialogHeader>
74
+ <DialogTitle>Bookmarks</DialogTitle>
75
+ <DialogDescription className="sr-only">
76
+ Messages starred in any chat. Open one to go to it.
77
+ </DialogDescription>
78
+ </DialogHeader>
79
+ <div className="flex items-center gap-2">
80
+ <label className="border-input bg-background text-muted-foreground flex h-11 min-w-0 flex-1 items-center gap-2 rounded-md border px-3 md:h-9">
81
+ <Search className="h-4 w-4 shrink-0" />
82
+ <input
83
+ value={text}
84
+ onChange={(e) => setText(e.target.value)}
85
+ placeholder="Search bookmarks"
86
+ aria-label="Search bookmarks"
87
+ className="text-foreground min-w-0 flex-1 bg-transparent text-base outline-none md:text-sm"
88
+ />
89
+ </label>
90
+ {snap.workspaceId && (
91
+ <div className="flex shrink-0 overflow-hidden rounded-md border text-xs">
92
+ {[
93
+ [false, where ?? "This workspace"],
94
+ [true, "All"],
95
+ ].map(([value, label]) => (
96
+ <button
97
+ key={String(value)}
98
+ type="button"
99
+ aria-pressed={all === value}
100
+ onClick={() => setAll(value as boolean)}
101
+ className={cn(
102
+ "text-muted-foreground max-w-36 truncate px-3 py-2 md:py-1.5",
103
+ all === value && "bg-accent text-foreground"
104
+ )}
105
+ >
106
+ {label}
107
+ </button>
108
+ ))}
109
+ </div>
110
+ )}
111
+ </div>
112
+ <div className="-mx-2">
113
+ {list.isPending &&
114
+ [0, 1, 2].map((i) => (
115
+ <div
116
+ key={i}
117
+ className="bg-muted/40 mx-2 mb-2 h-14 animate-pulse rounded-xl"
118
+ />
119
+ ))}
120
+ {list.isError && (
121
+ <p className="text-destructive px-2 text-sm">
122
+ {list.error.message}
123
+ </p>
124
+ )}
125
+ {!list.isPending &&
126
+ !list.isError &&
127
+ bookmarks.length === 0 &&
128
+ (q ? (
129
+ <div className="text-muted-foreground px-4 py-8 text-center text-sm">
130
+ No bookmarks match “{q}”
131
+ {scope === "all" ? "." : " in this workspace."}
132
+ {scope !== "all" && (
133
+ <button
134
+ type="button"
135
+ onClick={() => setAll(true)}
136
+ className="text-primary block min-h-11 w-full"
137
+ >
138
+ Search all workspaces
139
+ </button>
140
+ )}
141
+ </div>
142
+ ) : (
143
+ <EmptyBookmarks
144
+ title="No bookmarks yet"
145
+ body={
146
+ scope === "all"
147
+ ? "Messages you star in any chat collect here."
148
+ : "Messages you star in any chat in this workspace collect here."
149
+ }
150
+ />
151
+ ))}
152
+ {groupByDay(bookmarks).map((g) => (
153
+ <section key={g.day}>
154
+ <h3 className="text-muted-foreground px-2 pt-3 pb-1 font-mono text-[11px] tracking-wide uppercase">
155
+ {g.day}
156
+ </h3>
157
+ {g.bookmarks.map((b) => (
158
+ <BookmarkRow
159
+ key={b.id}
160
+ bookmark={b}
161
+ showSession
162
+ editing={snap.editing === b.id}
163
+ onOpen={openBookmark}
164
+ />
165
+ ))}
166
+ </section>
167
+ ))}
168
+ {list.hasNextPage && (
169
+ <button
170
+ type="button"
171
+ onClick={() => list.fetchNextPage()}
172
+ disabled={list.isFetchingNextPage}
173
+ className="text-muted-foreground hover:text-foreground flex min-h-11 w-full items-center justify-center text-sm"
174
+ >
175
+ {list.isFetchingNextPage ? (
176
+ <Loader2 className="h-4 w-4 animate-spin" />
177
+ ) : (
178
+ "Show older"
179
+ )}
180
+ </button>
181
+ )}
182
+ </div>
183
+ </DialogContent>
184
+ </Dialog>
185
+ );
186
+ }
@@ -0,0 +1,58 @@
1
+ "use client";
2
+
3
+ import { useSnapshot } from "valtio";
4
+ import {
5
+ Dialog,
6
+ DialogContent,
7
+ DialogDescription,
8
+ DialogHeader,
9
+ DialogTitle,
10
+ } from "@/components/ui/dialog";
11
+ import { useSessionBookmarks } from "@/data/bookmarks";
12
+ import { bookmarksUi, bookmarksUiActions } from "@/stores/bookmarksUi";
13
+ import { BookmarkRow } from "./BookmarkRow";
14
+ import { EmptyBookmarks } from "./EmptyBookmarks";
15
+
16
+ // One chat's bookmarks, in chat order; a tap takes the chat to the message.
17
+ export function ChatBookmarksDialog() {
18
+ const snap = useSnapshot(bookmarksUi);
19
+ const sessionId = snap.chatSessionId;
20
+ const { data = [], isPending } = useSessionBookmarks(sessionId);
21
+ return (
22
+ <Dialog
23
+ open={!!sessionId}
24
+ onOpenChange={(open) => !open && bookmarksUiActions.closeChat()}
25
+ >
26
+ <DialogContent className="max-h-[85vh] max-w-md overflow-y-auto [&>*]:min-w-0">
27
+ <DialogHeader>
28
+ <DialogTitle>Bookmarks in this chat</DialogTitle>
29
+ <DialogDescription className="sr-only">
30
+ The messages starred in this chat, in the order they were said.
31
+ </DialogDescription>
32
+ </DialogHeader>
33
+ <div className="-mx-2 space-y-0.5">
34
+ {isPending && !!sessionId && (
35
+ <div className="bg-muted/40 mx-2 h-14 animate-pulse rounded-xl" />
36
+ )}
37
+ {!isPending && data.length === 0 && (
38
+ <EmptyBookmarks
39
+ title="No bookmarks in this chat"
40
+ body="Star a message, a tool call or a turn to come back to it here. On a phone you can also hold a message."
41
+ />
42
+ )}
43
+ {data.map((b) => (
44
+ <BookmarkRow
45
+ key={b.id}
46
+ bookmark={b}
47
+ editing={snap.editing === b.id}
48
+ onOpen={() => {
49
+ bookmarksUiActions.closeChat();
50
+ bookmarksUiActions.jump(b.sessionId, b.itemId);
51
+ }}
52
+ />
53
+ ))}
54
+ </div>
55
+ </DialogContent>
56
+ </Dialog>
57
+ );
58
+ }
@@ -0,0 +1,38 @@
1
+ "use client";
2
+
3
+ import { Star } from "lucide-react";
4
+ import { cn } from "@/lib/utils";
5
+ import { useSessionBookmarks } from "@/data/bookmarks";
6
+ import { bookmarksUiActions } from "@/stores/bookmarksUi";
7
+
8
+ // The star in a chat's header: how many it has, and its list.
9
+ export function ChatBookmarksButton({
10
+ sessionId,
11
+ className,
12
+ }: {
13
+ sessionId: string;
14
+ className?: string;
15
+ }) {
16
+ const { data = [] } = useSessionBookmarks(sessionId);
17
+ return (
18
+ <button
19
+ type="button"
20
+ aria-label={`Bookmarks in this chat${data.length ? ` (${data.length})` : ""}`}
21
+ title="Bookmarks in this chat"
22
+ onClick={(e) => {
23
+ e.stopPropagation();
24
+ bookmarksUiActions.openChat(sessionId);
25
+ }}
26
+ className={cn(
27
+ "text-muted-foreground hover:text-foreground hover:bg-accent flex shrink-0 items-center justify-center gap-1 rounded-md",
28
+ data.length > 0 && "px-1.5",
29
+ className
30
+ )}
31
+ >
32
+ <Star className="h-3.5 w-3.5" />
33
+ {data.length > 0 && (
34
+ <span className="text-xs tabular-nums">{data.length}</span>
35
+ )}
36
+ </button>
37
+ );
38
+ }
@@ -0,0 +1,20 @@
1
+ import type { ReactNode } from "react";
2
+ import { Star } from "lucide-react";
3
+
4
+ export function EmptyBookmarks({
5
+ title,
6
+ body,
7
+ }: {
8
+ title: string;
9
+ body: ReactNode;
10
+ }) {
11
+ return (
12
+ <div className="text-muted-foreground px-4 py-8 text-center text-sm">
13
+ <div className="bg-muted mx-auto mb-3 flex h-9 w-9 items-center justify-center rounded-lg">
14
+ <Star className="h-4 w-4" />
15
+ </div>
16
+ <p className="text-foreground font-medium">{title}</p>
17
+ <p className="mt-0.5">{body}</p>
18
+ </div>
19
+ );
20
+ }
@@ -0,0 +1,41 @@
1
+ "use client";
2
+
3
+ import { createContext, useContext, useSyncExternalStore } from "react";
4
+
5
+ // Where each turn starts (a TurnEnd's bookmark marks the turn's first
6
+ // message). Kept outside React state so a streamed word redraws only the
7
+ // turn ends whose start changed, not every star.
8
+ export class TurnStarts {
9
+ private map = new Map<string, string>();
10
+ private listeners = new Set<() => void>();
11
+ set(map: Map<string, string>) {
12
+ this.map = map;
13
+ this.listeners.forEach((l) => l());
14
+ }
15
+ get = (turnEndId: string) => this.map.get(turnEndId) ?? null;
16
+ subscribe = (l: () => void) => {
17
+ this.listeners.add(l);
18
+ return () => this.listeners.delete(l);
19
+ };
20
+ }
21
+
22
+ // The chat the stars inside it belong to.
23
+ export interface BookmarkChat {
24
+ sessionId: string;
25
+ turnStarts: TurnStarts;
26
+ }
27
+
28
+ export const BookmarkChatContext = createContext<BookmarkChat | null>(null);
29
+
30
+ export const useBookmarkChat = () => useContext(BookmarkChatContext);
31
+
32
+ const NO_STARTS = new TurnStarts();
33
+
34
+ export function useTurnStart(turnEndId: string): string | null {
35
+ const starts = useBookmarkChat()?.turnStarts ?? NO_STARTS;
36
+ return useSyncExternalStore(
37
+ starts.subscribe,
38
+ () => starts.get(turnEndId),
39
+ () => null
40
+ );
41
+ }
@@ -0,0 +1,69 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { Bookmark } from "@/lib/bookmarks/types";
3
+ import {
4
+ STATE_BADGE,
5
+ canOpen,
6
+ createdAt,
7
+ excerpt,
8
+ groupByDay,
9
+ } from "./helpers";
10
+
11
+ const b = (createdAt: string, state: Bookmark["state"] = "live"): Bookmark => ({
12
+ id: createdAt,
13
+ sessionId: "s",
14
+ itemId: "i",
15
+ scope: "item",
16
+ kind: "assistant",
17
+ text: "t",
18
+ note: null,
19
+ sessionName: "n",
20
+ workspaceId: null,
21
+ createdAt,
22
+ state,
23
+ });
24
+
25
+ describe("bookmark helpers", () => {
26
+ it("reads SQLite's zoneless time as UTC", () => {
27
+ expect(createdAt(b("2026-10-14 09:30:00")).toISOString()).toBe(
28
+ "2026-10-14T09:30:00.000Z"
29
+ );
30
+ });
31
+
32
+ it("groups newest-first bookmarks by local day", () => {
33
+ const now = new Date(2026, 9, 14, 18);
34
+ const at = (d: Date) => d.toISOString();
35
+ const groups = groupByDay(
36
+ [
37
+ b(at(new Date(2026, 9, 14, 12))),
38
+ b(at(new Date(2026, 9, 14, 8))),
39
+ b(at(new Date(2026, 9, 13, 23))),
40
+ b(at(new Date(2026, 9, 9, 10))),
41
+ ],
42
+ now
43
+ );
44
+ expect(groups.map((g) => [g.day, g.bookmarks.length])).toEqual([
45
+ ["Today", 2],
46
+ ["Yesterday", 1],
47
+ [
48
+ new Date(2026, 9, 9).toLocaleDateString(undefined, {
49
+ month: "short",
50
+ day: "numeric",
51
+ }),
52
+ 1,
53
+ ],
54
+ ]);
55
+ });
56
+
57
+ it("badges archived and deleted chats, and won't open a deleted one", () => {
58
+ expect(STATE_BADGE.live).toBeNull();
59
+ expect(STATE_BADGE.archived).toBe("archived");
60
+ expect(STATE_BADGE.deleted).toBe("conversation deleted");
61
+ expect(canOpen(b("x", "deleted"))).toBe(false);
62
+ expect(canOpen(b("x", "archived"))).toBe(true);
63
+ });
64
+
65
+ it("flattens and clips an excerpt", () => {
66
+ expect(excerpt("a\n\n b")).toBe("a b");
67
+ expect(excerpt("x".repeat(300), 10)).toBe("xxxxxxxxx…");
68
+ });
69
+ });
@@ -0,0 +1,65 @@
1
+ import type { Bookmark, BookmarkState } from "@/lib/bookmarks/types";
2
+
3
+ export const KIND_LABEL: Record<Bookmark["kind"], string> = {
4
+ user: "You",
5
+ assistant: "Reply",
6
+ tool: "Tool",
7
+ turn: "Turn",
8
+ };
9
+
10
+ export const STATE_BADGE: Record<BookmarkState, string | null> = {
11
+ live: null,
12
+ moved: null,
13
+ archived: "archived",
14
+ deleted: "conversation deleted",
15
+ };
16
+
17
+ // A deleted chat has nothing to open.
18
+ export const canOpen = (b: Bookmark) => b.state !== "deleted";
19
+
20
+ // SQLite's datetime('now') is UTC without a zone.
21
+ export const createdAt = (b: Bookmark) =>
22
+ new Date(
23
+ /[zZ]|[+-]\d\d:?\d\d$/.test(b.createdAt)
24
+ ? b.createdAt
25
+ : b.createdAt.replace(" ", "T") + "Z"
26
+ );
27
+
28
+ export const excerpt = (text: string, max = 240) => {
29
+ const t = text.replace(/\s+/g, " ").trim();
30
+ return t.length > max ? t.slice(0, max - 1) + "…" : t;
31
+ };
32
+
33
+ function dayLabel(d: Date, now: Date): string {
34
+ const start = (x: Date) =>
35
+ new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
36
+ const days = Math.round((start(now) - start(d)) / 86_400_000);
37
+ if (days === 0) return "Today";
38
+ if (days === 1) return "Yesterday";
39
+ return d.toLocaleDateString(undefined, {
40
+ month: "short",
41
+ day: "numeric",
42
+ ...(d.getFullYear() !== now.getFullYear() && { year: "numeric" }),
43
+ });
44
+ }
45
+
46
+ /** Newest-first bookmarks, in runs by local day. */
47
+ export function groupByDay(
48
+ bookmarks: Bookmark[],
49
+ now = new Date()
50
+ ): { day: string; bookmarks: Bookmark[] }[] {
51
+ const out: { day: string; bookmarks: Bookmark[] }[] = [];
52
+ for (const b of bookmarks) {
53
+ const day = dayLabel(createdAt(b), now);
54
+ const last = out[out.length - 1];
55
+ if (last?.day === day) last.bookmarks.push(b);
56
+ else out.push({ day, bookmarks: [b] });
57
+ }
58
+ return out;
59
+ }
60
+
61
+ export const timeOf = (b: Bookmark) =>
62
+ createdAt(b).toLocaleTimeString(undefined, {
63
+ hour: "numeric",
64
+ minute: "2-digit",
65
+ });
@@ -0,0 +1,4 @@
1
+ export { BookmarkButton, TurnBookmark } from "./BookmarkButton";
2
+ export { BookmarksDialog } from "./BookmarksDialog";
3
+ export { ChatBookmarksButton } from "./ChatBookmarksButton";
4
+ export { ChatBookmarksDialog } from "./ChatBookmarks";
@@ -0,0 +1,51 @@
1
+ "use client";
2
+
3
+ import { useCallback, useMemo } from "react";
4
+ import { toast } from "sonner";
5
+ import { bookmarkKey, type BookmarkScope } from "@/lib/bookmarks/types";
6
+ import { useSessionBookmarks, useToggleBookmark } from "@/data/bookmarks";
7
+ import { bookmarksUiActions } from "@/stores/bookmarksUi";
8
+
9
+ // Whether an item is starred in its chat, and a toggle that says what it did
10
+ // with a way back.
11
+ export function useBookmarkToggle(
12
+ sessionId: string,
13
+ itemId: string | null,
14
+ scope: BookmarkScope
15
+ ) {
16
+ const { data } = useSessionBookmarks(sessionId);
17
+ const existing = useMemo(
18
+ () =>
19
+ (itemId &&
20
+ data?.find(
21
+ (b) => bookmarkKey(b.itemId, b.scope) === bookmarkKey(itemId, scope)
22
+ )) ||
23
+ null,
24
+ [data, itemId, scope]
25
+ );
26
+ const toggle = useToggleBookmark(sessionId);
27
+ const run = useCallback(() => {
28
+ if (!itemId) return;
29
+ toggle.mutate(
30
+ { itemId, scope, existing },
31
+ {
32
+ onSuccess: (made) => {
33
+ if (!made) return toast("Bookmark removed");
34
+ toast("Bookmarked", {
35
+ action: {
36
+ label: "Undo",
37
+ onClick: () => toggle.mutate({ itemId, scope, existing: made }),
38
+ },
39
+ cancel: {
40
+ label: "Add note",
41
+ onClick: () => bookmarksUiActions.openChat(sessionId, made.id),
42
+ },
43
+ classNames: { cancelButton: "!bg-muted !text-foreground" },
44
+ });
45
+ },
46
+ onError: (err) => toast.error(err.message),
47
+ }
48
+ );
49
+ }, [toggle, itemId, scope, existing, sessionId]);
50
+ return { on: !!existing, toggle: run };
51
+ }
@@ -65,6 +65,13 @@ import { useViewport } from "@/hooks/useViewport";
65
65
  import { useChatCommands } from "./useChatCommands";
66
66
  import { copySelection } from "./copy-selection";
67
67
  import { arrivalKey, useStickToBottom } from "./stick-to-bottom";
68
+ import { useSnapshot } from "valtio";
69
+ import {
70
+ BookmarkChatContext,
71
+ TurnStarts,
72
+ type BookmarkChat,
73
+ } from "@/components/Bookmarks/context";
74
+ import { bookmarksUi, bookmarksUiActions } from "@/stores/bookmarksUi";
68
75
  import {
69
76
  AssistantMessage,
70
77
  CommandOutput,
@@ -295,6 +302,7 @@ export function ChatPanel({
295
302
  requestFiles,
296
303
  search,
297
304
  compacting,
305
+ loadedFor,
298
306
  } = useChat(sessionId, (text) => setPrefill({ text, at: Date.now() }));
299
307
  const startFresh = useFreshStart();
300
308
  const { isMobile } = useViewport();
@@ -511,6 +519,37 @@ export function ChatPanel({
511
519
  scrollToBlock,
512
520
  foldEvents: !!orchestratorOf,
513
521
  });
522
+ // Where each turn starts, for a TurnEnd's "Bookmark turn".
523
+ const turnStarts = useMemo(() => {
524
+ const m = new Map<string, string>();
525
+ let user: string | null = null;
526
+ for (const i of items) {
527
+ if (i.kind === "user") user = i.id;
528
+ else if (i.kind === "turn_end" && user) m.set(i.id, user);
529
+ }
530
+ return m;
531
+ }, [items]);
532
+ // eslint-disable-next-line react-hooks/exhaustive-deps
533
+ const turnStartStore = useMemo(() => new TurnStarts(), [sessionId]);
534
+ useLayoutEffect(
535
+ () => turnStartStore.set(turnStarts),
536
+ [turnStartStore, turnStarts]
537
+ );
538
+ const bookmarkChat = useMemo<BookmarkChat>(
539
+ () => ({ sessionId, turnStarts: turnStartStore }),
540
+ [sessionId, turnStartStore]
541
+ );
542
+ // A bookmark opened from a list: once this chat is the one showing.
543
+ const { jump } = useSnapshot(bookmarksUi);
544
+ useEffect(() => {
545
+ // Waits for this chat's first page: until it lands, the items are
546
+ // the last chat's, and every jump would miss.
547
+ if (!jump || jump.sessionId !== sessionId || loadedFor !== sessionId)
548
+ return;
549
+ const itemId = bookmarksUiActions.takeJump(sessionId);
550
+ if (itemId)
551
+ goTo((all) => (all.some((i) => i.id === itemId) ? itemId : null));
552
+ }, [jump, sessionId, goTo, loadedFor]);
514
553
  const resolveQuestion = useResolveQuestion(orchestratorOf ?? "");
515
554
  // The question the next message answers, resolved when it is sent.
516
555
  const [replyTo, setReplyTo] = useState<{ id: number; text: string } | null>(
@@ -588,39 +627,41 @@ export function ChatPanel({
588
627
  <RowState.Provider value={rowState}>
589
628
  <Background.Provider value={background}>
590
629
  <ToolBodies.Provider value={bodies}>
591
- <div
592
- // A new conversation starts over, scrolled to the end.
593
- key={sessionId}
594
- ref={listRef}
595
- onScroll={onListScroll}
596
- onCopy={onCopy}
597
- className="h-full overflow-y-auto overscroll-y-contain"
598
- >
599
- <div className="mx-auto flex w-full max-w-3xl flex-col gap-4 px-4 pt-5 pb-4">
600
- {hasMore && (
601
- <div className="text-muted-foreground flex min-h-11 items-center justify-center text-xs">
602
- {loadingOlder ? (
603
- <Loader2 className="h-4 w-4 animate-spin" />
604
- ) : (
605
- <button
606
- type="button"
607
- onClick={loadOlder}
608
- className="hover:text-foreground min-h-11 px-3"
609
- >
610
- Show earlier messages
611
- </button>
612
- )}
613
- </div>
614
- )}
615
- <SetupCard sessionId={sessionId} />
616
- {blocks.length === 0 && !queue.length && (
617
- <p className="text-muted-foreground py-16 text-center text-sm">
618
- Ask anything to start.
619
- </p>
620
- )}
630
+ <BookmarkChatContext.Provider value={bookmarkChat}>
631
+ <div
632
+ // A new conversation starts over, scrolled to the end.
633
+ key={sessionId}
634
+ ref={listRef}
635
+ onScroll={onListScroll}
636
+ onCopy={onCopy}
637
+ className="h-full overflow-y-auto overscroll-y-contain"
638
+ >
639
+ <div className="mx-auto flex w-full max-w-3xl flex-col gap-4 px-4 pt-5 pb-4">
640
+ {hasMore && (
641
+ <div className="text-muted-foreground flex min-h-11 items-center justify-center text-xs">
642
+ {loadingOlder ? (
643
+ <Loader2 className="h-4 w-4 animate-spin" />
644
+ ) : (
645
+ <button
646
+ type="button"
647
+ onClick={loadOlder}
648
+ className="hover:text-foreground min-h-11 px-3"
649
+ >
650
+ Show earlier messages
651
+ </button>
652
+ )}
653
+ </div>
654
+ )}
655
+ <SetupCard sessionId={sessionId} />
656
+ {blocks.length === 0 && !queue.length && (
657
+ <p className="text-muted-foreground py-16 text-center text-sm">
658
+ Ask anything to start.
659
+ </p>
660
+ )}
661
+ </div>
662
+ <TimelineRows blocks={blocks} actions={actions} />
621
663
  </div>
622
- <TimelineRows blocks={blocks} actions={actions} />
623
- </div>
664
+ </BookmarkChatContext.Provider>
624
665
  </ToolBodies.Provider>
625
666
  </Background.Provider>
626
667
  </RowState.Provider>