@saadnvd1/agent-os 0.3.523 → 0.3.527
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 +82 -3
- package/app/api/doctor/route.ts +8 -0
- package/app/api/sessions/[id]/simulator/route.ts +32 -0
- package/app/api/sessions/[id]/simulator/run/route.ts +37 -0
- package/app/api/workspaces/[id]/orchestrator/needs-you/dismiss/route.ts +31 -0
- package/app/api/workspaces/[id]/orchestrator/questions/[questionId]/route.ts +15 -0
- package/app/page.tsx +8 -3
- package/components/Chat/ChatPanel.tsx +96 -8
- package/components/Chat/EventRow.tsx +2 -2
- package/components/Chat/EventTurns.test.ts +56 -0
- package/components/Chat/EventTurns.tsx +141 -0
- package/components/Chat/useFind.ts +5 -3
- package/components/Chat/useGoToItem.ts +71 -0
- package/components/Orchestrator/AsksList.tsx +16 -11
- package/components/Orchestrator/NeedsYouStrip.tsx +148 -0
- package/components/Orchestrator/OrchestratorBar.tsx +27 -2
- package/components/Settings/SettingsDialog.tsx +2 -2
- package/components/Settings/SetupSection.tsx +6 -1
- package/components/Simulator/ScreenshotStrip.test.ts +91 -0
- package/components/Simulator/ScreenshotStrip.tsx +102 -0
- package/components/Simulator/index.ts +1 -0
- package/components/Tasks/TaskRow.tsx +2 -0
- package/components/Welcome/DevicesStep.tsx +90 -0
- package/components/Welcome/HealthList.tsx +69 -0
- package/components/Welcome/ProjectStep.tsx +2 -8
- package/components/Welcome/WelcomeDialog.tsx +31 -7
- package/data/doctor/index.ts +29 -0
- package/data/orchestrators/index.ts +59 -0
- package/data/push/topics.ts +2 -0
- package/data/simulator/index.test.ts +14 -0
- package/data/simulator/index.ts +33 -0
- package/data/simulator/keys.ts +5 -0
- package/hooks/useNeedsYou.ts +53 -0
- package/lib/chat/events.ts +2 -0
- package/lib/chat/find.ts +25 -4
- package/lib/chat/group.ts +50 -2
- package/lib/chat/turns.ts +111 -0
- package/lib/db/changes.ts +6 -0
- package/lib/db/migrations.ts +51 -0
- package/lib/doctor/checks.ts +222 -0
- package/lib/orchestrator/ask-view.ts +13 -0
- package/lib/orchestrator/brief.ts +5 -1
- package/lib/orchestrator/deliver.ts +3 -0
- package/lib/orchestrator/events.ts +19 -0
- package/lib/orchestrator/needs-you-items.ts +104 -0
- package/lib/orchestrator/overview.ts +3 -0
- package/lib/orchestrator/questions.ts +120 -0
- package/lib/orchestrator/serve.ts +11 -0
- package/lib/orchestrator/tool-names.ts +2 -0
- package/lib/orchestrator/tool-schemas.ts +12 -0
- package/lib/orchestrator/tools.ts +4 -0
- package/lib/project-config/agentos.schema.json +19 -0
- package/lib/project-config/brief.ts +3 -0
- package/lib/project-config/schema.ts +10 -0
- package/lib/sessions/end.ts +3 -0
- package/lib/sessions/stop.ts +3 -0
- package/lib/simulator/agent-device.ts +258 -0
- package/lib/simulator/available.ts +5 -0
- package/lib/simulator/brief.ts +18 -0
- package/lib/simulator/http.ts +16 -0
- package/lib/simulator/index.ts +458 -0
- package/lib/simulator/lease.ts +169 -0
- package/lib/simulator/simctl.ts +167 -0
- package/lib/welcome-url.ts +6 -1
- package/package.json +1 -1
- package/server.ts +16 -0
- package/stores/needsYouFocus.ts +13 -0
- package/stores/welcomeUi.test.ts +15 -0
- package/stores/welcomeUi.ts +6 -1
package/README.md
CHANGED
|
@@ -219,10 +219,13 @@ On a fresh install (no sessions, no projects of your own) the web app opens a
|
|
|
219
219
|
short setup at `/?welcome`: it shows each agent CLI as ready, needing a
|
|
220
220
|
sign-in, or missing, installs or signs one in from an embedded terminal, adds
|
|
221
221
|
a first project (a folder, a clone or an empty one), then opens a session in
|
|
222
|
-
it.
|
|
222
|
+
it. A devices step offers to pair a phone, link another machine and set up
|
|
223
|
+
notifications. Every step after the first can be skipped. It is remembered on the server,
|
|
223
224
|
so a phone you pair later doesn't see it, and an install that already has work
|
|
224
225
|
never does. Reopen it from Settings → Setup (**Run setup again**) or at
|
|
225
|
-
`/welcome`; `AGENTOS_SKIP_WELCOME=1` turns it off.
|
|
226
|
+
`/welcome`; `AGENTOS_SKIP_WELCOME=1` turns it off. Settings → Setup & health
|
|
227
|
+
also lists what this machine needs (Node, git, tmux, ripgrep, lsof, an agent
|
|
228
|
+
CLI) with the fix for anything missing (`/api/doctor`).
|
|
226
229
|
|
|
227
230
|
### Supported agents
|
|
228
231
|
|
|
@@ -801,6 +804,63 @@ schema, including the fields later releases implement (MCP servers, apps,
|
|
|
801
804
|
recipes), is in
|
|
802
805
|
[docs/decisions/2026-10-10-agentos-json.md](docs/decisions/2026-10-10-agentos-json.md).
|
|
803
806
|
|
|
807
|
+
### iOS simulator for tasks
|
|
808
|
+
|
|
809
|
+
On a Mac, a task on an iOS app can boot a simulator of its own, put a debug
|
|
810
|
+
build of its worktree on it, tap through screens, read the accessibility tree
|
|
811
|
+
and take screenshots, to check a UI fix before anyone installs a release
|
|
812
|
+
build. A project opts in with a `simulator` block in `agentos.json`:
|
|
813
|
+
|
|
814
|
+
```json
|
|
815
|
+
{
|
|
816
|
+
"ports": { "METRO_PORT": 8100 },
|
|
817
|
+
"simulator": {
|
|
818
|
+
"start": "npm run sim",
|
|
819
|
+
"app": "com.example.app",
|
|
820
|
+
"device": "iPhone 17"
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
```
|
|
824
|
+
|
|
825
|
+
`start` runs in the task's worktree with `SIMULATOR_UDID` (and the task's
|
|
826
|
+
ports) in its environment. It builds if needed, installs the app on that
|
|
827
|
+
device and exits 0 once the app is up; AgentOS builds nothing itself, because
|
|
828
|
+
the project knows its native fingerprint and what to cache. `app` is the
|
|
829
|
+
bundle id, and `device` the model (default `iPhone 17`).
|
|
830
|
+
|
|
831
|
+
A project without the block gets nothing: no brief lines and no tools. A task
|
|
832
|
+
declares nothing and chooses nothing; it runs:
|
|
833
|
+
|
|
834
|
+
```bash
|
|
835
|
+
aos sim open [--no-wait] # make its simulator, run start; waits until it is up
|
|
836
|
+
aos sim status | close
|
|
837
|
+
aos sim snapshot -i # accessibility tree with @e1, @e2... refs
|
|
838
|
+
aos sim click @e3 | fill @e5 "text" | scroll | swipe | back | home | find | wait
|
|
839
|
+
aos sim screenshot [path] # prints a PNG path under ~/.agent-os/simulators/<task>/shots
|
|
840
|
+
aos sim logs --last 2m # the app's recent log
|
|
841
|
+
```
|
|
842
|
+
|
|
843
|
+
- **Driving** is [agent-device](https://github.com/callstack/agent-device) (MIT),
|
|
844
|
+
pinned and installed on first use into `~/.agent-os/tools`, with its own
|
|
845
|
+
state dir. Only the commands above are accepted; the device and session are
|
|
846
|
+
filled in by the server, so a task can only reach its own simulator.
|
|
847
|
+
- **One simulator per task**, cloned from a base device AgentOS makes once on
|
|
848
|
+
the newest iOS runtime. At most two are booted at once across all tasks
|
|
849
|
+
(`AGENTOS_SIMULATOR_MAX`); one idle for 15 minutes is shut down and boots
|
|
850
|
+
again on its next command. The XCTest runner builds the first time per Xcode
|
|
851
|
+
version (minutes), then is cached.
|
|
852
|
+
- **Cleanup**: a task's simulator is deleted when it is done, signed off,
|
|
853
|
+
dropped, deleted or moved away, and shut down when stopped. A sweep at start
|
|
854
|
+
and every 5 minutes also removes those of tasks that ended or crashed.
|
|
855
|
+
Devices are named with a per-database prefix and only those are ever
|
|
856
|
+
touched, so a dev instance and the real one never reap each other's.
|
|
857
|
+
- **Mac only.** On any other machine, including a linked one a task was moved
|
|
858
|
+
to, the brief says the simulator is unavailable and `aos sim` says the same;
|
|
859
|
+
nothing is forwarded between machines.
|
|
860
|
+
- **Optional.** No merge, review or release check reads simulator state.
|
|
861
|
+
Screenshots are written outside the worktree, and the brief tells the task
|
|
862
|
+
not to put them in commits or PR bodies unless they belong there.
|
|
863
|
+
|
|
804
864
|
### Stacks
|
|
805
865
|
|
|
806
866
|
A project whose board is linked to LumifyHub can run the whole board as
|
|
@@ -1232,7 +1292,7 @@ it may do on its own, and the lines that always come back to you as asks.
|
|
|
1232
1292
|
workspace, or the project already at that path moves in from no workspace;
|
|
1233
1293
|
folders that hold secrets and projects of another workspace are refused),
|
|
1234
1294
|
`note`,
|
|
1235
|
-
`rule_add`, `rule_remove`, `review`, `sign_off`, `arm`, `disarm`, `remind_me`, `reminders`, `cancel_reminder` and `
|
|
1295
|
+
`rule_add`, `rule_remove`, `review`, `sign_off`, `arm`, `disarm`, `remind_me`, `reminders`, `cancel_reminder`, `ask_saad`, `question` and `resolve_question`. All are served in-process to its chat, refuse
|
|
1236
1296
|
any target outside its workspace, and validate their arguments. The one
|
|
1237
1297
|
exception is other orchestrators: `orchestrators` lists every workspace and
|
|
1238
1298
|
whether it has one, and `message_orchestrator` messages another workspace's
|
|
@@ -1442,6 +1502,25 @@ approved`). An approval covers that one item only: a held task's approval
|
|
|
1442
1502
|
fold into one, a declined subject isn't asked again for 6 hours, and a
|
|
1443
1503
|
"decision" that reads as money, outbound, irreversible or credentials is
|
|
1444
1504
|
filed as that hard line.
|
|
1505
|
+
- **Readable when busy:** in the orchestrator's chat, anything AgentOS
|
|
1506
|
+
sent it (a PR opened, CI green, a task woken, a schedule, a task's message)
|
|
1507
|
+
and the orchestrator's reply fold into one line: an icon, the task, how the
|
|
1508
|
+
reply starts, the time. Tap to read the whole reply; runs of them group as
|
|
1509
|
+
"5 updates". What you typed, and the replies to it, stay whole, and so do
|
|
1510
|
+
your own decisions and any turn waiting on an approval or showing a plan or
|
|
1511
|
+
an error. Where a message came from is recorded when it is delivered;
|
|
1512
|
+
older messages are told apart by their opening text only.
|
|
1513
|
+
A **Needs you** strip above the composer lists everything waiting on you
|
|
1514
|
+
until it is resolved: open asks (escalated gates and brakes too), questions
|
|
1515
|
+
the orchestrator put to you in chat, its chat waiting on an approval, and
|
|
1516
|
+
tasks that need you. Each is one line that jumps to the ask, the message or
|
|
1517
|
+
the session, with a count when collapsed. The orchestrator marks a chat
|
|
1518
|
+
question with `question` (the same words while open are the same
|
|
1519
|
+
question) and clears it with `resolve_question`; the strip's Reply quotes
|
|
1520
|
+
it into the composer and resolves it when you send. Dismissing an item
|
|
1521
|
+
hides it on all your devices and resolves nothing: an ask stays open on
|
|
1522
|
+
its list and in the counts. Questions about a task clear when it is merged
|
|
1523
|
+
or dropped.
|
|
1445
1524
|
- **Proof it's you:** only this machine, the tailnet, or a paired device you
|
|
1446
1525
|
switched to "Can approve" in Devices may answer asks or pause. Approving a
|
|
1447
1526
|
hard line, a gate, a brake or a new passkey, Resume, changing the
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { runDoctor } from "@/lib/doctor/checks";
|
|
3
|
+
|
|
4
|
+
// Whether this machine has what AgentOS needs, and how to fix what it lacks.
|
|
5
|
+
export async function GET(request: NextRequest) {
|
|
6
|
+
const fresh = request.nextUrl.searchParams.has("fresh");
|
|
7
|
+
return NextResponse.json({ checks: await runDoctor(fresh) });
|
|
8
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import {
|
|
3
|
+
openSimulator,
|
|
4
|
+
releaseSimulator,
|
|
5
|
+
simulatorStatus,
|
|
6
|
+
} from "@/lib/simulator";
|
|
7
|
+
import { simulatorFailure } from "@/lib/simulator/http";
|
|
8
|
+
|
|
9
|
+
type Ctx = { params: Promise<{ id: string }> };
|
|
10
|
+
|
|
11
|
+
// GET /api/sessions/[id]/simulator - the task's simulator: state and latest shots
|
|
12
|
+
export async function GET(_request: NextRequest, { params }: Ctx) {
|
|
13
|
+
const { id } = await params;
|
|
14
|
+
return NextResponse.json(simulatorStatus(id));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// POST - start it in the background (`aos sim open`); poll GET
|
|
18
|
+
export async function POST(_request: NextRequest, { params }: Ctx) {
|
|
19
|
+
try {
|
|
20
|
+
const { id } = await params;
|
|
21
|
+
return NextResponse.json(openSimulator(id), { status: 202 });
|
|
22
|
+
} catch (error) {
|
|
23
|
+
return simulatorFailure(error);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// DELETE - end it (`aos sim close`): shut down and removed
|
|
28
|
+
export async function DELETE(_request: NextRequest, { params }: Ctx) {
|
|
29
|
+
const { id } = await params;
|
|
30
|
+
await releaseSimulator(id, { remove: true });
|
|
31
|
+
return NextResponse.json(simulatorStatus(id));
|
|
32
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { runSimulator, simulatorLogs } from "@/lib/simulator";
|
|
3
|
+
import { simulatorFailure } from "@/lib/simulator/http";
|
|
4
|
+
|
|
5
|
+
// POST /api/sessions/[id]/simulator/run - one allow-listed agent-device call
|
|
6
|
+
// on the task's own simulator, or its recent app log: { args } | { logs }
|
|
7
|
+
export async function POST(
|
|
8
|
+
request: NextRequest,
|
|
9
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
10
|
+
) {
|
|
11
|
+
try {
|
|
12
|
+
const { id } = await params;
|
|
13
|
+
const body = (await request.json().catch(() => ({}))) as {
|
|
14
|
+
args?: unknown;
|
|
15
|
+
logs?: unknown;
|
|
16
|
+
};
|
|
17
|
+
if (typeof body.logs === "string")
|
|
18
|
+
return NextResponse.json({
|
|
19
|
+
stdout: await simulatorLogs(id, body.logs),
|
|
20
|
+
stderr: "",
|
|
21
|
+
code: 0,
|
|
22
|
+
screenshot: null,
|
|
23
|
+
});
|
|
24
|
+
if (
|
|
25
|
+
!Array.isArray(body.args) ||
|
|
26
|
+
!body.args.length ||
|
|
27
|
+
!body.args.every((a) => typeof a === "string")
|
|
28
|
+
)
|
|
29
|
+
return NextResponse.json(
|
|
30
|
+
{ error: "args must be a list of strings" },
|
|
31
|
+
{ status: 400 }
|
|
32
|
+
);
|
|
33
|
+
return NextResponse.json(await runSimulator(id, body.args as string[]));
|
|
34
|
+
} catch (error) {
|
|
35
|
+
return simulatorFailure(error);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { dismissItem, undismissItem } from "@/lib/orchestrator/questions";
|
|
3
|
+
|
|
4
|
+
type RouteParams = { params: Promise<{ id: string }> };
|
|
5
|
+
|
|
6
|
+
const KEY = /^(ask|question|task|approval):[\w:.-]{1,100}$/;
|
|
7
|
+
|
|
8
|
+
async function keyOf(request: NextRequest): Promise<string | null> {
|
|
9
|
+
const body = (await request.json().catch(() => null)) as {
|
|
10
|
+
key?: unknown;
|
|
11
|
+
} | null;
|
|
12
|
+
return typeof body?.key === "string" && KEY.test(body.key) ? body.key : null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Hides an item from the Needs-you strip. It resolves nothing: an ask stays
|
|
16
|
+
// open on its list and in the counts.
|
|
17
|
+
export async function POST(request: NextRequest, { params }: RouteParams) {
|
|
18
|
+
const { id } = await params;
|
|
19
|
+
const key = await keyOf(request);
|
|
20
|
+
if (!key) return NextResponse.json({ error: "Bad key" }, { status: 400 });
|
|
21
|
+
dismissItem(id, key);
|
|
22
|
+
return NextResponse.json({ ok: true });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
|
26
|
+
const { id } = await params;
|
|
27
|
+
const key = await keyOf(request);
|
|
28
|
+
if (!key) return NextResponse.json({ error: "Bad key" }, { status: 400 });
|
|
29
|
+
undismissItem(id, key);
|
|
30
|
+
return NextResponse.json({ ok: true });
|
|
31
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { resolveQuestion } from "@/lib/orchestrator/questions";
|
|
3
|
+
|
|
4
|
+
type RouteParams = { params: Promise<{ id: string; questionId: string }> };
|
|
5
|
+
|
|
6
|
+
// Saad answered a question the orchestrator put to him in chat.
|
|
7
|
+
export async function POST(_request: NextRequest, { params }: RouteParams) {
|
|
8
|
+
const { id, questionId } = await params;
|
|
9
|
+
const n = Number(questionId);
|
|
10
|
+
if (!Number.isInteger(n) || n < 1)
|
|
11
|
+
return NextResponse.json({ error: "Not a question" }, { status: 400 });
|
|
12
|
+
if (!resolveQuestion(id, n, "saad"))
|
|
13
|
+
return NextResponse.json({ error: "Not open" }, { status: 404 });
|
|
14
|
+
return NextResponse.json({ ok: true });
|
|
15
|
+
}
|
package/app/page.tsx
CHANGED
|
@@ -33,6 +33,7 @@ if (typeof window !== "undefined") {
|
|
|
33
33
|
import { PaneProvider, usePanes } from "@/contexts/PaneContext";
|
|
34
34
|
import { Pane } from "@/components/Pane";
|
|
35
35
|
import { AwayReport } from "@/components/Away/AwayReport";
|
|
36
|
+
import type { BrowserNotifications } from "@/components/Settings/NotificationsSection";
|
|
36
37
|
import { useNotifications } from "@/hooks/useNotifications";
|
|
37
38
|
import { useViewport } from "@/hooks/useViewport";
|
|
38
39
|
import { useViewportHeight } from "@/hooks/useViewportHeight";
|
|
@@ -123,11 +124,15 @@ const CommandPalette = dynamic(
|
|
|
123
124
|
);
|
|
124
125
|
|
|
125
126
|
// Held apart from HomeContent, which re-renders on every status push.
|
|
126
|
-
const AppDialogs = memo(function AppDialogs(
|
|
127
|
+
const AppDialogs = memo(function AppDialogs({
|
|
128
|
+
notifications,
|
|
129
|
+
}: {
|
|
130
|
+
notifications: BrowserNotifications;
|
|
131
|
+
}) {
|
|
127
132
|
return (
|
|
128
133
|
<>
|
|
129
134
|
<TasksDialog />
|
|
130
|
-
<WelcomeDialog />
|
|
135
|
+
<WelcomeDialog notifications={notifications} />
|
|
131
136
|
<AddProjectDialog />
|
|
132
137
|
<MessagesDialog />
|
|
133
138
|
<ArchivedDialog />
|
|
@@ -695,7 +700,7 @@ function HomeContent() {
|
|
|
695
700
|
) : (
|
|
696
701
|
<DesktopView {...viewProps} />
|
|
697
702
|
)}
|
|
698
|
-
<AppDialogs />
|
|
703
|
+
<AppDialogs notifications={browserNotifications} />
|
|
699
704
|
<SettingsDialog notifications={browserNotifications} />
|
|
700
705
|
</>
|
|
701
706
|
);
|
|
@@ -52,6 +52,13 @@ import { PlanCard } from "./PlanCard";
|
|
|
52
52
|
import { ContextMeter } from "./ContextMeter";
|
|
53
53
|
import { SetupCard } from "./SetupCard";
|
|
54
54
|
import { UserTurn } from "./EventRow";
|
|
55
|
+
import { EventGroup } from "./EventTurns";
|
|
56
|
+
import { useGoToItem } from "./useGoToItem";
|
|
57
|
+
import { NeedsYouStrip } from "@/components/Orchestrator/NeedsYouStrip";
|
|
58
|
+
import { useResolveQuestion } from "@/data/orchestrators";
|
|
59
|
+
import { needsYouFocusActions } from "@/stores/needsYouFocus";
|
|
60
|
+
import { hrefWithSession } from "@/lib/session-url";
|
|
61
|
+
import { isQuestionTool } from "@/lib/chat/turns";
|
|
55
62
|
import { useViewport } from "@/hooks/useViewport";
|
|
56
63
|
import { useChatCommands } from "./useChatCommands";
|
|
57
64
|
import { copySelection } from "./copy-selection";
|
|
@@ -167,6 +174,14 @@ const Block = memo(
|
|
|
167
174
|
const undoneActions = useMemo(() => ({ respond }), [respond]);
|
|
168
175
|
return b.type === "tools" ? (
|
|
169
176
|
<ToolGroup tools={b.tools} />
|
|
177
|
+
) : b.type === "events" ? (
|
|
178
|
+
<EventGroup
|
|
179
|
+
id={b.id}
|
|
180
|
+
turns={b.turns}
|
|
181
|
+
renderReplies={(items) => (
|
|
182
|
+
<Timeline blocks={groupTimeline(items)} actions={undoneActions} />
|
|
183
|
+
)}
|
|
184
|
+
/>
|
|
170
185
|
) : b.type === "undone" ? (
|
|
171
186
|
<UndoneBlock
|
|
172
187
|
undo={b.undo}
|
|
@@ -367,7 +382,10 @@ export function ChatPanel({
|
|
|
367
382
|
focusComposer,
|
|
368
383
|
});
|
|
369
384
|
|
|
370
|
-
const blocks = useMemo(
|
|
385
|
+
const blocks = useMemo(
|
|
386
|
+
() => groupTimeline(items, { foldEvents: !!orchestratorOf }),
|
|
387
|
+
[items, orchestratorOf]
|
|
388
|
+
);
|
|
371
389
|
const tasks = useMemo(() => {
|
|
372
390
|
if (!earlierTasks.length) return items;
|
|
373
391
|
const loaded = new Set(items.map((i) => i.id));
|
|
@@ -461,7 +479,30 @@ export function ChatPanel({
|
|
|
461
479
|
if (el && o.hasMore && !o.loadingOlder && el.scrollTop < el.clientHeight)
|
|
462
480
|
o.loadOlder();
|
|
463
481
|
}, []);
|
|
482
|
+
const scrollToBlock = useCallback(async (key: string) => {
|
|
483
|
+
// The row may only just have been added.
|
|
484
|
+
await new Promise((r) => requestAnimationFrame(r));
|
|
485
|
+
listRef.current
|
|
486
|
+
?.querySelector(`[data-block="${CSS.escape(key)}"]`)
|
|
487
|
+
?.scrollIntoView({ block: "center" });
|
|
488
|
+
}, []);
|
|
489
|
+
const goTo = useGoToItem({
|
|
490
|
+
items,
|
|
491
|
+
hasMore,
|
|
492
|
+
loadingOlder,
|
|
493
|
+
loadOlder,
|
|
494
|
+
rows: rowState,
|
|
495
|
+
release,
|
|
496
|
+
scrollToBlock,
|
|
497
|
+
foldEvents: !!orchestratorOf,
|
|
498
|
+
});
|
|
499
|
+
const resolveQuestion = useResolveQuestion(orchestratorOf ?? "");
|
|
500
|
+
// The question the next message answers, resolved when it is sent.
|
|
501
|
+
const [replyTo, setReplyTo] = useState<{ id: number; text: string } | null>(
|
|
502
|
+
null
|
|
503
|
+
);
|
|
464
504
|
const find = useFind({
|
|
505
|
+
foldEvents: !!orchestratorOf,
|
|
465
506
|
search,
|
|
466
507
|
items,
|
|
467
508
|
hasMore,
|
|
@@ -469,13 +510,7 @@ export function ChatPanel({
|
|
|
469
510
|
loadOlder,
|
|
470
511
|
rows: rowState,
|
|
471
512
|
release,
|
|
472
|
-
scrollToBlock
|
|
473
|
-
// The row may only just have been added.
|
|
474
|
-
await new Promise((r) => requestAnimationFrame(r));
|
|
475
|
-
listRef.current
|
|
476
|
-
?.querySelector(`[data-block="${CSS.escape(key)}"]`)
|
|
477
|
-
?.scrollIntoView({ block: "center" });
|
|
478
|
-
}, []),
|
|
513
|
+
scrollToBlock,
|
|
479
514
|
scroller: useCallback(() => listRef.current, []),
|
|
480
515
|
});
|
|
481
516
|
const activeRef = useRef(active);
|
|
@@ -598,6 +633,55 @@ export function ChatPanel({
|
|
|
598
633
|
)}
|
|
599
634
|
</div>
|
|
600
635
|
<div className="mx-auto w-full max-w-3xl px-3 pb-3">
|
|
636
|
+
{orchestratorOf && (
|
|
637
|
+
<NeedsYouStrip
|
|
638
|
+
workspaceId={orchestratorOf}
|
|
639
|
+
chatWaiting={state === "waiting"}
|
|
640
|
+
onGo={(item) => {
|
|
641
|
+
if (item.kind === "ask") needsYouFocusActions.ask(item.ask.id);
|
|
642
|
+
else if (item.kind === "chat") toEnd();
|
|
643
|
+
else if (item.kind === "task") {
|
|
644
|
+
window.history.pushState(
|
|
645
|
+
null,
|
|
646
|
+
"",
|
|
647
|
+
hrefWithSession(location.href, item.sessionId)
|
|
648
|
+
);
|
|
649
|
+
window.dispatchEvent(new PopStateEvent("popstate"));
|
|
650
|
+
} else
|
|
651
|
+
goTo((all) => {
|
|
652
|
+
const asked = all.findLast(
|
|
653
|
+
(i) =>
|
|
654
|
+
isQuestionTool(i) &&
|
|
655
|
+
(i as { input?: { text?: string } }).input?.text ===
|
|
656
|
+
item.question.text
|
|
657
|
+
);
|
|
658
|
+
return asked?.id ?? null;
|
|
659
|
+
});
|
|
660
|
+
}}
|
|
661
|
+
onReply={(item) => {
|
|
662
|
+
setReplyTo({ id: item.question.id, text: item.question.text });
|
|
663
|
+
setInsert({
|
|
664
|
+
text: quoteMarkdown(item.question.text),
|
|
665
|
+
at: Date.now(),
|
|
666
|
+
});
|
|
667
|
+
}}
|
|
668
|
+
/>
|
|
669
|
+
)}
|
|
670
|
+
{replyTo && (
|
|
671
|
+
<div className="text-muted-foreground mb-2 flex min-h-11 items-center gap-2 rounded-lg px-2 text-xs">
|
|
672
|
+
<span className="min-w-0 flex-1 truncate">
|
|
673
|
+
Answering: {replyTo.text}
|
|
674
|
+
</span>
|
|
675
|
+
<button
|
|
676
|
+
type="button"
|
|
677
|
+
onClick={() => setReplyTo(null)}
|
|
678
|
+
aria-label="Stop answering"
|
|
679
|
+
className="hover:text-foreground flex size-11 shrink-0 items-center justify-center"
|
|
680
|
+
>
|
|
681
|
+
×
|
|
682
|
+
</button>
|
|
683
|
+
</div>
|
|
684
|
+
)}
|
|
601
685
|
<ActivityLine
|
|
602
686
|
items={items}
|
|
603
687
|
state={state}
|
|
@@ -660,6 +744,10 @@ export function ChatPanel({
|
|
|
660
744
|
});
|
|
661
745
|
setAsIs(false);
|
|
662
746
|
send(text, images.length ? images : undefined, compact);
|
|
747
|
+
if (replyTo) {
|
|
748
|
+
resolveQuestion.mutate(replyTo.id);
|
|
749
|
+
setReplyTo(null);
|
|
750
|
+
}
|
|
663
751
|
}}
|
|
664
752
|
onStop={interrupt}
|
|
665
753
|
commands={freshStart ? withFresh(commands) : commands}
|
|
@@ -19,7 +19,7 @@ import { useRowState } from "./rowState";
|
|
|
19
19
|
|
|
20
20
|
type UserItem = Extract<ChatItem, { kind: "user" }>;
|
|
21
21
|
|
|
22
|
-
const ICON: Record<ChatOriginKind, LucideIcon> = {
|
|
22
|
+
export const ICON: Record<ChatOriginKind, LucideIcon> = {
|
|
23
23
|
event: Zap,
|
|
24
24
|
peer: Bot,
|
|
25
25
|
schedule: CalendarClock,
|
|
@@ -31,7 +31,7 @@ const ICON: Record<ChatOriginKind, LucideIcon> = {
|
|
|
31
31
|
// and it opens on a tap.
|
|
32
32
|
const SHORT = 32;
|
|
33
33
|
|
|
34
|
-
const clock = (at: number) =>
|
|
34
|
+
export const clock = (at: number) =>
|
|
35
35
|
new Date(at).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
|
36
36
|
|
|
37
37
|
// Opens the session through its address, the way a shared link does.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { createElement } from "react";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import type { ChatItem } from "@/lib/chat/events";
|
|
5
|
+
import { eventTurnAt } from "@/lib/chat/turns";
|
|
6
|
+
import { EventGroup } from "./EventTurns";
|
|
7
|
+
|
|
8
|
+
const turn = (id: string, task: string, text: string) => {
|
|
9
|
+
const items: ChatItem[] = [
|
|
10
|
+
{
|
|
11
|
+
id,
|
|
12
|
+
kind: "user",
|
|
13
|
+
text: "PR opened",
|
|
14
|
+
tagged: true,
|
|
15
|
+
createdAt: Date.now(),
|
|
16
|
+
origin: {
|
|
17
|
+
kind: "event",
|
|
18
|
+
label: "AgentOS",
|
|
19
|
+
subjects: [{ id: "s", name: task }],
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
{ id: `${id}-a`, kind: "assistant", text, createdAt: Date.now() },
|
|
23
|
+
{ id: `${id}-t`, kind: "turn_end", createdAt: Date.now() },
|
|
24
|
+
];
|
|
25
|
+
return eventTurnAt(items, 0)!.turn;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const render = (turns: ReturnType<typeof turn>[]) =>
|
|
29
|
+
renderToStaticMarkup(
|
|
30
|
+
createElement(EventGroup, {
|
|
31
|
+
id: "g",
|
|
32
|
+
turns,
|
|
33
|
+
renderReplies: () => "FULL-REPLY-BODY",
|
|
34
|
+
})
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
describe("EventGroup", () => {
|
|
38
|
+
it("shows one line for a turn: task and how the reply starts, not the reply", () => {
|
|
39
|
+
const html = render([turn("e1", "billing", "Merged #88.\n\nMore detail.")]);
|
|
40
|
+
expect(html).toContain("billing");
|
|
41
|
+
expect(html).toContain("Merged #88.");
|
|
42
|
+
expect(html).not.toContain("FULL-REPLY-BODY");
|
|
43
|
+
expect(html).not.toContain("More detail");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("folds several into a count, collapsed", () => {
|
|
47
|
+
const html = render([
|
|
48
|
+
turn("e1", "billing", "one"),
|
|
49
|
+
turn("e2", "api", "two"),
|
|
50
|
+
turn("e3", "web", "three"),
|
|
51
|
+
]);
|
|
52
|
+
expect(html).toContain("3 updates");
|
|
53
|
+
expect(html).toContain('aria-expanded="false"');
|
|
54
|
+
expect(html).not.toContain("FULL-REPLY-BODY");
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import { ChevronRight, Loader2, MessageCircleQuestion } from "lucide-react";
|
|
5
|
+
import type { ChatItem } from "@/lib/chat/events";
|
|
6
|
+
import type { EventTurn } from "@/lib/chat/turns";
|
|
7
|
+
import { cn } from "@/lib/utils";
|
|
8
|
+
import { clock, EventRow, ICON } from "./EventRow";
|
|
9
|
+
import { useRowState } from "./rowState";
|
|
10
|
+
|
|
11
|
+
type Render = (items: ChatItem[]) => ReactNode;
|
|
12
|
+
|
|
13
|
+
// A tap that ends a text selection isn't a tap.
|
|
14
|
+
const tapped = () => !!window.getSelection()?.isCollapsed;
|
|
15
|
+
|
|
16
|
+
function Chevron({ open }: { open: boolean }) {
|
|
17
|
+
return (
|
|
18
|
+
<ChevronRight
|
|
19
|
+
className={cn(
|
|
20
|
+
"text-muted-foreground/70 h-3.5 w-3.5 shrink-0 transition-transform",
|
|
21
|
+
open && "rotate-90"
|
|
22
|
+
)}
|
|
23
|
+
aria-hidden
|
|
24
|
+
/>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function Time({ at }: { at: number }) {
|
|
29
|
+
return (
|
|
30
|
+
<time
|
|
31
|
+
dateTime={new Date(at).toISOString()}
|
|
32
|
+
className="text-muted-foreground/60 shrink-0 font-mono text-[10px]"
|
|
33
|
+
>
|
|
34
|
+
{clock(at)}
|
|
35
|
+
</time>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// One event and the reply to it, on a line: what it was about, how the reply
|
|
40
|
+
// starts, when. A tap opens the whole exchange.
|
|
41
|
+
export function EventTurnRow({
|
|
42
|
+
turn,
|
|
43
|
+
renderReplies,
|
|
44
|
+
}: {
|
|
45
|
+
turn: EventTurn;
|
|
46
|
+
renderReplies: Render;
|
|
47
|
+
}) {
|
|
48
|
+
const [open, setOpen] = useRowState(`eventturn:${turn.id}`, false);
|
|
49
|
+
const Icon = ICON[turn.origin.kind];
|
|
50
|
+
const toggle = () => tapped() && setOpen(!open);
|
|
51
|
+
return (
|
|
52
|
+
<div data-origin={turn.origin.kind} data-event-turn={turn.id}>
|
|
53
|
+
<button
|
|
54
|
+
type="button"
|
|
55
|
+
onClick={toggle}
|
|
56
|
+
aria-expanded={open}
|
|
57
|
+
className="text-muted-foreground hover:bg-foreground/[0.04] flex min-h-11 w-full items-center gap-2 rounded-lg px-2.5 text-left text-xs md:min-h-9"
|
|
58
|
+
>
|
|
59
|
+
<Icon className="text-muted-foreground/70 h-3.5 w-3.5 shrink-0" />
|
|
60
|
+
<span className="text-foreground/80 max-w-[40%] min-w-0 shrink-0 truncate font-medium">
|
|
61
|
+
{turn.task}
|
|
62
|
+
</span>
|
|
63
|
+
{turn.hasQuestion && (
|
|
64
|
+
<MessageCircleQuestion
|
|
65
|
+
className="h-3.5 w-3.5 shrink-0 text-amber-600 dark:text-amber-400"
|
|
66
|
+
aria-label="Asked a question"
|
|
67
|
+
/>
|
|
68
|
+
)}
|
|
69
|
+
<span className="min-w-0 flex-1 truncate">{turn.summary}</span>
|
|
70
|
+
{turn.live && (
|
|
71
|
+
<Loader2
|
|
72
|
+
className="h-3 w-3 shrink-0 animate-spin"
|
|
73
|
+
aria-label="Working"
|
|
74
|
+
/>
|
|
75
|
+
)}
|
|
76
|
+
<Time at={turn.at} />
|
|
77
|
+
<Chevron open={open} />
|
|
78
|
+
</button>
|
|
79
|
+
{open && (
|
|
80
|
+
<div className="border-border/60 mt-1 ml-3 flex flex-col gap-3 border-l pl-3">
|
|
81
|
+
<EventRow item={turn.user} origin={turn.origin} />
|
|
82
|
+
{renderReplies(turn.replies)}
|
|
83
|
+
</div>
|
|
84
|
+
)}
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const span = (turns: EventTurn[]) =>
|
|
90
|
+
turns.length > 1 && clock(turns[0].at) !== clock(turns.at(-1)!.at)
|
|
91
|
+
? `${clock(turns[0].at)}–${clock(turns.at(-1)!.at)}`
|
|
92
|
+
: clock(turns[0].at);
|
|
93
|
+
|
|
94
|
+
// Consecutive event turns: "5 updates", opened to their rows. A single one is
|
|
95
|
+
// just its row. While the newest is being answered it stays in view.
|
|
96
|
+
export function EventGroup({
|
|
97
|
+
id,
|
|
98
|
+
turns,
|
|
99
|
+
renderReplies,
|
|
100
|
+
}: {
|
|
101
|
+
id: string;
|
|
102
|
+
turns: EventTurn[];
|
|
103
|
+
renderReplies: Render;
|
|
104
|
+
}) {
|
|
105
|
+
const [open, setOpen] = useRowState(`events:${id}`, false);
|
|
106
|
+
if (turns.length === 1)
|
|
107
|
+
return <EventTurnRow turn={turns[0]} renderReplies={renderReplies} />;
|
|
108
|
+
const names = [...new Set(turns.map((t) => t.task))];
|
|
109
|
+
const last = turns.at(-1)!;
|
|
110
|
+
return (
|
|
111
|
+
<div data-event-group={id}>
|
|
112
|
+
<button
|
|
113
|
+
type="button"
|
|
114
|
+
onClick={() => tapped() && setOpen(!open)}
|
|
115
|
+
aria-expanded={open}
|
|
116
|
+
className="text-muted-foreground hover:bg-foreground/[0.04] bg-foreground/[0.03] flex min-h-11 w-full items-center gap-2 rounded-lg px-2.5 text-left text-xs md:min-h-9"
|
|
117
|
+
>
|
|
118
|
+
<span className="text-foreground/80 shrink-0 font-medium">
|
|
119
|
+
{turns.length} updates
|
|
120
|
+
</span>
|
|
121
|
+
<span className="min-w-0 flex-1 truncate">
|
|
122
|
+
{names.slice(0, 3).join(", ")}
|
|
123
|
+
{names.length > 3 ? ` +${names.length - 3}` : ""}
|
|
124
|
+
</span>
|
|
125
|
+
<span className="text-muted-foreground/60 shrink-0 font-mono text-[10px]">
|
|
126
|
+
{span(turns)}
|
|
127
|
+
</span>
|
|
128
|
+
<Chevron open={open} />
|
|
129
|
+
</button>
|
|
130
|
+
{open ? (
|
|
131
|
+
<div className="mt-1 flex flex-col">
|
|
132
|
+
{turns.map((t) => (
|
|
133
|
+
<EventTurnRow key={t.id} turn={t} renderReplies={renderReplies} />
|
|
134
|
+
))}
|
|
135
|
+
</div>
|
|
136
|
+
) : (
|
|
137
|
+
last.live && <EventTurnRow turn={last} renderReplies={renderReplies} />
|
|
138
|
+
)}
|
|
139
|
+
</div>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
@@ -49,6 +49,8 @@ type Pinned = FindTarget & { block: string };
|
|
|
49
49
|
export type FindStatus = "idle" | "searching" | "done" | "unavailable";
|
|
50
50
|
|
|
51
51
|
export interface FindDeps {
|
|
52
|
+
// The orchestrator's chat folds events into rows that find has to open.
|
|
53
|
+
foldEvents?: boolean;
|
|
52
54
|
search: (query: string, before?: number) => Promise<SearchResult | null>;
|
|
53
55
|
items: ChatItem[];
|
|
54
56
|
hasMore: boolean;
|
|
@@ -147,11 +149,11 @@ export function useFind(deps: FindDeps) {
|
|
|
147
149
|
// pull the reader back to it.
|
|
148
150
|
const shown = useRef<FindTarget | null>(null);
|
|
149
151
|
const [pinned, setPinned] = useState<Pinned | null>(null);
|
|
150
|
-
const { items, hasMore, loadingOlder } = deps;
|
|
152
|
+
const { items, hasMore, loadingOlder, foldEvents } = deps;
|
|
151
153
|
useEffect(() => {
|
|
152
154
|
if (!open || !target || shown.current === target) return;
|
|
153
155
|
const { loadOlder, rows, release, scrollToBlock } = d.current;
|
|
154
|
-
const where = locate(items, target.id);
|
|
156
|
+
const where = locate(items, target.id, { foldEvents });
|
|
155
157
|
if (!where) {
|
|
156
158
|
if (!hasMore) shown.current = target;
|
|
157
159
|
else if (!loadingOlder) loadOlder();
|
|
@@ -163,7 +165,7 @@ export function useFind(deps: FindDeps) {
|
|
|
163
165
|
const pin = () =>
|
|
164
166
|
shown.current === target && setPinned({ ...target, block: where.block });
|
|
165
167
|
scrollToBlock(where.block).then(pin, pin);
|
|
166
|
-
}, [open, target, items, hasMore, loadingOlder]);
|
|
168
|
+
}, [open, target, items, hasMore, loadingOlder, foldEvents]);
|
|
167
169
|
|
|
168
170
|
// Paints every match on screen, and the current one apart; the current one
|
|
169
171
|
// is scrolled to once its row (and a tool's loaded output) shows it.
|