@saadnvd1/agent-os 0.3.524 → 0.3.528

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 (72) hide show
  1. package/README.md +52 -19
  2. package/app/api/doctor/route.ts +8 -0
  3. package/app/api/tasks/route.test.ts +22 -0
  4. package/app/api/tasks/route.ts +14 -2
  5. package/app/api/workspaces/[id]/orchestrator/needs-you/dismiss/route.ts +31 -0
  6. package/app/api/workspaces/[id]/orchestrator/questions/[questionId]/route.ts +15 -0
  7. package/app/page.tsx +8 -3
  8. package/components/Chat/ChatPanel.tsx +96 -8
  9. package/components/Chat/EventRow.tsx +2 -2
  10. package/components/Chat/EventTurns.test.ts +56 -0
  11. package/components/Chat/EventTurns.tsx +141 -0
  12. package/components/Chat/useFind.ts +5 -3
  13. package/components/Chat/useGoToItem.ts +71 -0
  14. package/components/Orchestrator/AsksList.tsx +16 -11
  15. package/components/Orchestrator/NeedsYouStrip.tsx +148 -0
  16. package/components/Orchestrator/OrchestratorBar.tsx +27 -2
  17. package/components/Settings/SettingsDialog.tsx +2 -2
  18. package/components/Settings/SetupSection.tsx +6 -1
  19. package/components/Simulator/ScreenshotStrip.test.ts +91 -0
  20. package/components/Simulator/ScreenshotStrip.tsx +102 -0
  21. package/components/Simulator/index.ts +1 -0
  22. package/components/Tasks/TaskRow.tsx +2 -0
  23. package/components/Welcome/DevicesStep.tsx +90 -0
  24. package/components/Welcome/HealthList.tsx +69 -0
  25. package/components/Welcome/ProjectStep.tsx +2 -8
  26. package/components/Welcome/WelcomeDialog.tsx +31 -7
  27. package/data/doctor/index.ts +29 -0
  28. package/data/orchestrators/index.ts +59 -0
  29. package/data/push/topics.ts +2 -0
  30. package/data/simulator/index.test.ts +14 -0
  31. package/data/simulator/index.ts +33 -0
  32. package/data/simulator/keys.ts +5 -0
  33. package/hooks/useNeedsYou.ts +53 -0
  34. package/lib/agent-options.ts +1 -0
  35. package/lib/agents/probe.ts +21 -0
  36. package/lib/agents/setup-commands.ts +4 -0
  37. package/lib/chat/capabilities.ts +7 -1
  38. package/lib/chat/drivers/copilot-approvals.ts +99 -0
  39. package/lib/chat/drivers/copilot-mapper.ts +203 -0
  40. package/lib/chat/drivers/copilot-rpc.ts +126 -0
  41. package/lib/chat/drivers/copilot.ts +174 -0
  42. package/lib/chat/drivers/index.ts +2 -0
  43. package/lib/chat/events.ts +2 -0
  44. package/lib/chat/find.ts +25 -4
  45. package/lib/chat/group.ts +50 -2
  46. package/lib/chat/turns.ts +111 -0
  47. package/lib/db/changes.ts +6 -0
  48. package/lib/db/migrations.ts +43 -0
  49. package/lib/doctor/checks.ts +222 -0
  50. package/lib/orchestrator/ask-view.ts +13 -0
  51. package/lib/orchestrator/brief.ts +5 -1
  52. package/lib/orchestrator/deliver.ts +3 -0
  53. package/lib/orchestrator/events.ts +19 -0
  54. package/lib/orchestrator/needs-you-items.ts +104 -0
  55. package/lib/orchestrator/overview.ts +3 -0
  56. package/lib/orchestrator/questions.ts +120 -0
  57. package/lib/orchestrator/serve.ts +11 -0
  58. package/lib/orchestrator/tool-names.ts +2 -0
  59. package/lib/orchestrator/tool-schemas.ts +12 -0
  60. package/lib/orchestrator/tools.ts +4 -0
  61. package/lib/providers/registry.ts +19 -0
  62. package/lib/providers/resume-ids.ts +30 -0
  63. package/lib/tasks/code-review.ts +5 -2
  64. package/lib/tasks/index.ts +45 -10
  65. package/lib/tasks/queue.ts +7 -2
  66. package/lib/welcome-url.ts +6 -1
  67. package/lib/worktree-hooks.ts +1 -1
  68. package/package.json +1 -1
  69. package/scripts/setup.sh +1 -1
  70. package/stores/needsYouFocus.ts +13 -0
  71. package/stores/welcomeUi.test.ts +15 -0
  72. package/stores/welcomeUi.ts +6 -1
package/README.md CHANGED
@@ -126,7 +126,8 @@ You need Node.js 20+, tmux, and at least one agent CLI:
126
126
  Pi,
127
127
  [Kilo Code CLI](https://kilo.ai/docs/cli),
128
128
  [Gemini CLI](https://github.com/google-gemini/gemini-cli),
129
- [Aider](https://aider.chat/), [Cursor CLI](https://cursor.com/cli) or Amp.
129
+ [Aider](https://aider.chat/), [Cursor CLI](https://cursor.com/cli), Amp or
130
+ [GitHub Copilot CLI](https://github.com/github/copilot-cli).
130
131
 
131
132
  ```bash
132
133
  npm install -g @saadnvd1/agent-os
@@ -219,25 +220,29 @@ On a fresh install (no sessions, no projects of your own) the web app opens a
219
220
  short setup at `/?welcome`: it shows each agent CLI as ready, needing a
220
221
  sign-in, or missing, installs or signs one in from an embedded terminal, adds
221
222
  a first project (a folder, a clone or an empty one), then opens a session in
222
- it. Every step after the first can be skipped. It is remembered on the server,
223
+ it. A devices step offers to pair a phone, link another machine and set up
224
+ notifications. Every step after the first can be skipped. It is remembered on the server,
223
225
  so a phone you pair later doesn't see it, and an install that already has work
224
226
  never does. Reopen it from Settings → Setup (**Run setup again**) or at
225
- `/welcome`; `AGENTOS_SKIP_WELCOME=1` turns it off.
227
+ `/welcome`; `AGENTOS_SKIP_WELCOME=1` turns it off. Settings → Setup & health
228
+ also lists what this machine needs (Node, git, tmux, ripgrep, lsof, an agent
229
+ CLI) with the fix for anything missing (`/api/doctor`).
226
230
 
227
231
  ### Supported agents
228
232
 
229
- | Agent | Chat | Resume | Fork | Auto-approve |
230
- | ----------- | ---- | ------ | ---- | -------------------------------------------- |
231
- | Claude Code | ✅ | ✅ | ✅ | `--dangerously-skip-permissions` |
232
- | Codex | ✅ | ✅ | ✅ | `--dangerously-bypass-approvals-and-sandbox` |
233
- | OpenCode | ✅ | ✅ | ✅ | `--auto` |
234
- | Pi | ✅ | ✅ | ✅ | Never asks |
235
- | Kilo Code | ❌ | ✅ | ✅ | Config file |
236
- | Gemini CLI | ❌ | ❌ | ❌ | `--yolo` |
237
- | Aider | ❌ | ❌ | ❌ | `--yes` |
238
- | Cursor CLI | ❌ | ✅ | ❌ | `--force` |
239
- | Amp | ❌ | ✅ | ❌ | `--dangerously-allow-all` |
240
- | Oh My Pi | ❌ | ❌ | ❌ | N/A |
233
+ | Agent | Chat | Resume | Fork | Auto-approve |
234
+ | -------------- | ---- | ------ | ---- | -------------------------------------------- |
235
+ | Claude Code | ✅ | ✅ | ✅ | `--dangerously-skip-permissions` |
236
+ | Codex | ✅ | ✅ | ✅ | `--dangerously-bypass-approvals-and-sandbox` |
237
+ | OpenCode | ✅ | ✅ | ✅ | `--auto` |
238
+ | Pi | ✅ | ✅ | ✅ | Never asks |
239
+ | Kilo Code | ❌ | ✅ | ✅ | Config file |
240
+ | Gemini CLI | ❌ | ❌ | ❌ | `--yolo` |
241
+ | Aider | ❌ | ❌ | ❌ | `--yes` |
242
+ | Cursor CLI | ❌ | ✅ | ❌ | `--force` |
243
+ | Amp | ❌ | ✅ | ❌ | `--dangerously-allow-all` |
244
+ | Oh My Pi | ❌ | ❌ | ❌ | N/A |
245
+ | GitHub Copilot | ✅ | ✅ | ❌ | `--allow-all` |
241
246
 
242
247
  Agents without chat run in the terminal. The new-session picker says when an
243
248
  agent isn't installed or needs a sign-in first, and what to run.
@@ -272,10 +277,16 @@ agent as the terminal, with your own logins: Claude Code through the Agent
272
277
  SDK, Codex through `codex app-server`, OpenCode through its own `opencode
273
278
  serve` (loopback only, behind a per-conversation password) and Pi through its
274
279
  RPC mode. Pi never asks before a tool, so AgentOS loads a small Pi extension
275
- that asks the chat as the access setting says. The **Chat /
280
+ that asks the chat as the access setting says. GitHub Copilot runs through
281
+ `copilot --acp` (the Agent Client Protocol): its permission prompts become
282
+ approval cards under the same access setting, and a conversation resumes with
283
+ `session/load` after a restart. It signs in with `copilot login` or a
284
+ `COPILOT_GITHUB_TOKEN`, `GH_TOKEN` or `GITHUB_TOKEN`; it has no plan mode,
285
+ undo or in-chat model list yet, so it runs on its own default model unless one
286
+ is named. The **Chat /
276
287
  Terminal** switch in the tab bar hands the same conversation between the two:
277
288
  the terminal resumes it (`claude --resume`, `codex resume`, `opencode
278
- --session`, `pi --session`), and switching back closes the
289
+ --session`, `pi --session`, `copilot --resume`), and switching back closes the
279
290
  terminal so only one side drives it. Each chat conversation runs in its own
280
291
  worker process (in tmux, like terminal sessions), so restarting or updating
281
292
  AgentOS never cuts off a turn: the server reconnects to running workers when
@@ -488,7 +499,10 @@ orchestrator's `start_task` with `after`) holds a task until a named task
488
499
  finishes, or until any task still working now does (a running task whose PR
489
500
  was closed or merged doesn't count; with none, it starts at once). The
490
501
  orchestrator's `drop` removes a queued task by its id. Pause stops automatic starts,
491
- and the orchestrator's brakes still apply when its queued tasks start. The
502
+ and the orchestrator's brakes still apply when its queued tasks start. A task runs on Claude Code unless
503
+ `POST /api/tasks` names another `agent` (`"copilot"`): that one runs as a chat on
504
+ this machine, with no plan-first step or role models, and is never picked for
505
+ you. The
492
506
  line is kept in the database across restarts (`GET /api/tasks/queue`; start
493
507
  now, move up or down, or remove with `/api/tasks/queue/<id>`). The sidebar
494
508
  shows them under **Queued** with their place in line or what they wait on,
@@ -1289,7 +1303,7 @@ it may do on its own, and the lines that always come back to you as asks.
1289
1303
  workspace, or the project already at that path moves in from no workspace;
1290
1304
  folders that hold secrets and projects of another workspace are refused),
1291
1305
  `note`,
1292
- `rule_add`, `rule_remove`, `review`, `sign_off`, `arm`, `disarm`, `remind_me`, `reminders`, `cancel_reminder` and `ask_saad`. All are served in-process to its chat, refuse
1306
+ `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
1293
1307
  any target outside its workspace, and validate their arguments. The one
1294
1308
  exception is other orchestrators: `orchestrators` lists every workspace and
1295
1309
  whether it has one, and `message_orchestrator` messages another workspace's
@@ -1499,6 +1513,25 @@ approved`). An approval covers that one item only: a held task's approval
1499
1513
  fold into one, a declined subject isn't asked again for 6 hours, and a
1500
1514
  "decision" that reads as money, outbound, irreversible or credentials is
1501
1515
  filed as that hard line.
1516
+ - **Readable when busy:** in the orchestrator's chat, anything AgentOS
1517
+ sent it (a PR opened, CI green, a task woken, a schedule, a task's message)
1518
+ and the orchestrator's reply fold into one line: an icon, the task, how the
1519
+ reply starts, the time. Tap to read the whole reply; runs of them group as
1520
+ "5 updates". What you typed, and the replies to it, stay whole, and so do
1521
+ your own decisions and any turn waiting on an approval or showing a plan or
1522
+ an error. Where a message came from is recorded when it is delivered;
1523
+ older messages are told apart by their opening text only.
1524
+ A **Needs you** strip above the composer lists everything waiting on you
1525
+ until it is resolved: open asks (escalated gates and brakes too), questions
1526
+ the orchestrator put to you in chat, its chat waiting on an approval, and
1527
+ tasks that need you. Each is one line that jumps to the ask, the message or
1528
+ the session, with a count when collapsed. The orchestrator marks a chat
1529
+ question with `question` (the same words while open are the same
1530
+ question) and clears it with `resolve_question`; the strip's Reply quotes
1531
+ it into the composer and resolves it when you send. Dismissing an item
1532
+ hides it on all your devices and resolves nothing: an ask stays open on
1533
+ its list and in the counts. Questions about a task clear when it is merged
1534
+ or dropped.
1502
1535
  - **Proof it's you:** only this machine, the tailnet, or a paired device you
1503
1536
  switched to "Can approve" in Devices may answer asks or pause. Approving a
1504
1537
  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,22 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { NextRequest } from "next/server";
3
+ import { POST } from "./route";
4
+
5
+ const post = (body: object) =>
6
+ POST(
7
+ new NextRequest("http://localhost/api/tasks", {
8
+ method: "POST",
9
+ body: JSON.stringify({ projectId: "none", prompt: "x", ...body }),
10
+ })
11
+ );
12
+
13
+ describe("POST /api/tasks with another agent's model", () => {
14
+ it.each(["--allow-all-tools", "-m", "a b", "x\ny", "m".repeat(101), 7])(
15
+ "refuses %j before anything is queued",
16
+ async (model) => {
17
+ const res = await post({ agent: "copilot", model });
18
+ expect(res.status).toBe(400);
19
+ expect((await res.json()).error).toBe("Bad model name");
20
+ }
21
+ );
22
+ });
@@ -6,6 +6,8 @@ import { TASK_CAPABILITIES } from "@/lib/tasks/remote";
6
6
  import { ensureProject, type ProjectRef } from "@/lib/tasks/project-ref";
7
7
  import { isSessionId, statusFor } from "@/lib/tasks/move-bundle";
8
8
  import { startView } from "@/lib/sessions/launch";
9
+ import { isValidAgentType } from "@/lib/providers";
10
+ import { isAgentModelName } from "@/lib/providers/registry";
9
11
 
10
12
  // moved and capabilities are for another machine that mirrors these tasks.
11
13
  export async function GET() {
@@ -34,6 +36,7 @@ export async function POST(request: NextRequest) {
34
36
  baseBranch,
35
37
  after,
36
38
  view,
39
+ agent,
37
40
  plan,
38
41
  plan_reason,
39
42
  plan_only,
@@ -43,11 +46,20 @@ export async function POST(request: NextRequest) {
43
46
  project && typeof project === "object"
44
47
  ? (await ensureProject(project as ProjectRef)).id
45
48
  : projectId;
49
+ if (agent !== undefined && !isValidAgentType(agent))
50
+ throw new Error("Unknown agent");
51
+ if (agent && agent !== "claude" && model != null && model !== "")
52
+ if (!isAgentModelName(model)) throw new Error("Bad model name");
46
53
  const opts = {
47
54
  projectId: pid,
48
55
  prompt: String(prompt ?? ""),
49
- // Checked now, so a queued task can't fail on it later.
50
- model: parseOverride(model) ?? undefined,
56
+ agent: agent && agent !== "claude" ? agent : undefined,
57
+ // Checked now, so a queued task can't fail on it later. Another
58
+ // agent's model is its own name for one.
59
+ model:
60
+ agent && agent !== "claude" && typeof model === "string"
61
+ ? model
62
+ : (parseOverride(model) ?? undefined),
51
63
  plan: typeof plan === "boolean" ? plan : undefined,
52
64
  planOnly: plan_only === true ? true : undefined,
53
65
  planReason:
@@ -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(() => groupTimeline(items), [items]);
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: useCallback(async (key: string) => {
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
+ }