@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.
- package/README.md +30 -5
- package/app/api/fresh-start/route.ts +19 -0
- package/app/api/workspaces/[id]/orchestrator/rules/[ruleId]/route.ts +60 -0
- package/app/api/workspaces/[id]/orchestrator/rules/presence.ts +29 -0
- package/app/api/workspaces/[id]/orchestrator/rules/route.ts +36 -0
- package/app/globals.css +10 -0
- package/components/Chat/ChatPanel.tsx +87 -4
- package/components/Chat/FindBar.tsx +113 -0
- package/components/Chat/Tools.tsx +1 -1
- package/components/Chat/find-highlight.test.ts +56 -0
- package/components/Chat/find-highlight.ts +78 -0
- package/components/Chat/rowState.tsx +44 -4
- package/components/Chat/stick-to-bottom.ts +7 -1
- package/components/Chat/useFind.ts +253 -0
- package/components/Pane/MobileTabBar.tsx +18 -0
- package/components/Pane/index.tsx +6 -0
- package/components/Workspaces/FreshStartForm.tsx +128 -0
- package/components/Workspaces/RulesForm.tsx +166 -0
- package/components/Workspaces/WorkspaceSettings.tsx +4 -0
- package/components/Workspaces/fresh-start.ts +19 -0
- package/data/chat/useChat.ts +29 -0
- package/data/fresh-start/index.ts +34 -0
- package/data/orchestrators/rules.ts +80 -0
- package/data/push/topics.ts +4 -0
- package/lib/chat/demo.ts +1 -0
- package/lib/chat/events.ts +7 -1
- package/lib/chat/find.ts +278 -0
- package/lib/chat/refresh-limits.ts +5 -0
- package/lib/chat/refresh.ts +82 -0
- package/lib/chat/runner.ts +11 -2
- package/lib/chat/search.ts +117 -0
- package/lib/chat/socket.ts +12 -0
- package/lib/chat/worker/extras.ts +11 -1
- package/lib/chat/worker/host.ts +70 -5
- package/lib/db/changes.ts +3 -0
- package/lib/db/migrations.ts +37 -0
- package/lib/db/types.ts +4 -0
- package/lib/orchestrator/brief.ts +14 -3
- package/lib/orchestrator/digest.ts +339 -0
- package/lib/orchestrator/presence-binding.ts +5 -0
- package/lib/orchestrator/rules.ts +124 -0
- package/lib/orchestrator/serve.ts +11 -0
- package/lib/orchestrator/tool-names.ts +2 -0
- package/lib/orchestrator/tool-schemas.ts +10 -0
- package/lib/orchestrator/tools.ts +4 -0
- package/lib/orchestrator/worker.ts +5 -0
- package/lib/security/presence.ts +2 -1
- package/package.json +1 -1
- 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.
|
|
82
|
-
|
|
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,
|
|
952
|
-
approvals off, also
|
|
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
|
-
<
|
|
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
|
|
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
|
|
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
|
+
}
|
|
@@ -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
|
+
}
|