@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
@@ -24,7 +24,13 @@ import {
24
24
  import { mergePR } from "./merge-pr";
25
25
  import { getProject } from "../projects";
26
26
  import { canSignOff } from "./state";
27
- import { forgetPR, getTaskSession, prFor, projectPathFor } from "./session";
27
+ import {
28
+ forgetPR,
29
+ getTaskSession,
30
+ isAllowedBase,
31
+ prFor,
32
+ projectPathFor,
33
+ } from "./session";
28
34
  import { dropSessionDatabase } from "../project-config/database";
29
35
  import { captureMergedTask } from "../session-logs";
30
36
  import { sessionEnded } from "../experiments/hooks";
@@ -114,7 +120,13 @@ async function finishMerge(
114
120
  // machine merged it and didn't say).
115
121
  export async function signOffTask(
116
122
  id: string,
117
- opts: { wait?: boolean; head?: string; device?: string | null } = {}
123
+ opts: {
124
+ wait?: boolean;
125
+ head?: string;
126
+ device?: string | null;
127
+ // The base another machine judged the gates against (a forwarded merge).
128
+ base?: string;
129
+ } = {}
118
130
  ): Promise<MergeMethod | null> {
119
131
  const session = getTaskSession(id);
120
132
  if (session.task_status !== "running")
@@ -124,14 +136,24 @@ export async function signOffTask(
124
136
  if (held) throw new Error(held);
125
137
  // Its own machine checks the gates and merges.
126
138
  // That machine's settings chose the method; null if it didn't say.
127
- if (isMirror(session)) return remoteTaskAction(session, "merge", opts.head);
139
+ if (isMirror(session))
140
+ return remoteTaskAction(session, "merge", opts.head, session.base_branch);
128
141
  const repo = projectPathFor(session);
129
142
  if (!repo) throw new Error("Task has no project");
130
143
  const refusal = signOffRefusal(id);
131
144
  if (refusal) throw new Error(refusal);
132
145
  // prFor follows a retarget onto the record, so compare with what the
133
146
  // checks above were judged against.
134
- const judgedBase = session.base_branch;
147
+ // A forwarded merge was judged on the other machine against the base it
148
+ // recorded, which may be newer than this machine's row; only a base this
149
+ // task may have is taken.
150
+ if (
151
+ opts.base &&
152
+ opts.base !== session.base_branch &&
153
+ !(await isAllowedBase(session, repo, opts.base))
154
+ )
155
+ throw new Error(`${opts.base} isn't a base ${session.name} may merge into`);
156
+ const judgedBase = opts.base ?? session.base_branch;
135
157
  const pr = await prFor(session, true);
136
158
  const verdict = canSignOff(pr);
137
159
  if (!verdict.ok) throw new Error(verdict.reason);
@@ -6,6 +6,8 @@
6
6
  import { isBranchName } from "../git";
7
7
  import { CHAT_ACCESS, type ChatAccess } from "../chat/events";
8
8
  import type { ProjectRef } from "./project-ref";
9
+ import type { MovedBookmark } from "../bookmarks/store";
10
+ import { MAX_NOTE_CHARS, MAX_SNAPSHOT_CHARS } from "../bookmarks/types";
9
11
  import { isClaudeSessionId } from "./transcript";
10
12
 
11
13
  // A long task's conversation runs to a few MB; this is far past that.
@@ -92,11 +94,17 @@ export interface ChatBundle {
92
94
  // An undo's resume point not yet used up, and the context meter.
93
95
  resumeAt: string | null;
94
96
  context: string | null;
97
+ // Its bookmarks; absent from a machine that predates them.
98
+ bookmarks?: MovedBookmark[];
95
99
  }
96
100
 
97
101
  const isStr = (v: unknown): v is string => typeof v === "string";
98
102
  const isStrOrNull = (v: unknown) => v === null || isStr(v);
99
103
 
104
+ const BOOKMARK_KINDS = ["user", "assistant", "tool", "turn"];
105
+ // Far past what a person stars in one chat.
106
+ export const MAX_MOVED_BOOKMARKS = 2000;
107
+
100
108
  function validateChat(c: ChatBundle): ChatBundle {
101
109
  if (!c || typeof c !== "object" || !Array.isArray(c.items))
102
110
  throw new Error("Bad chat");
@@ -119,6 +127,26 @@ function validateChat(c: ChatBundle): ChatBundle {
119
127
  throw new Error("Bad queued message");
120
128
  bytes += m.id.length + m.text.length + (m.images?.length ?? 0);
121
129
  }
130
+ if (c.bookmarks !== undefined && !Array.isArray(c.bookmarks))
131
+ throw new Error("Bad chat bookmarks");
132
+ if ((c.bookmarks?.length ?? 0) > MAX_MOVED_BOOKMARKS)
133
+ throw new Error("Too many bookmarks to move");
134
+ for (const b of c.bookmarks ?? []) {
135
+ if (
136
+ !b ||
137
+ !isStr(b.itemId) ||
138
+ b.itemId.length > 200 ||
139
+ !(b.scope === "item" || b.scope === "turn") ||
140
+ !BOOKMARK_KINDS.includes(b.kind) ||
141
+ !isStr(b.text) ||
142
+ b.text.length > MAX_SNAPSHOT_CHARS + 1 ||
143
+ !isStrOrNull(b.note) ||
144
+ (b.note?.length ?? 0) > MAX_NOTE_CHARS ||
145
+ !isStr(b.createdAt)
146
+ )
147
+ throw new Error("Bad bookmark");
148
+ bytes += b.itemId.length + b.text.length + (b.note?.length ?? 0);
149
+ }
122
150
  if (bytes > MAX_CHAT_BYTES) throw new Error("The chat is too large to move");
123
151
  if (!isStrOrNull(c.resumeAt) || !isStrOrNull(c.context))
124
152
  throw new Error("Bad chat");
@@ -11,6 +11,7 @@ import { settle, listItems } from "../chat/store";
11
11
  import { stopChatAtTurnEnd } from "../chat/stop";
12
12
  import { rewritePaths } from "./transcript";
13
13
  import { MAX_CHAT_BYTES, type ChatBundle } from "./move-bundle";
14
+ import { bookmarksForMove, insertMovedBookmarks } from "../bookmarks/store";
14
15
 
15
16
  // A turn this long is left to finish: the move is refused, not cut short.
16
17
  export const MOVE_TURN_WAIT_MS = 3 * 60_000;
@@ -50,6 +51,7 @@ export function packChat(session: Session): ChatBundle {
50
51
  sentBy: r.sent_by,
51
52
  createdAt: r.created_at,
52
53
  }));
54
+ const bookmarks = bookmarksForMove(session.id);
53
55
  const chat: ChatBundle = {
54
56
  items,
55
57
  queue,
@@ -57,10 +59,15 @@ export function packChat(session: Session): ChatBundle {
57
59
  plan: !!session.chat_plan,
58
60
  resumeAt: session.chat_resume_at,
59
61
  context: session.chat_context,
62
+ bookmarks,
60
63
  };
61
64
  const bytes =
62
65
  items.reduce((n, i) => n + i.id.length + i.data.length, 0) +
63
- queue.reduce((n, m) => n + m.text.length + (m.images?.length ?? 0), 0);
66
+ queue.reduce((n, m) => n + m.text.length + (m.images?.length ?? 0), 0) +
67
+ bookmarks.reduce(
68
+ (n, b) => n + b.itemId.length + b.text.length + (b.note?.length ?? 0),
69
+ 0
70
+ );
64
71
  if (bytes > MAX_CHAT_BYTES) throw new Error("The chat is too large to move");
65
72
  return chat;
66
73
  }
@@ -103,6 +110,7 @@ export function unpackChat(
103
110
  m.createdAt
104
111
  )
105
112
  );
113
+ insertMovedBookmarks(id, chat.bookmarks ?? []);
106
114
  }
107
115
 
108
116
  const imageCount = (images: string) => {
@@ -118,4 +126,5 @@ const imageCount = (images: string) => {
118
126
  export function dropChat(id: string): void {
119
127
  db.prepare(`DELETE FROM chat_items WHERE session_id = ?`).run(id);
120
128
  db.prepare(`DELETE FROM chat_queue WHERE session_id = ?`).run(id);
129
+ db.prepare(`DELETE FROM chat_bookmarks WHERE session_id = ?`).run(id);
121
130
  }
@@ -312,6 +312,26 @@ export interface QueueRequest {
312
312
  priority?: Priority;
313
313
  // "mac" pins it to a Mac; "any" says it needs nothing in particular.
314
314
  machine?: "any" | "mac";
315
+ // Start at once, past the running-task limit, instead of queueing for a
316
+ // slot. Still needs the machine it needs, and an orchestrator's start
317
+ // still passes its brakes.
318
+ now?: boolean;
319
+ }
320
+
321
+ // How full the workspace's running-task limit is for this project.
322
+ export function slotState(projectId: string): {
323
+ running: number;
324
+ limit: number | null;
325
+ full: boolean;
326
+ } {
327
+ const workspaceId = workspaceOf(projectId);
328
+ const limit = limitOf(workspaceId);
329
+ if (!workspaceId || limit === null)
330
+ return { running: 0, limit: null, full: false };
331
+ const running =
332
+ runningIn({ workspaceId, projectId }, true).length +
333
+ startingIn(workspaceId);
334
+ return { running, limit, full: running >= limit };
315
335
  }
316
336
 
317
337
  const waitingIn = (workspaceId: string) =>
@@ -349,8 +369,11 @@ export function queueIfNeeded(req: QueueRequest): QueuedTaskView | null {
349
369
  }
350
370
  } else if (after)
351
371
  afterTask = resolveAfter(after, req.originWorkspaceId ?? workspaceId).id;
372
+ if (req.now && waits)
373
+ throw new Error("now and after don't go together: now starts it at once");
352
374
  const full =
353
375
  !req.planOnly &&
376
+ !req.now &&
354
377
  limit !== null &&
355
378
  !!workspaceId &&
356
379
  (runningIn({ workspaceId, projectId: project.id }, true).length +
@@ -364,9 +387,18 @@ export function queueIfNeeded(req: QueueRequest): QueuedTaskView | null {
364
387
  throw new Error(`Machine is "any" or "mac"`);
365
388
  // The orchestrator's own starts wait out a loaded machine; a person's
366
389
  // direct start goes ahead, as "start now" always has.
390
+ if (
391
+ req.now &&
392
+ !req.planOnly &&
393
+ needsUnmetHere(project.id, req.prompt, setting)
394
+ )
395
+ throw new Error(
396
+ `Can't start now: this task needs a Mac, and this machine isn't one. Queue it without now.`
397
+ );
367
398
  const loaded =
368
399
  !waits &&
369
400
  !full &&
401
+ !req.now &&
370
402
  !req.planOnly &&
371
403
  !!req.originWorkspaceId &&
372
404
  loadHeldStart(project.id, req.prompt, setting);
@@ -426,7 +458,8 @@ export async function startOrQueue<T>(
426
458
  const queued = queueIfNeeded(req);
427
459
  if (queued) return { queued };
428
460
  const workspaceId = workspaceOf(req.projectId);
429
- if (!workspaceId) return { started: await start() };
461
+ // A plan-only start never holds a slot, as its queue row doesn't.
462
+ if (!workspaceId || req.planOnly) return { started: await start() };
430
463
  reserved.set(workspaceId, (reserved.get(workspaceId) ?? 0) + 1);
431
464
  try {
432
465
  return { started: await start() };
@@ -539,6 +572,33 @@ function autoHold(workspaceId: string | null, planOnly = false): Hold | null {
539
572
  return window ? { code: "window", text: `Waiting: ${window}` } : null;
540
573
  }
541
574
 
575
+ const requestNeeds = (
576
+ projectId: string,
577
+ workingDirectory: string,
578
+ prompt: string,
579
+ setting: Need[] | null
580
+ ): Need[] =>
581
+ setting ??
582
+ needsOf({
583
+ projectId,
584
+ projectPath: expandHome(workingDirectory),
585
+ prompt,
586
+ }).needs;
587
+
588
+ // Whether the task needs something this machine lacks (a Mac).
589
+ function needsUnmetHere(
590
+ projectId: string,
591
+ prompt: string,
592
+ setting: Need[] | null
593
+ ): boolean {
594
+ const project = getProject(projectId);
595
+ if (!project || isRemoteHost(project.host_id)) return false;
596
+ return !meetsNeeds(
597
+ requestNeeds(projectId, project.working_directory, prompt, setting),
598
+ machineCapabilities()
599
+ );
600
+ }
601
+
542
602
  // Whether this machine's load rules out starting a task for this project now.
543
603
  function loadHeldStart(
544
604
  projectId: string,
@@ -547,13 +607,12 @@ function loadHeldStart(
547
607
  ): boolean {
548
608
  const project = getProject(projectId);
549
609
  if (!project || isRemoteHost(project.host_id)) return false;
550
- const needs =
551
- setting ??
552
- needsOf({
553
- projectId,
554
- projectPath: expandHome(project.working_directory),
555
- prompt,
556
- }).needs;
610
+ const needs = requestNeeds(
611
+ projectId,
612
+ project.working_directory,
613
+ prompt,
614
+ setting
615
+ );
557
616
  const hold = machineHold(
558
617
  {
559
618
  heavy: heavyNeeds(needs),
@@ -593,6 +652,8 @@ interface StartOpts {
593
652
  // Start on this linked machine instead of the project's.
594
653
  placement?: Placement | null;
595
654
  heavy?: boolean;
655
+ // Pass this workspace's brakes even for a row no orchestrator queued.
656
+ brakeAs?: string;
596
657
  }
597
658
 
598
659
  async function startRow(
@@ -683,9 +744,10 @@ async function startRow(
683
744
  try {
684
745
  // The orchestrator's queued starts pass its brakes when they start,
685
746
  // a person's "start now" included: it skips only the limit and `after`.
686
- const session = origin
747
+ const brakeWs = origin ?? opts.brakeAs;
748
+ const session = brakeWs
687
749
  ? await braked(
688
- origin,
750
+ brakeWs,
689
751
  "task",
690
752
  () => createTask(task),
691
753
  (s) => s.id,
@@ -1090,6 +1152,67 @@ export async function startQueuedNow(id: string): Promise<StartOutcome> {
1090
1152
  return startRow(r, { force: true });
1091
1153
  }
1092
1154
 
1155
+ export interface ForcedStart {
1156
+ outcome: "started" | "held" | "failed";
1157
+ // Why it is held, or what failed; empty when it started.
1158
+ text: string;
1159
+ // The running-task limit was full when it started.
1160
+ wasFull: boolean;
1161
+ running: number;
1162
+ limit: number | null;
1163
+ // It skipped an `after`.
1164
+ skippedAfter: boolean;
1165
+ }
1166
+
1167
+ // The orchestrator's start now: past the limit and `after`, never past
1168
+ // Pause, its brakes or a machine need. A held task stays queued.
1169
+ export async function startQueuedForOrchestrator(
1170
+ workspaceId: string,
1171
+ id: string
1172
+ ): Promise<ForcedStart> {
1173
+ const r = row(id);
1174
+ const project = r ? getProject(r.project_id) : null;
1175
+ if (!r || r.status !== "queued" || project?.workspace_id !== workspaceId)
1176
+ throw new Error("That task isn't waiting in the queue");
1177
+ const slots = slotState(r.project_id);
1178
+ const base = {
1179
+ wasFull: slots.full,
1180
+ running: slots.running,
1181
+ limit: slots.limit,
1182
+ skippedAfter: !!(r.after_task || r.after_any),
1183
+ };
1184
+ const held = (text: string): ForcedStart => ({
1185
+ ...base,
1186
+ outcome: "held",
1187
+ text,
1188
+ });
1189
+ if (isPaused(workspaceId)) return held("the orchestrator is paused by Saad");
1190
+ if (isRemoteHost(r.host_id ?? project.host_id))
1191
+ return held(
1192
+ "it runs on another machine, and the orchestrator starts tasks on this one only"
1193
+ );
1194
+ if (
1195
+ !meetsNeeds(
1196
+ parseNeeds(r.machine_needs) ??
1197
+ requestNeeds(project.id, project.working_directory, r.prompt, null),
1198
+ machineCapabilities()
1199
+ )
1200
+ )
1201
+ return held("it needs a Mac, and this machine isn't one");
1202
+ try {
1203
+ const outcome = await startRow(r, { force: true, brakeAs: workspaceId });
1204
+ if (outcome === "started") return { ...base, outcome, text: "" };
1205
+ return held("it could not be claimed just now; try again");
1206
+ } catch (error) {
1207
+ if (error instanceof BrakeRefused) return held(error.reason);
1208
+ return {
1209
+ ...base,
1210
+ outcome: "failed",
1211
+ text: row(id)?.error ?? String(error),
1212
+ };
1213
+ }
1214
+ }
1215
+
1093
1216
  // A task still waiting in the line (or failed to start) by id, an id prefix
1094
1217
  // of 4+ characters or, when asked, its name; in one workspace.
1095
1218
  export function findQueued(
@@ -191,7 +191,8 @@ export function setMirrorStatus(id: string, status: TaskStatus): void {
191
191
  export async function remoteTaskAction(
192
192
  session: Session,
193
193
  action: "merge" | "drop",
194
- head?: string
194
+ head?: string,
195
+ base?: string | null
195
196
  ): Promise<MergeMethod | null> {
196
197
  const link = requireHostLink(session.host_id);
197
198
  // An AgentOS that predates pinned merges would ignore the head and merge
@@ -203,11 +204,28 @@ export async function remoteTaskAction(
203
204
  `${link.hostName}'s AgentOS can't pin a merge to a commit; update it first`
204
205
  );
205
206
  }
207
+ // base: what the gates here were judged against; the machine that merges
208
+ // compares the PR with it, not with a base only it recorded.
206
209
  const res = await hostApi<{ head?: string | null; method?: unknown }>(
207
210
  link,
208
211
  `/api/tasks/${encodeURIComponent(session.id)}/${action}`,
209
- { body: head ? { head } : {}, timeout: 300000 }
210
- );
212
+ {
213
+ body: { ...(head && { head }), ...(head && base && { base }) },
214
+ timeout: 300000,
215
+ }
216
+ ).catch(async (err) => {
217
+ if (action !== "merge" || !unknownOutcome(err)) throw err;
218
+ // A proxy or a dropped connection after the merge ran answers 502 with
219
+ // nothing from AgentOS: ask what state the task is in before failing.
220
+ const merged = await hostTasks(link, true)
221
+ .then((l) => l.tasks.find((t) => t.id === session.id)?.state === "merged")
222
+ .catch(() => false);
223
+ if (!merged)
224
+ throw new Error(
225
+ `${(err as Error).message}: couldn't confirm whether ${link.hostName} merged ${session.name}; check its PR before signing off again`
226
+ );
227
+ return { head: head ?? null, method: undefined };
228
+ });
211
229
  forgetHostTasks(link.hostId);
212
230
  setMirrorStatus(session.id, action === "merge" ? "merged" : "dropped");
213
231
  if (head && res.head !== head)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.584",
3
+ "version": "0.3.587",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
@@ -0,0 +1,50 @@
1
+ import { proxy } from "valtio";
2
+
3
+ // The Bookmarks views: every chat's (`open`, one workspace's or all), or one
4
+ // chat's (`chatSessionId`); the bookmark whose note is being written; and a
5
+ // jump waiting for its chat to be on screen.
6
+ export const bookmarksUi = proxy<{
7
+ open: boolean;
8
+ workspaceId: string | null;
9
+ chatSessionId: string | null;
10
+ editing: string | null;
11
+ jump: { sessionId: string; itemId: string; at: number } | null;
12
+ }>({
13
+ open: false,
14
+ workspaceId: null,
15
+ chatSessionId: null,
16
+ editing: null,
17
+ jump: null,
18
+ });
19
+
20
+ export const bookmarksUiActions = {
21
+ open: (workspaceId: string | null = null) => {
22
+ bookmarksUi.workspaceId = workspaceId;
23
+ bookmarksUi.open = true;
24
+ },
25
+ setOpen: (open: boolean) => {
26
+ bookmarksUi.open = open;
27
+ if (!open) bookmarksUi.editing = null;
28
+ },
29
+ openChat: (sessionId: string, editing: string | null = null) => {
30
+ bookmarksUi.editing = editing;
31
+ bookmarksUi.chatSessionId = sessionId;
32
+ },
33
+ closeChat: () => {
34
+ bookmarksUi.chatSessionId = null;
35
+ bookmarksUi.editing = null;
36
+ },
37
+ edit: (id: string | null) => {
38
+ bookmarksUi.editing = id;
39
+ },
40
+ jump: (sessionId: string, itemId: string) => {
41
+ bookmarksUi.jump = { sessionId, itemId, at: Date.now() };
42
+ },
43
+ // The chat showing it takes the jump; one taken isn't taken twice.
44
+ takeJump: (sessionId: string): string | null => {
45
+ const j = bookmarksUi.jump;
46
+ if (!j || j.sessionId !== sessionId) return null;
47
+ bookmarksUi.jump = null;
48
+ return j.itemId;
49
+ },
50
+ };
package/styles/themes.css CHANGED
@@ -28,6 +28,8 @@
28
28
  --find-match: 48 96% 53% / 0.45;
29
29
  --find-current: 25 95% 53%;
30
30
  --find-current-foreground: 0 0% 0%;
31
+ /* A bookmarked item's star. */
32
+ --bookmark: 30 90% 40%;
31
33
  --background: 240 10% 95.5%;
32
34
  --foreground: 240 12% 14%;
33
35
  --card: 0 0% 100%;
@@ -292,6 +294,7 @@
292
294
  =========================================================================== */
293
295
  .dark {
294
296
  --find-match: 48 96% 53% / 0.35;
297
+ --bookmark: 43 96% 56%;
295
298
  --background: 240 18% 3.5%;
296
299
  --foreground: 240 10% 93%;
297
300
  --card: 240 14% 6%;