@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
package/README.md CHANGED
@@ -20,6 +20,7 @@ and check on all of it from your phone.
20
20
  - **Built for your phone.** The whole app works on a phone; pair it with a QR code. [More](docs/guides/phone.md)
21
21
  - **Agents that talk to each other.** Sessions message each other with `aos`, and you see every conversation in Messages. [More](docs/guides/agent-network.md)
22
22
  - **Chat or terminal, same conversation.** Streaming replies, steps and diffs, plan mode, slash commands, `@file`, undo. [More](docs/guides/chat.md)
23
+ - **Bookmarks.** Star any message, tool call or turn, then find it again from the chat or across every chat, with a search. [More](docs/guides/chat.md)
23
24
  - **Share a chat or a page.** A read-only snapshot link with secrets, home paths and machine names masked, a preview before anything leaves your machine, and a Markdown copy to hand to an agent. [More](docs/guides/sharing.md)
24
25
  - **Project config.** `agentos.json` gives each session its own ports, environment, files and a private Postgres copy. [More](docs/reference/project-config.md)
25
26
  - **Schedules and recipes.** Start work on a timer, or save a job that comes up again. [More](docs/guides/schedules.md)
@@ -0,0 +1,35 @@
1
+ import { NextResponse } from "next/server";
2
+ import {
3
+ BookmarkError,
4
+ removeBookmark,
5
+ setBookmarkNote,
6
+ } from "@/lib/bookmarks/store";
7
+ import { readBookmarkBody } from "@/lib/bookmarks/body";
8
+
9
+ type Ctx = { params: Promise<{ id: string }> };
10
+
11
+ const notFound = () =>
12
+ NextResponse.json({ error: "Bookmark not found" }, { status: 404 });
13
+
14
+ // PATCH /api/bookmarks/:id { note } - set or clear (null) its note.
15
+ export async function PATCH(request: Request, { params }: Ctx) {
16
+ const { id } = await params;
17
+ const body = await readBookmarkBody(request);
18
+ if (body instanceof NextResponse) return body;
19
+ if (!("note" in body))
20
+ return NextResponse.json({ error: "note is required" }, { status: 400 });
21
+ try {
22
+ const bookmark = setBookmarkNote(id, body.note);
23
+ return bookmark ? NextResponse.json({ bookmark }) : notFound();
24
+ } catch (err) {
25
+ if (err instanceof BookmarkError)
26
+ return NextResponse.json({ error: err.message }, { status: err.status });
27
+ throw err;
28
+ }
29
+ }
30
+
31
+ // DELETE /api/bookmarks/:id - unstar.
32
+ export async function DELETE(_request: Request, { params }: Ctx) {
33
+ const { id } = await params;
34
+ return removeBookmark(id) ? NextResponse.json({ ok: true }) : notFound();
35
+ }
@@ -0,0 +1,103 @@
1
+ import { randomUUID } from "crypto";
2
+ import { describe, expect, it } from "vitest";
3
+ import { getDb } from "@/lib/db";
4
+ import type { ChatItem } from "@/lib/chat/events";
5
+ import { saveItem } from "@/lib/chat/store";
6
+ import { MAX_BOOKMARK_BODY_BYTES } from "@/lib/bookmarks/body";
7
+ import * as sessionRoute from "../sessions/[id]/bookmarks/route";
8
+ import * as oneRoute from "./[id]/route";
9
+ import * as listRoute from "./route";
10
+
11
+ function chat() {
12
+ const id = randomUUID();
13
+ getDb()
14
+ .prepare(
15
+ `INSERT INTO sessions (id, name, tmux_name, working_directory) VALUES (?, 'routes', ?, '/tmp')`
16
+ )
17
+ .run(id, `claude-${id}`);
18
+ const item = (i: object) => {
19
+ const full = { id: randomUUID(), createdAt: 1, ...i } as ChatItem;
20
+ saveItem(id, full);
21
+ return full.id;
22
+ };
23
+ return {
24
+ id,
25
+ user: item({ kind: "user", text: "find the albatross" }),
26
+ reasoning: item({ kind: "reasoning", text: "thinking" }),
27
+ };
28
+ }
29
+
30
+ const ctx = (id: string) => ({ params: Promise.resolve({ id }) });
31
+ const req = (body?: unknown, raw?: string) =>
32
+ new Request("http://localhost/x", {
33
+ method: "POST",
34
+ body: raw ?? (body === undefined ? undefined : JSON.stringify(body)),
35
+ });
36
+ const post = (id: string, body?: unknown, raw?: string) =>
37
+ sessionRoute.POST(req(body, raw), ctx(id));
38
+
39
+ describe("bookmark routes", () => {
40
+ it("creates (201), returns the same one again (200), lists it", async () => {
41
+ const c = chat();
42
+ const made = await post(c.id, { itemId: c.user });
43
+ expect(made.status).toBe(201);
44
+ const { bookmark } = await made.json();
45
+ const again = await post(c.id, { itemId: c.user });
46
+ expect(again.status).toBe(200);
47
+ expect((await again.json()).bookmark.id).toBe(bookmark.id);
48
+ const list = await sessionRoute.GET(req(), ctx(c.id));
49
+ expect(
50
+ (await list.json()).bookmarks.map((b: { id: string }) => b.id)
51
+ ).toEqual([bookmark.id]);
52
+ const all = await listRoute.GET(
53
+ new Request("http://localhost/api/bookmarks?workspace=all&q=albatross")
54
+ );
55
+ expect((await all.json()).bookmarks[0].id).toBe(bookmark.id);
56
+ });
57
+
58
+ it("refuses a bad body (400) and what isn't there (404)", async () => {
59
+ const c = chat();
60
+ expect((await post(c.id, undefined, "not json")).status).toBe(400);
61
+ expect((await post(c.id, {})).status).toBe(400);
62
+ expect((await post(c.id, { itemId: c.reasoning })).status).toBe(400);
63
+ expect((await post(c.id, { itemId: c.user, scope: "x" })).status).toBe(400);
64
+ expect((await post(c.id, { itemId: "missing" })).status).toBe(404);
65
+ expect((await post(randomUUID(), { itemId: c.user })).status).toBe(404);
66
+ });
67
+
68
+ it("refuses a body past its cap (413) on POST and PATCH", async () => {
69
+ const c = chat();
70
+ const huge = { itemId: c.user, note: "x".repeat(MAX_BOOKMARK_BODY_BYTES) };
71
+ expect((await post(c.id, huge)).status).toBe(413);
72
+ const { bookmark } = await (await post(c.id, { itemId: c.user })).json();
73
+ const patched = await oneRoute.PATCH(
74
+ new Request("http://localhost/x", {
75
+ method: "PATCH",
76
+ body: JSON.stringify({ note: "x".repeat(MAX_BOOKMARK_BODY_BYTES) }),
77
+ }),
78
+ ctx(bookmark.id)
79
+ );
80
+ expect(patched.status).toBe(413);
81
+ });
82
+
83
+ it("sets a note, deletes once, and 404s a missing id", async () => {
84
+ const c = chat();
85
+ const { bookmark } = await (await post(c.id, { itemId: c.user })).json();
86
+ const patch = (id: string, body: unknown) =>
87
+ oneRoute.PATCH(
88
+ new Request("http://localhost/x", {
89
+ method: "PATCH",
90
+ body: JSON.stringify(body),
91
+ }),
92
+ ctx(id)
93
+ );
94
+ const noted = await patch(bookmark.id, { note: "seabird" });
95
+ expect((await noted.json()).bookmark.note).toBe("seabird");
96
+ expect((await patch(bookmark.id, {})).status).toBe(400);
97
+ expect((await patch(bookmark.id, { note: 3 })).status).toBe(400);
98
+ expect((await patch(randomUUID(), { note: "x" })).status).toBe(404);
99
+ const del = () => oneRoute.DELETE(req(), ctx(bookmark.id));
100
+ expect((await del()).status).toBe(200);
101
+ expect((await del()).status).toBe(404);
102
+ });
103
+ });
@@ -0,0 +1,18 @@
1
+ import { NextResponse } from "next/server";
2
+ import { listBookmarks } from "@/lib/bookmarks/store";
3
+
4
+ // GET /api/bookmarks?workspace=<id|all|none>&q=&before=&limit= - bookmarks
5
+ // across chats, newest first, a page at a time (`next` is the next `before`).
6
+ export async function GET(request: Request) {
7
+ const url = new URL(request.url);
8
+ const ws = url.searchParams.get("workspace") ?? "all";
9
+ const limit = Number(url.searchParams.get("limit") ?? 50);
10
+ return NextResponse.json(
11
+ listBookmarks({
12
+ workspace: ws === "none" ? null : ws,
13
+ q: url.searchParams.get("q") ?? undefined,
14
+ before: url.searchParams.get("before"),
15
+ limit: Number.isFinite(limit) ? limit : 50,
16
+ })
17
+ );
18
+ }
@@ -0,0 +1,38 @@
1
+ import { NextResponse } from "next/server";
2
+ import {
3
+ BookmarkError,
4
+ createBookmark,
5
+ listSessionBookmarks,
6
+ } from "@/lib/bookmarks/store";
7
+ import { readBookmarkBody } from "@/lib/bookmarks/body";
8
+
9
+ type Ctx = { params: Promise<{ id: string }> };
10
+
11
+ // GET /api/sessions/:id/bookmarks - this chat's bookmarks, in chat order.
12
+ // A deleted chat's still list: they outlive it.
13
+ export async function GET(_request: Request, { params }: Ctx) {
14
+ const { id } = await params;
15
+ return NextResponse.json({ bookmarks: listSessionBookmarks(id) });
16
+ }
17
+
18
+ // POST /api/sessions/:id/bookmarks { itemId, scope?, note? } - bookmark one
19
+ // item, or (scope 'turn') the turn its user message starts. 201 when made,
20
+ // 200 with the one already there.
21
+ export async function POST(request: Request, { params }: Ctx) {
22
+ const { id } = await params;
23
+ const body = await readBookmarkBody(request);
24
+ if (body instanceof NextResponse) return body;
25
+ try {
26
+ const { bookmark, created } = createBookmark({
27
+ sessionId: id,
28
+ itemId: body.itemId,
29
+ scope: body.scope,
30
+ note: body.note,
31
+ });
32
+ return NextResponse.json({ bookmark }, { status: created ? 201 : 200 });
33
+ } catch (err) {
34
+ if (err instanceof BookmarkError)
35
+ return NextResponse.json({ error: err.message }, { status: err.status });
36
+ throw err;
37
+ }
38
+ }
@@ -1,6 +1,7 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { signOffTask } from "@/lib/tasks";
3
3
  import { callingDevice } from "@/lib/security/device-me";
4
+ import { isBranchName } from "@/lib/git";
4
5
  import { heldRefusal } from "@/lib/tasks/held";
5
6
 
6
7
  // head: merge only if the PR is still at this commit; the answer names the
@@ -12,15 +13,18 @@ export async function POST(
12
13
  const id = (await params).id;
13
14
  const held = heldRefusal(id, callingDevice(request));
14
15
  if (held) return NextResponse.json({ error: held }, { status: 403 });
15
- const { head } = await request.json().catch(() => ({}));
16
+ const { head, base } = await request.json().catch(() => ({}));
16
17
  if (
17
18
  head !== undefined &&
18
19
  !(typeof head === "string" && /^[0-9a-f]{40}$/.test(head))
19
20
  )
20
21
  return NextResponse.json({ error: "Bad head commit" }, { status: 400 });
22
+ if (base !== undefined && !(typeof base === "string" && isBranchName(base)))
23
+ return NextResponse.json({ error: "Bad base branch" }, { status: 400 });
21
24
  try {
22
25
  const method = await signOffTask(id, {
23
26
  head,
27
+ base,
24
28
  device: callingDevice(request),
25
29
  });
26
30
  return NextResponse.json({ success: true, head: head ?? null, method });
package/app/globals.css CHANGED
@@ -10,6 +10,7 @@
10
10
  @theme inline {
11
11
  --color-background: hsl(var(--background));
12
12
  --color-foreground: hsl(var(--foreground));
13
+ --color-bookmark: hsl(var(--bookmark));
13
14
  --font-sans: var(--font-geist-sans);
14
15
  --font-mono: var(--font-geist-mono);
15
16
 
package/app/page.tsx CHANGED
@@ -91,6 +91,20 @@ const MessagesDialog = dynamic(
91
91
  () => import("@/components/Bus/MessagesDialog").then((m) => m.MessagesDialog),
92
92
  { ssr: false }
93
93
  );
94
+ const BookmarksDialog = dynamic(
95
+ () =>
96
+ import("@/components/Bookmarks/BookmarksDialog").then(
97
+ (m) => m.BookmarksDialog
98
+ ),
99
+ { ssr: false }
100
+ );
101
+ const ChatBookmarksDialog = dynamic(
102
+ () =>
103
+ import("@/components/Bookmarks/ChatBookmarks").then(
104
+ (m) => m.ChatBookmarksDialog
105
+ ),
106
+ { ssr: false }
107
+ );
94
108
  const ArchivedDialog = dynamic(
95
109
  () =>
96
110
  import("@/components/Archived/ArchivedDialog").then(
@@ -143,6 +157,8 @@ const AppDialogs = memo(function AppDialogs({
143
157
  <AddProjectDialog />
144
158
  <MessagesDialog />
145
159
  <ArchivedDialog />
160
+ <BookmarksDialog />
161
+ <ChatBookmarksDialog />
146
162
  <CleanupDialog />
147
163
  <LumifyHubDialogs />
148
164
  <UsageDialog />
@@ -0,0 +1,149 @@
1
+ "use client";
2
+
3
+ import type { ReactNode } from "react";
4
+ import { Star } from "lucide-react";
5
+ import { cn } from "@/lib/utils";
6
+ import type { BookmarkScope } from "@/lib/bookmarks/types";
7
+ import { useBookmarkChat, useTurnStart } from "./context";
8
+ import { useBookmarkToggle } from "./useBookmarkToggle";
9
+ import { useLongPress } from "@/components/Chat/useLongPress";
10
+
11
+ // Holding a message on a phone stars it. Outside a chat panel (a share, a
12
+ // test) it's just its children.
13
+ export function BookmarkPress({
14
+ itemId,
15
+ children,
16
+ }: {
17
+ itemId: string;
18
+ children: ReactNode;
19
+ }) {
20
+ const chat = useBookmarkChat();
21
+ if (!chat) return children;
22
+ return (
23
+ <Press sessionId={chat.sessionId} itemId={itemId}>
24
+ {children}
25
+ </Press>
26
+ );
27
+ }
28
+
29
+ function Press({
30
+ sessionId,
31
+ itemId,
32
+ children,
33
+ }: {
34
+ sessionId: string;
35
+ itemId: string;
36
+ children: ReactNode;
37
+ }) {
38
+ const { toggle } = useBookmarkToggle(sessionId, itemId, "item");
39
+ const press = useLongPress(toggle);
40
+ return (
41
+ <div className="contents" {...press}>
42
+ {children}
43
+ </div>
44
+ );
45
+ }
46
+
47
+ export const STAR_ON = "fill-bookmark text-bookmark";
48
+
49
+ // The star in a message's action row. Unset, it shows as its row does (on
50
+ // hover on a desktop, dimmed on a phone); set, it always shows, so a
51
+ // bookmarked message is seen while scrolling.
52
+ export function BookmarkButton({
53
+ itemId,
54
+ scope = "item",
55
+ className,
56
+ }: {
57
+ itemId: string;
58
+ scope?: BookmarkScope;
59
+ className?: string;
60
+ }) {
61
+ const chat = useBookmarkChat();
62
+ if (!chat) return null;
63
+ return (
64
+ <StarToggle
65
+ sessionId={chat.sessionId}
66
+ itemId={itemId}
67
+ scope={scope}
68
+ className={className}
69
+ />
70
+ );
71
+ }
72
+
73
+ function StarToggle({
74
+ sessionId,
75
+ itemId,
76
+ scope,
77
+ className,
78
+ }: {
79
+ sessionId: string;
80
+ itemId: string;
81
+ scope: BookmarkScope;
82
+ className?: string;
83
+ }) {
84
+ const { on, toggle } = useBookmarkToggle(sessionId, itemId, scope);
85
+ return (
86
+ <button
87
+ type="button"
88
+ aria-label={on ? "Remove bookmark" : "Bookmark"}
89
+ aria-pressed={on}
90
+ title={on ? "Remove bookmark" : "Bookmark"}
91
+ data-bookmarked={on || undefined}
92
+ onClick={(e) => {
93
+ e.stopPropagation();
94
+ toggle();
95
+ }}
96
+ className={cn(
97
+ "text-muted-foreground hover:text-foreground hover:bg-foreground/[0.06] flex h-11 w-11 shrink-0 items-center justify-center rounded-md md:h-7 md:w-7",
98
+ on
99
+ ? "opacity-100"
100
+ : "opacity-40 group-hover:opacity-100 focus-visible:opacity-100 md:opacity-0",
101
+ className
102
+ )}
103
+ >
104
+ <Star className={cn("h-3.5 w-3.5", on && STAR_ON)} />
105
+ </button>
106
+ );
107
+ }
108
+
109
+ // "Bookmark turn" on a turn's end line: marks the turn by its first message.
110
+ export function TurnBookmark({ turnEndId }: { turnEndId: string }) {
111
+ const chat = useBookmarkChat();
112
+ const start = useTurnStart(turnEndId);
113
+ if (!chat || !start) return null;
114
+ return <TurnStar sessionId={chat.sessionId} itemId={start} />;
115
+ }
116
+
117
+ function TurnStar({
118
+ sessionId,
119
+ itemId,
120
+ }: {
121
+ sessionId: string;
122
+ itemId: string;
123
+ }) {
124
+ const { on, toggle } = useBookmarkToggle(sessionId, itemId, "turn");
125
+ return (
126
+ <button
127
+ type="button"
128
+ aria-pressed={on}
129
+ onClick={toggle}
130
+ className={cn(
131
+ "hover:text-foreground hover:bg-foreground/[0.06] -my-2 flex min-h-11 shrink-0 items-center gap-1 rounded-md px-2 md:-my-1 md:min-h-7",
132
+ on
133
+ ? "text-bookmark"
134
+ : "opacity-60 group-hover:opacity-100 focus-visible:opacity-100"
135
+ )}
136
+ aria-label={on ? "Turn bookmarked" : "Bookmark turn"}
137
+ >
138
+ <Star className={cn("h-3 w-3", on && STAR_ON)} />
139
+ {/* On desktop an unstarred turn shows only its star until hovered. */}
140
+ <span
141
+ className={cn(
142
+ !on && "md:hidden md:group-focus-within:inline md:group-hover:inline"
143
+ )}
144
+ >
145
+ {on ? "Turn bookmarked" : "Bookmark turn"}
146
+ </span>
147
+ </button>
148
+ );
149
+ }
@@ -0,0 +1,161 @@
1
+ "use client";
2
+
3
+ import { useRef, useState } from "react";
4
+ import {
5
+ AlignLeft,
6
+ MessageSquare,
7
+ Pencil,
8
+ Star,
9
+ Terminal,
10
+ User,
11
+ } from "lucide-react";
12
+ import { toast } from "sonner";
13
+ import { cn } from "@/lib/utils";
14
+ import type { Bookmark } from "@/lib/bookmarks/types";
15
+ import { useRemoveBookmark, useSetBookmarkNote } from "@/data/bookmarks";
16
+ import { bookmarksUiActions } from "@/stores/bookmarksUi";
17
+ import { STAR_ON } from "./BookmarkButton";
18
+ import { KIND_LABEL, STATE_BADGE, canOpen, excerpt, timeOf } from "./helpers";
19
+
20
+ const KIND_ICON = {
21
+ user: User,
22
+ assistant: MessageSquare,
23
+ tool: Terminal,
24
+ turn: AlignLeft,
25
+ } as const;
26
+
27
+ // One bookmark in a list: tap to open it, edit its note, or unstar it.
28
+ export function BookmarkRow({
29
+ bookmark: b,
30
+ editing,
31
+ showSession,
32
+ onOpen,
33
+ }: {
34
+ bookmark: Bookmark;
35
+ editing: boolean;
36
+ showSession?: boolean;
37
+ onOpen: (b: Bookmark) => void;
38
+ }) {
39
+ const Icon = KIND_ICON[b.kind];
40
+ const badge = STATE_BADGE[b.state];
41
+ const remove = useRemoveBookmark();
42
+ const openable = canOpen(b);
43
+ return (
44
+ <div className="hover:bg-accent/60 flex flex-wrap items-start gap-2 rounded-lg px-2 py-1.5">
45
+ <button
46
+ type="button"
47
+ disabled={!openable}
48
+ onClick={() => onOpen(b)}
49
+ className="flex min-h-11 min-w-0 flex-1 items-start gap-2.5 py-1 text-left disabled:cursor-default"
50
+ >
51
+ <span className="bg-muted text-muted-foreground mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-md">
52
+ <Icon className="h-3.5 w-3.5" />
53
+ </span>
54
+ <span className="min-w-0 flex-1">
55
+ <span className="text-muted-foreground flex flex-wrap items-center gap-x-1.5 text-xs">
56
+ {showSession && (
57
+ <span className="text-foreground truncate font-medium">
58
+ {b.sessionName}
59
+ </span>
60
+ )}
61
+ <span>{KIND_LABEL[b.kind]}</span>
62
+ <span aria-hidden>·</span>
63
+ <span>{timeOf(b)}</span>
64
+ {badge && (
65
+ <span className="rounded border px-1.5 text-[10.5px] leading-4">
66
+ {badge}
67
+ </span>
68
+ )}
69
+ </span>
70
+ <span
71
+ className={cn(
72
+ "line-clamp-2 text-sm break-words",
73
+ b.kind === "tool" && "font-mono text-xs"
74
+ )}
75
+ >
76
+ {excerpt(b.text) || "(empty)"}
77
+ </span>
78
+ {b.note && !editing && (
79
+ <span className="text-muted-foreground block truncate text-xs">
80
+ “{b.note}”
81
+ </span>
82
+ )}
83
+ </span>
84
+ </button>
85
+ <div className="flex shrink-0 items-center">
86
+ <button
87
+ type="button"
88
+ aria-label={b.note ? "Edit note" : "Add note"}
89
+ title={b.note ? "Edit note" : "Add note"}
90
+ onClick={() => bookmarksUiActions.edit(editing ? null : b.id)}
91
+ className="text-muted-foreground hover:text-foreground hover:bg-foreground/[0.06] flex h-11 w-11 items-center justify-center rounded-md md:h-8 md:w-8"
92
+ >
93
+ <Pencil className="h-3.5 w-3.5" />
94
+ </button>
95
+ <button
96
+ type="button"
97
+ aria-label="Remove bookmark"
98
+ title="Remove bookmark"
99
+ onClick={() =>
100
+ remove.mutate(b.id, {
101
+ onError: (err) => toast.error(err.message),
102
+ })
103
+ }
104
+ className="hover:bg-foreground/[0.06] flex h-11 w-11 items-center justify-center rounded-md md:h-8 md:w-8"
105
+ >
106
+ <Star className={cn("h-3.5 w-3.5", STAR_ON)} />
107
+ </button>
108
+ </div>
109
+ {editing && <NoteEditor bookmark={b} />}
110
+ </div>
111
+ );
112
+ }
113
+
114
+ function NoteEditor({ bookmark: b }: { bookmark: Bookmark }) {
115
+ const [note, setNote] = useState(b.note ?? "");
116
+ const save = useSetBookmarkNote();
117
+ // Escape, or a save already sent: the blur that follows does nothing.
118
+ const done = useRef(false);
119
+ const commit = () => {
120
+ if (done.current) return;
121
+ done.current = true;
122
+ if ((b.note ?? "") === note.trim()) return bookmarksUiActions.edit(null);
123
+ save.mutate(
124
+ { id: b.id, note: note.trim() || null },
125
+ {
126
+ onSuccess: () => bookmarksUiActions.edit(null),
127
+ onError: (err) => {
128
+ done.current = false;
129
+ toast.error(err.message);
130
+ },
131
+ }
132
+ );
133
+ };
134
+ return (
135
+ <form
136
+ className="basis-full pb-1 pl-9"
137
+ onSubmit={(e) => {
138
+ e.preventDefault();
139
+ commit();
140
+ }}
141
+ >
142
+ <input
143
+ autoFocus
144
+ value={note}
145
+ maxLength={500}
146
+ onChange={(e) => setNote(e.target.value)}
147
+ onKeyDown={(e) => {
148
+ if (e.key === "Escape") {
149
+ e.stopPropagation();
150
+ done.current = true;
151
+ bookmarksUiActions.edit(null);
152
+ }
153
+ }}
154
+ onBlur={commit}
155
+ placeholder="A note to find it by"
156
+ aria-label="Bookmark note"
157
+ className="border-input bg-background focus-visible:ring-ring h-11 w-full rounded-md border px-3 text-base outline-none focus-visible:ring-1 md:h-8 md:text-sm"
158
+ />
159
+ </form>
160
+ );
161
+ }