@saadnvd1/agent-os 0.3.457 → 0.3.459

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 (49) hide show
  1. package/README.md +30 -5
  2. package/app/api/fresh-start/route.ts +19 -0
  3. package/app/api/workspaces/[id]/orchestrator/rules/[ruleId]/route.ts +60 -0
  4. package/app/api/workspaces/[id]/orchestrator/rules/presence.ts +29 -0
  5. package/app/api/workspaces/[id]/orchestrator/rules/route.ts +36 -0
  6. package/app/globals.css +10 -0
  7. package/components/Chat/ChatPanel.tsx +87 -4
  8. package/components/Chat/FindBar.tsx +113 -0
  9. package/components/Chat/Tools.tsx +1 -1
  10. package/components/Chat/find-highlight.test.ts +56 -0
  11. package/components/Chat/find-highlight.ts +78 -0
  12. package/components/Chat/rowState.tsx +44 -4
  13. package/components/Chat/stick-to-bottom.ts +7 -1
  14. package/components/Chat/useFind.ts +253 -0
  15. package/components/Pane/MobileTabBar.tsx +18 -0
  16. package/components/Pane/index.tsx +6 -0
  17. package/components/Workspaces/FreshStartForm.tsx +128 -0
  18. package/components/Workspaces/RulesForm.tsx +166 -0
  19. package/components/Workspaces/WorkspaceSettings.tsx +4 -0
  20. package/components/Workspaces/fresh-start.ts +19 -0
  21. package/data/chat/useChat.ts +29 -0
  22. package/data/fresh-start/index.ts +34 -0
  23. package/data/orchestrators/rules.ts +80 -0
  24. package/data/push/topics.ts +4 -0
  25. package/lib/chat/demo.ts +1 -0
  26. package/lib/chat/events.ts +7 -1
  27. package/lib/chat/find.ts +278 -0
  28. package/lib/chat/refresh-limits.ts +5 -0
  29. package/lib/chat/refresh.ts +82 -0
  30. package/lib/chat/runner.ts +11 -2
  31. package/lib/chat/search.ts +117 -0
  32. package/lib/chat/socket.ts +12 -0
  33. package/lib/chat/worker/extras.ts +11 -1
  34. package/lib/chat/worker/host.ts +70 -5
  35. package/lib/db/changes.ts +3 -0
  36. package/lib/db/migrations.ts +37 -0
  37. package/lib/db/types.ts +4 -0
  38. package/lib/orchestrator/brief.ts +14 -3
  39. package/lib/orchestrator/digest.ts +339 -0
  40. package/lib/orchestrator/presence-binding.ts +5 -0
  41. package/lib/orchestrator/rules.ts +124 -0
  42. package/lib/orchestrator/serve.ts +11 -0
  43. package/lib/orchestrator/tool-names.ts +2 -0
  44. package/lib/orchestrator/tool-schemas.ts +10 -0
  45. package/lib/orchestrator/tools.ts +4 -0
  46. package/lib/orchestrator/worker.ts +5 -0
  47. package/lib/security/presence.ts +2 -1
  48. package/package.json +1 -1
  49. package/styles/themes.css +5 -0
package/README.md CHANGED
@@ -78,8 +78,10 @@ conversation in **Messages** and can join in. [More](#agent-network)
78
78
 
79
79
  - **Chat or terminal, same conversation.** Streaming replies, steps and
80
80
  diffs, plan mode, a queue you can steer, slash commands and skills,
81
- `@file`, undo, and inline charts and pages. One click hands the
82
- conversation to the agent's own terminal. [More](#chat)
81
+ `@file`, undo, and inline charts and pages. ⌘F (Ctrl+F) finds text
82
+ anywhere in the conversation, tool output and older pages included, and
83
+ opens the step it's in. One click hands the conversation to the agent's
84
+ own terminal. [More](#chat)
83
85
  - **Project config.** `agentos.json` (or an existing `.dispatch.json`) gives
84
86
  each session its own ports, environment, copied files, cloned
85
87
  dependencies, a private Postgres copy, and a "Running this project"
@@ -842,7 +844,7 @@ it may do on its own, and the lines that always come back to you as asks.
842
844
  stack position), `read` (the end of a terminal or chat) and `cards` (the
843
845
  linked boards' cards), plus acting tools: `send`, `start_task`,
844
846
  `start_session`, `stack`, `stack_status`, `land`, `drop`, `stop`, `done`, `note`,
845
- `review`, `sign_off`, `arm`, `disarm` and `ask_saad`. All are served in-process to its chat, refuse
847
+ `rule_add`, `rule_remove`, `review`, `sign_off`, `arm`, `disarm` and `ask_saad`. All are served in-process to its chat, refuse
846
848
  any target outside its workspace, and validate their arguments. The one
847
849
  exception is other orchestrators: `orchestrators` lists every workspace and
848
850
  whether it has one, and `message_orchestrator` messages another workspace's
@@ -916,6 +918,28 @@ it may do on its own, and the lines that always come back to you as asks.
916
918
  again needs your passkey.
917
919
  - **Decision log:** `note` and the brakes and escalations write to
918
920
  `orchestrator_notes`, and each line shows in its chat.
921
+ - **Standing rules:** what it must keep knowing ("never merge #111", "briefs
922
+ must not name competitors") it keeps with `rule_add` and drops with
923
+ `rule_remove`, in `orchestrator_rules`. You see, add, edit and remove them in
924
+ Settings → Workspaces, each change with your passkey (any process on the
925
+ machine reaches the route, and a rule of yours is your word to it). A rule restricts or informs; it never approves an
926
+ ask, passes a gate or clears a hard line. Yours are marked as from you and
927
+ only you remove them; the ones it kept, like its decision-log lines, come
928
+ back to it fenced as its own notes, so text it read can't pass for your
929
+ word. Rule text is redacted when stored, and removed rules stay on record.
930
+ - **Where things stand:** every start appends a digest read straight from
931
+ the database, no model involved: the standing rules, open asks, running
932
+ tasks with their PR, armed-to-merge and gate state, recently gated external
933
+ PRs, the queue, schedules and the newest decision-log lines, capped at about
934
+ 4k tokens. The brief tells it this is authoritative over what it remembers.
935
+ - **Fresh starts:** fed events nonstop, an orchestrator never idles and can't
936
+ compact itself. Once its context passes a threshold (200k tokens by
937
+ default, Settings → Workspaces → Fresh starts), its worker ends the
938
+ conversation at the next turn boundary and the next one starts a new
939
+ conversation with the brief and digest, telling the agent in one line why.
940
+ Nothing is lost, because its state lives in the database; its chat goes on,
941
+ with a line where it started over. The same can be switched on for chat
942
+ tasks (off by default: a task keeps only its brief and its branch).
919
943
  - **Events:** the server sends it one short line per event (a PR opened, CI
920
944
  failed, or green once it has settled so `sign_off`'s ci gate passes, a `BLOCKED:` line, a merge, a session needing input, a
921
945
  stack step, a task idle 30 minutes with no PR, a review's verdict). Events
@@ -948,8 +972,9 @@ approved`). An approval covers that one item only: a held task's approval
948
972
  filed as that hard line.
949
973
  - **Proof it's you:** only this machine, the tailnet, or a paired device you
950
974
  switched to "Can approve" in Devices may answer asks or pause. Approving a
951
- hard line, a gate, a brake or a new passkey, Resume, and switching merge
952
- approvals off, also need a
975
+ hard line, a gate, a brake or a new passkey, Resume, changing the
976
+ orchestrator's standing rules, and switching merge approvals off, also
977
+ need a
953
978
  passkey (Touch ID or Face ID, user verification required) on a challenge
954
979
  bound to that one ask at its current commit or brake: single use, two
955
980
  minutes. Agents on this machine reach every route but can't make your
@@ -0,0 +1,19 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { refreshSettings, setRefreshSettings } from "@/lib/chat/refresh";
3
+
4
+ // When a chat's conversation starts over (lib/chat/refresh): context
5
+ // tokens for orchestrators and for tasks, 0 for never.
6
+ export async function GET() {
7
+ return NextResponse.json(refreshSettings());
8
+ }
9
+
10
+ export async function PUT(request: NextRequest) {
11
+ try {
12
+ const { orchestratorTokens, taskTokens } = await request.json();
13
+ setRefreshSettings({ orchestratorTokens, taskTokens });
14
+ return NextResponse.json(refreshSettings());
15
+ } catch (error) {
16
+ const message = error instanceof Error ? error.message : String(error);
17
+ return NextResponse.json({ error: message }, { status: 400 });
18
+ }
19
+ }
@@ -0,0 +1,60 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import type { AuthenticationResponseJSON } from "@simplewebauthn/server";
3
+ import { removeRule, updateRule } from "@/lib/orchestrator/rules";
4
+ import { provenAsSaad } from "../presence";
5
+
6
+ type RouteParams = { params: Promise<{ id: string; ruleId: string }> };
7
+
8
+ type Body = { text?: unknown; assertion?: AuthenticationResponseJSON };
9
+
10
+ // The rule, its body, and Saad's passkey on it; or the refusal.
11
+ async function target(
12
+ request: NextRequest,
13
+ { params }: RouteParams
14
+ ): Promise<
15
+ { response: Response } | { workspaceId: string; ruleId: number; body: Body }
16
+ > {
17
+ const { id, ruleId } = await params;
18
+ const n = Number(ruleId);
19
+ if (!Number.isInteger(n) || n <= 0)
20
+ return {
21
+ response: NextResponse.json({ error: "Bad rule id" }, { status: 400 }),
22
+ };
23
+ const body = (await request.json().catch(() => ({}))) as Body;
24
+ const refused = await provenAsSaad(request, id, body.assertion);
25
+ if (refused) return { response: refused };
26
+ return { workspaceId: id, ruleId: n, body };
27
+ }
28
+
29
+ const refused = (error: unknown) =>
30
+ NextResponse.json(
31
+ { error: error instanceof Error ? error.message : String(error) },
32
+ { status: 400 }
33
+ );
34
+
35
+ // { text, assertion }: Saad rewords a rule.
36
+ export async function PATCH(request: NextRequest, ctx: RouteParams) {
37
+ const t = await target(request, ctx);
38
+ if ("response" in t) return t.response;
39
+ if (typeof t.body.text !== "string")
40
+ return NextResponse.json({ error: "text is required" }, { status: 400 });
41
+ try {
42
+ return NextResponse.json({
43
+ rule: updateRule(t.workspaceId, t.ruleId, t.body.text),
44
+ });
45
+ } catch (error) {
46
+ return refused(error);
47
+ }
48
+ }
49
+
50
+ // { assertion }: marked removed, never deleted.
51
+ export async function DELETE(request: NextRequest, ctx: RouteParams) {
52
+ const t = await target(request, ctx);
53
+ if ("response" in t) return t.response;
54
+ try {
55
+ removeRule(t.workspaceId, t.ruleId, "saad");
56
+ return NextResponse.json({ success: true });
57
+ } catch (error) {
58
+ return refused(error);
59
+ }
60
+ }
@@ -0,0 +1,29 @@
1
+ import type { NextRequest } from "next/server";
2
+ import type { AuthenticationResponseJSON } from "@simplewebauthn/server";
3
+ import { requireApprover } from "@/lib/security/approver";
4
+ import { relyingParty, verifyPresence } from "@/lib/security/presence";
5
+ import { refusalResponse } from "@/lib/security/presence-http";
6
+ import { rulesPresence } from "@/lib/orchestrator/presence-binding";
7
+
8
+ // A rule written here is Saad's, shown to the orchestrator as his word and
9
+ // removable only by him: so it takes his passkey, not just a trusted place
10
+ // (any process on this machine reaches the route).
11
+ export async function provenAsSaad(
12
+ request: NextRequest,
13
+ workspaceId: string,
14
+ assertion: AuthenticationResponseJSON | undefined
15
+ ): Promise<Response | null> {
16
+ const gate = requireApprover(request);
17
+ if (!gate.ok) return gate.response;
18
+ try {
19
+ await verifyPresence(
20
+ relyingParty(request.headers),
21
+ assertion,
22
+ "rules",
23
+ rulesPresence(workspaceId)
24
+ );
25
+ return null;
26
+ } catch (error) {
27
+ return refusalResponse(error);
28
+ }
29
+ }
@@ -0,0 +1,36 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import type { AuthenticationResponseJSON } from "@simplewebauthn/server";
3
+ import { getWorkspace } from "@/lib/workspaces";
4
+ import { addRule, listRules } from "@/lib/orchestrator/rules";
5
+ import { provenAsSaad } from "./presence";
6
+
7
+ type RouteParams = { params: Promise<{ id: string }> };
8
+
9
+ // The workspace orchestrator's standing rules, in its brief on every start.
10
+ export async function GET(_request: NextRequest, { params }: RouteParams) {
11
+ const { id } = await params;
12
+ if (!getWorkspace(id))
13
+ return NextResponse.json({ error: "Workspace not found" }, { status: 404 });
14
+ return NextResponse.json({ rules: listRules(id) });
15
+ }
16
+
17
+ // { text, assertion }: a rule from Saad, with his passkey.
18
+ export async function POST(request: NextRequest, { params }: RouteParams) {
19
+ const { id } = await params;
20
+ if (!getWorkspace(id))
21
+ return NextResponse.json({ error: "Workspace not found" }, { status: 404 });
22
+ const { text, assertion } = (await request.json().catch(() => ({}))) as {
23
+ text?: unknown;
24
+ assertion?: AuthenticationResponseJSON;
25
+ };
26
+ if (typeof text !== "string")
27
+ return NextResponse.json({ error: "text is required" }, { status: 400 });
28
+ const refused = await provenAsSaad(request, id, assertion);
29
+ if (refused) return refused;
30
+ try {
31
+ return NextResponse.json({ rule: addRule(id, text, "saad") });
32
+ } catch (error) {
33
+ const message = error instanceof Error ? error.message : String(error);
34
+ return NextResponse.json({ error: message }, { status: 400 });
35
+ }
36
+ }
package/app/globals.css CHANGED
@@ -252,3 +252,13 @@ body {
252
252
  color: hsl(var(--primary));
253
253
  animation: status-pulse 1s ease-in-out infinite;
254
254
  }
255
+
256
+ /* Find in a conversation (components/Chat/find-highlight): every match, and
257
+ the one it's on, in the browser's own find colours (styles/themes.css). */
258
+ ::highlight(chat-find) {
259
+ background-color: hsl(var(--find-match));
260
+ }
261
+ ::highlight(chat-find-current) {
262
+ background-color: hsl(var(--find-current));
263
+ color: hsl(var(--find-current-foreground));
264
+ }
@@ -28,7 +28,9 @@ import { Composer } from "./Composer";
28
28
  import { Queue } from "./Queue";
29
29
  import { QuoteButton } from "./QuoteButton";
30
30
  import { ToolBodies, ToolGroup } from "./Tools";
31
- import { RowState } from "./rowState";
31
+ import { RowState, RowStore } from "./rowState";
32
+ import { FindBar } from "./FindBar";
33
+ import { FIND_EVENT, isFindKey, useFind } from "./useFind";
32
34
  import { McpServers } from "./McpServers";
33
35
  import { ArtifactCard } from "./Artifact";
34
36
  import { PlanCard } from "./PlanCard";
@@ -159,7 +161,9 @@ const Block = memo(
159
161
  <Timeline blocks={groupTimeline(b.items)} actions={undoneActions} />
160
162
  </UndoneBlock>
161
163
  ) : (
162
- <Item item={b.item} actions={actions} />
164
+ <div data-item={b.item.id}>
165
+ <Item item={b.item} actions={actions} />
166
+ </div>
163
167
  );
164
168
  },
165
169
  (prev, next) =>
@@ -221,6 +225,7 @@ export function ChatPanel({
221
225
  queueSendNow,
222
226
  suggestion,
223
227
  requestFiles,
228
+ search,
224
229
  } = useChat(sessionId, (text) => setPrefill({ text, at: Date.now() }));
225
230
  const { isMobile } = useViewport();
226
231
  const scrollRef = useRef<HTMLDivElement>(null);
@@ -231,6 +236,7 @@ export function ChatPanel({
231
236
  stuck,
232
237
  unread,
233
238
  toBottom: toEnd,
239
+ release,
234
240
  } = useStickToBottom(listRef, sessionId, arrivalKey(items.at(-1)));
235
241
 
236
242
  const running = state === "running" || state === "waiting";
@@ -253,6 +259,8 @@ export function ChatPanel({
253
259
  const onKey = (e: KeyboardEvent) => {
254
260
  const root = rootRef.current;
255
261
  if (!root?.contains(e.target as Node)) return;
262
+ // The find bar takes its own Esc.
263
+ if ((e.target as Element).closest?.("[data-find-bar]")) return;
256
264
  const overlay = !!document.querySelector(OVERLAY_SELECTOR);
257
265
  const ghost = !!root.querySelector(GHOST_SELECTOR);
258
266
  const action = escapeAction(e, escape.current.running, overlay, ghost);
@@ -317,11 +325,72 @@ export function ChatPanel({
317
325
  }, [earlierTasks, items]);
318
326
  // A new conversation starts with every row closed.
319
327
  // eslint-disable-next-line react-hooks/exhaustive-deps
320
- const rowState = useMemo(() => new Map<string, unknown>(), [sessionId]);
328
+ const rowState = useMemo(() => new RowStore(), [sessionId]);
321
329
  const bodies = useMemo(
322
330
  () => ({ bodies: toolBodies, request: requestToolBody, connected }),
323
331
  [toolBodies, requestToolBody, connected]
324
332
  );
333
+ const blocksRef = useRef(blocks);
334
+ useEffect(() => {
335
+ blocksRef.current = blocks;
336
+ });
337
+ const find = useFind({
338
+ search,
339
+ items,
340
+ hasMore,
341
+ loadingOlder,
342
+ loadOlder,
343
+ rows: rowState,
344
+ release,
345
+ scrollToBlock: useCallback(async (key: string) => {
346
+ const index = blocksRef.current.findIndex((b) => blockKey(b) === key);
347
+ if (index !== -1)
348
+ await listRef.current?.scrollToIndex({
349
+ index,
350
+ viewPosition: 0.3,
351
+ animated: false,
352
+ });
353
+ }, []),
354
+ scroller: useCallback(() => {
355
+ const el = listRef.current?.getScrollableNode();
356
+ return el instanceof HTMLElement ? el : null;
357
+ }, []),
358
+ });
359
+ const activeRef = useRef(active);
360
+ useEffect(() => {
361
+ activeRef.current = active;
362
+ });
363
+ // ⌘F (Ctrl+F off a Mac) in this chat, or the phone header's search
364
+ // button, opens the find bar; pressed again, the field takes the caret.
365
+ const [findFocus, setFindFocus] = useState(0);
366
+ const showFind = find.show;
367
+ const closeFind = find.close;
368
+ useEffect(() => {
369
+ const open = () => {
370
+ showFind();
371
+ setFindFocus((n) => n + 1);
372
+ };
373
+ const onKey = (e: KeyboardEvent) => {
374
+ const root = rootRef.current;
375
+ const here =
376
+ root?.contains(e.target as Node) ||
377
+ (activeRef.current && e.target === document.body);
378
+ if (!here || !isFindKey(e)) return;
379
+ e.preventDefault();
380
+ open();
381
+ };
382
+ const onFind = (e: Event) => {
383
+ if ((e as CustomEvent<string>).detail === sessionId) open();
384
+ };
385
+ window.addEventListener("keydown", onKey, true);
386
+ window.addEventListener(FIND_EVENT, onFind);
387
+ return () => {
388
+ window.removeEventListener("keydown", onKey, true);
389
+ window.removeEventListener(FIND_EVENT, onFind);
390
+ };
391
+ }, [sessionId, showFind]);
392
+ // Another conversation starts without the last one's find.
393
+ useEffect(() => closeFind, [sessionId, closeFind]);
325
394
  const idle = !running && connected;
326
395
  const actions: ItemActions = useMemo(
327
396
  () => ({
@@ -354,7 +423,10 @@ export function ChatPanel({
354
423
  keyExtractor={blockKey}
355
424
  getItemType={blockType}
356
425
  renderItem={({ item }) => (
357
- <div className="mx-auto w-full max-w-3xl px-4 pb-4">
426
+ <div
427
+ data-block={blockKey(item)}
428
+ className="mx-auto w-full max-w-3xl px-4 pb-4"
429
+ >
358
430
  <Block block={item} actions={actions} />
359
431
  </div>
360
432
  )}
@@ -394,6 +466,17 @@ export function ChatPanel({
394
466
  />
395
467
  </ToolBodies.Provider>
396
468
  </RowState.Provider>
469
+ {find.open && (
470
+ <FindBar
471
+ find={find}
472
+ focusKey={findFocus}
473
+ onClose={(fromKey) => {
474
+ closeFind();
475
+ // A tap on a phone would bring up the keyboard over the match.
476
+ if (fromKey || !isMobile) focusComposer();
477
+ }}
478
+ />
479
+ )}
397
480
  {unread && !stuck && (
398
481
  <button
399
482
  type="button"
@@ -0,0 +1,113 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef } from "react";
4
+ import { ChevronDown, ChevronUp, Loader2, X } from "lucide-react";
5
+ import { MIN_QUERY } from "@/lib/chat/find";
6
+ import type { useFind } from "./useFind";
7
+
8
+ type Find = ReturnType<typeof useFind>;
9
+
10
+ const iconButton =
11
+ "text-muted-foreground hover:text-foreground hover:bg-foreground/[0.06] flex h-11 w-11 shrink-0 items-center justify-center rounded-md disabled:pointer-events-none disabled:opacity-40 md:h-7 md:w-7";
12
+
13
+ function Count({ find }: { find: Find }) {
14
+ if (find.query.trim().length < MIN_QUERY) return null;
15
+ if (find.status === "searching")
16
+ return (
17
+ <Loader2 className="h-3.5 w-3.5 animate-spin" aria-label="Searching" />
18
+ );
19
+ if (find.status === "unavailable") return <span>Unavailable</span>;
20
+ if (!find.total) return <span>No results</span>;
21
+ return (
22
+ <span>
23
+ {find.position} of {find.total}
24
+ {find.capped && "+"}
25
+ </span>
26
+ );
27
+ }
28
+
29
+ // Find in the conversation: a small bar over the top of the chat. Enter goes
30
+ // to the next match up the conversation, Shift+Enter back down, Esc closes.
31
+ export function FindBar({
32
+ find,
33
+ focusKey,
34
+ onClose,
35
+ }: {
36
+ find: Find;
37
+ // Changes whenever ⌘F is pressed again: the field takes the caret back.
38
+ focusKey: number;
39
+ // `fromKey`: closed with Esc, so the caret goes back to the composer.
40
+ onClose: (fromKey: boolean) => void;
41
+ }) {
42
+ const input = useRef<HTMLInputElement>(null);
43
+ useEffect(() => {
44
+ input.current?.focus();
45
+ input.current?.select();
46
+ }, [focusKey]);
47
+ const none = !find.total;
48
+ return (
49
+ <div
50
+ data-find-bar
51
+ role="search"
52
+ className="bg-background absolute top-2 right-2 left-2 z-20 flex items-center gap-1 rounded-lg border py-0.5 pr-0.5 pl-3 shadow-sm md:left-auto md:w-96"
53
+ >
54
+ <input
55
+ ref={input}
56
+ value={find.query}
57
+ onChange={(e) => find.setQuery(e.target.value)}
58
+ onKeyDown={(e) => {
59
+ if (e.key === "Enter" && !e.nativeEvent.isComposing) {
60
+ e.preventDefault();
61
+ if (e.shiftKey) find.previous();
62
+ else find.next();
63
+ } else if (e.key === "Escape") {
64
+ e.preventDefault();
65
+ onClose(true);
66
+ }
67
+ }}
68
+ placeholder="Find in conversation"
69
+ aria-label="Find in conversation"
70
+ enterKeyHint="search"
71
+ autoComplete="off"
72
+ spellCheck={false}
73
+ // 16px on phones, so iOS doesn't zoom in on focus.
74
+ className="placeholder:text-muted-foreground min-w-0 flex-1 bg-transparent py-1.5 text-base outline-none md:text-sm"
75
+ />
76
+ <span
77
+ aria-live="polite"
78
+ className="text-muted-foreground shrink-0 px-1 font-mono text-[11px] tabular-nums"
79
+ >
80
+ <Count find={find} />
81
+ </span>
82
+ <button
83
+ type="button"
84
+ onClick={find.next}
85
+ disabled={none}
86
+ aria-label="Next match, further up"
87
+ title="Next (Enter)"
88
+ className={iconButton}
89
+ >
90
+ <ChevronUp className="h-4 w-4" />
91
+ </button>
92
+ <button
93
+ type="button"
94
+ onClick={find.previous}
95
+ disabled={none}
96
+ aria-label="Previous match, further down"
97
+ title="Previous (Shift+Enter)"
98
+ className={iconButton}
99
+ >
100
+ <ChevronDown className="h-4 w-4" />
101
+ </button>
102
+ <button
103
+ type="button"
104
+ onClick={() => onClose(false)}
105
+ aria-label="Close find"
106
+ title="Close (Esc)"
107
+ className={iconButton}
108
+ >
109
+ <X className="h-4 w-4" />
110
+ </button>
111
+ </div>
112
+ );
113
+ }
@@ -132,7 +132,7 @@ function ToolRow({ tool }: { tool: ToolItem }) {
132
132
  tool.status === "error" || !!tool.diff || !!tool.hasDiff
133
133
  );
134
134
  return (
135
- <div>
135
+ <div data-item={tool.id}>
136
136
  <button
137
137
  type="button"
138
138
  onClick={() => setOpen(!open)}
@@ -0,0 +1,56 @@
1
+ import { JSDOM } from "jsdom";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { findRanges, matchSpans } from "./find-highlight";
4
+ import { RowStore } from "./rowState";
5
+
6
+ // The suite's setup needs node, so the DOM is built here.
7
+ const { window } = new JSDOM("<!doctype html><body></body>");
8
+ const { document } = window;
9
+
10
+ describe("matchSpans", () => {
11
+ it("finds a match split across pieces", () => {
12
+ expect(matchSpans(["const ", "fo", "o = 1"], "foo =")).toEqual([
13
+ { start: [1, 0], end: [2, 3] },
14
+ ]);
15
+ });
16
+
17
+ it("ends a match at the close of its piece, not the start of the next", () => {
18
+ expect(matchSpans(["ab", "cd"], "ab")).toEqual([
19
+ { start: [0, 0], end: [0, 2] },
20
+ ]);
21
+ });
22
+ });
23
+
24
+ describe("findRanges", () => {
25
+ it("covers markdown and a highlighted code block without touching them", () => {
26
+ document.body.innerHTML = `
27
+ <div id="root">
28
+ <p>Run <strong>npm</strong> test, then <code>npm run lint</code></p>
29
+ <pre><span class="k">npm</span><span> </span><span>ci</span></pre>
30
+ </div>`;
31
+ const root = document.getElementById("root")!;
32
+ const before = root.innerHTML;
33
+ const ranges = findRanges(root, "NPM");
34
+ expect(ranges.map((r) => r.toString())).toEqual(["npm", "npm", "npm"]);
35
+ expect(findRanges(root, "npm ci").map((r) => r.toString())).toEqual([
36
+ "npm ci",
37
+ ]);
38
+ expect(root.innerHTML).toBe(before);
39
+ });
40
+ });
41
+
42
+ describe("RowStore", () => {
43
+ it("tells an open row when find opens it from outside", () => {
44
+ const rows = new RowStore();
45
+ const heard = vi.fn();
46
+ const stop = rows.subscribe("steps:t1", heard);
47
+ rows.set("steps:t1", true);
48
+ rows.set("steps:t1", true);
49
+ rows.set("tool:t2", true);
50
+ expect(heard).toHaveBeenCalledTimes(1);
51
+ expect(rows.get("steps:t1")).toBe(true);
52
+ stop();
53
+ rows.set("steps:t1", false);
54
+ expect(heard).toHaveBeenCalledTimes(1);
55
+ });
56
+ });
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Marks find's matches in the rendered conversation with the CSS Custom
3
+ * Highlight API: ranges painted over the text, so markdown, code blocks and
4
+ * React's own DOM are never rewritten. Browsers without it still scroll to
5
+ * the match; they just don't paint it.
6
+ */
7
+
8
+ import { occurrences } from "@/lib/chat/find";
9
+
10
+ // Where a match starts and ends, as [piece, offset] in a run of text pieces.
11
+ export interface Span {
12
+ start: [number, number];
13
+ end: [number, number];
14
+ }
15
+
16
+ // Matches across text split over several nodes (a highlighted code line is
17
+ // many spans), found as if the pieces were one string.
18
+ export function matchSpans(pieces: string[], query: string): Span[] {
19
+ const starts: number[] = [];
20
+ let text = "";
21
+ for (const p of pieces) {
22
+ starts.push(text.length);
23
+ text += p;
24
+ }
25
+ // The piece holding position `i`; an end prefers the piece it closes.
26
+ const at = (i: number, end: boolean): [number, number] => {
27
+ let k = 0;
28
+ for (let j = 0; j < pieces.length; j++) {
29
+ if (starts[j] > i || (end && starts[j] === i && j > 0)) break;
30
+ k = j;
31
+ }
32
+ return [k, i - starts[k]];
33
+ };
34
+ return occurrences(text, query).map((i) => ({
35
+ start: at(i, false),
36
+ end: at(i + query.length, true),
37
+ }));
38
+ }
39
+
40
+ // The ranges `query` covers in `root`'s text.
41
+ export function findRanges(root: Node, query: string): Range[] {
42
+ const doc = root.ownerDocument ?? (root as Document);
43
+ const walker = doc.createTreeWalker(root, 4 /* NodeFilter.SHOW_TEXT */);
44
+ const nodes: Text[] = [];
45
+ for (let n = walker.nextNode(); n; n = walker.nextNode())
46
+ if (n.nodeValue) nodes.push(n as Text);
47
+ return matchSpans(
48
+ nodes.map((n) => n.nodeValue ?? ""),
49
+ query
50
+ ).map(({ start, end }) => {
51
+ const r = doc.createRange();
52
+ r.setStart(nodes[start[0]], start[1]);
53
+ r.setEnd(nodes[end[0]], end[1]);
54
+ return r;
55
+ });
56
+ }
57
+
58
+ // Every open find bar's marks, painted together: there's one registry.
59
+ const painted = new Map<object, { all: Range[]; current: Range | null }>();
60
+
61
+ function repaint() {
62
+ if (typeof CSS === "undefined" || !("highlights" in CSS)) return;
63
+ const all = [...painted.values()];
64
+ CSS.highlights.set("chat-find", new Highlight(...all.flatMap((p) => p.all)));
65
+ CSS.highlights.set(
66
+ "chat-find-current",
67
+ new Highlight(...all.flatMap((p) => (p.current ? [p.current] : [])))
68
+ );
69
+ }
70
+
71
+ export function paint(owner: object, all: Range[], current: Range | null) {
72
+ painted.set(owner, { all, current });
73
+ repaint();
74
+ }
75
+
76
+ export function unpaint(owner: object) {
77
+ if (painted.delete(owner)) repaint();
78
+ }