@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
@@ -0,0 +1,203 @@
1
+ import type { ChatContext, DriverEvent, UsageTotals } from "../events";
2
+ import { toolTitle } from "../tools";
3
+ import { TurnItems, type ToolStart } from "./turn-items";
4
+
5
+ type P = Record<string, unknown>;
6
+ const str = (v: unknown) => (typeof v === "string" ? v : "");
7
+
8
+ const TODO_STATUS = {
9
+ pending: "pending",
10
+ in_progress: "in_progress",
11
+ completed: "completed",
12
+ } as const;
13
+
14
+ // "*** Update File: f.txt" in an apply_patch the agent made.
15
+ const patchFile = (patch: string) =>
16
+ patch.match(/^\*\*\* (?:Update|Add|Delete) File: (.+)$/m)?.[1]?.trim();
17
+
18
+ export function toolStart(update: P): ToolStart {
19
+ const raw = update.rawInput;
20
+ const input = (typeof raw === "object" && raw ? raw : {}) as P;
21
+ const file =
22
+ str(input.path) ||
23
+ str(input.fileName) ||
24
+ str((update.locations as P[] | undefined)?.[0]?.path) ||
25
+ (typeof raw === "string" ? patchFile(raw) : undefined) ||
26
+ "";
27
+ const title = str(update.title);
28
+ switch (str(update.kind)) {
29
+ case "execute": {
30
+ const command = str(input.command);
31
+ return {
32
+ name: "Bash",
33
+ title: toolTitle("Bash", { command, description: input.description }),
34
+ input: { command, description: input.description },
35
+ };
36
+ }
37
+ case "edit":
38
+ case "delete":
39
+ case "move":
40
+ return {
41
+ name: "Edit",
42
+ title: file ? toolTitle("Edit", { file_path: file }) : title,
43
+ input: { file_path: file },
44
+ };
45
+ case "read":
46
+ if (input.pattern)
47
+ return {
48
+ name: "Glob",
49
+ title: toolTitle("Glob", { pattern: input.pattern }),
50
+ input,
51
+ };
52
+ return {
53
+ name: "Read",
54
+ title: toolTitle("Read", { file_path: file }),
55
+ input: { file_path: file },
56
+ };
57
+ case "search":
58
+ return {
59
+ name: "Grep",
60
+ title: toolTitle("Grep", { pattern: input.pattern ?? input.query }),
61
+ input,
62
+ };
63
+ case "fetch":
64
+ return {
65
+ name: "WebFetch",
66
+ title: toolTitle("WebFetch", { url: input.url }),
67
+ input,
68
+ };
69
+ default:
70
+ return { name: title || "Tool", title: title || "Tool", input: raw };
71
+ }
72
+ }
73
+
74
+ function contentText(update: P): string {
75
+ const parts = (update.content as P[] | undefined) ?? [];
76
+ const text = parts
77
+ .map((c) => (c.type === "content" ? str((c.content as P)?.text) : ""))
78
+ .filter(Boolean)
79
+ .join("\n");
80
+ if (text) return text;
81
+ const raw = (update.rawOutput ?? {}) as P;
82
+ return str(raw.content);
83
+ }
84
+
85
+ // Copilot's session updates as chat items. One per conversation.
86
+ export class CopilotMapper {
87
+ readonly items: TurnItems;
88
+ readonly totals: UsageTotals = {
89
+ costUsd: 0,
90
+ inputTokens: 0,
91
+ outputTokens: 0,
92
+ cacheReadTokens: 0,
93
+ cacheWriteTokens: 0,
94
+ };
95
+ // Text before and after a tool call are separate bubbles.
96
+ private block = 0;
97
+ // A resumed conversation replays its history before it's ready: not ours.
98
+ replaying = false;
99
+
100
+ constructor(private emit: (e: DriverEvent) => void) {
101
+ this.items = new TurnItems(emit);
102
+ }
103
+
104
+ map(update: P): void {
105
+ if (this.replaying) return;
106
+ const text = str((update.content as P | undefined)?.text);
107
+ switch (str(update.sessionUpdate)) {
108
+ case "agent_message_chunk":
109
+ this.items.startTurn();
110
+ return this.items.delta(`m${this.block}`, "assistant", text);
111
+ case "agent_thought_chunk":
112
+ this.items.startTurn();
113
+ return this.items.delta(`r${this.block}`, "reasoning", text);
114
+ case "tool_call":
115
+ return this.started(update);
116
+ case "tool_call_update":
117
+ return this.updated(update);
118
+ case "plan":
119
+ return this.items.todos(
120
+ ((update.entries as P[]) ?? []).map((e) => ({
121
+ text: str(e.content),
122
+ status:
123
+ TODO_STATUS[str(e.status) as keyof typeof TODO_STATUS] ??
124
+ "pending",
125
+ }))
126
+ );
127
+ case "usage_update":
128
+ return this.context(update);
129
+ }
130
+ }
131
+
132
+ private close(): void {
133
+ this.items.finishText(`m${this.block}`, "assistant");
134
+ this.items.finishText(`r${this.block}`, "reasoning");
135
+ this.block++;
136
+ }
137
+
138
+ private started(update: P): void {
139
+ this.items.startTurn();
140
+ this.close();
141
+ this.items.tool(str(update.toolCallId), toolStart(update));
142
+ }
143
+
144
+ private updated(update: P): void {
145
+ const id = str(update.toolCallId);
146
+ const status = str(update.status);
147
+ if (status !== "completed" && status !== "failed") return;
148
+ const start = toolStart(update);
149
+ if (!this.items.hasTool(id)) this.items.tool(id, start);
150
+ const diff = ((update.content as P[]) ?? []).find((c) => c.type === "diff");
151
+ const changed = diff
152
+ ? {
153
+ path: str(diff.path),
154
+ before: str(diff.oldText),
155
+ after: str(diff.newText),
156
+ }
157
+ : undefined;
158
+ this.items.toolDone(
159
+ id,
160
+ status === "failed" ? "error" : "done",
161
+ contentText(update),
162
+ changed
163
+ ? {
164
+ name: "Edit",
165
+ title: toolTitle("Edit", { file_path: changed.path }),
166
+ diff: changed,
167
+ }
168
+ : undefined
169
+ );
170
+ }
171
+
172
+ private context(update: P): void {
173
+ const used = Number(update.used);
174
+ const max = Number(update.size);
175
+ if (!max || !Number.isFinite(used)) return;
176
+ const context: ChatContext = {
177
+ usedTokens: used,
178
+ maxTokens: max,
179
+ percentage: Math.round((used / max) * 1000) / 10,
180
+ categories: [],
181
+ at: Date.now(),
182
+ };
183
+ this.emit({ type: "context", context });
184
+ }
185
+
186
+ // What a finished prompt spent. The CLI counts cached tokens in its input.
187
+ spent(usage: P | undefined): void {
188
+ if (!usage) return;
189
+ const read = Number(usage.cachedReadTokens) || 0;
190
+ const written = Number(usage.cachedWriteTokens) || 0;
191
+ const input = Number(usage.inputTokens) || 0;
192
+ this.totals.inputTokens += Math.max(0, input - read - written);
193
+ this.totals.outputTokens += Number(usage.outputTokens) || 0;
194
+ this.totals.cacheReadTokens += read;
195
+ this.totals.cacheWriteTokens += written;
196
+ }
197
+
198
+ endTurn(end: { interrupted?: boolean; error?: string }): void {
199
+ this.close();
200
+ this.emit({ type: "usage", totals: { ...this.totals } });
201
+ this.items.endTurn(end);
202
+ }
203
+ }
@@ -0,0 +1,126 @@
1
+ import { AgentChild } from "./child";
2
+
3
+ export interface AcpMessage {
4
+ id?: number | string;
5
+ method?: string;
6
+ params?: Record<string, unknown>;
7
+ result?: unknown;
8
+ error?: { code?: number; message?: string };
9
+ }
10
+
11
+ const REQUEST_MS = 60_000;
12
+
13
+ // The GitHub Copilot CLI as an Agent Client Protocol server: JSON-RPC 2.0
14
+ // over the stdio of `copilot --acp`. Its own requests to us (permission
15
+ // prompts) count their ids separately from ours.
16
+ export class CopilotRpc {
17
+ private child: AgentChild;
18
+ private nextId = 1;
19
+ private calls = new Map<
20
+ number,
21
+ { resolve: (r: unknown) => void; reject: (e: Error) => void }
22
+ >();
23
+
24
+ constructor(
25
+ args: string[],
26
+ options: { cwd: string; env: NodeJS.ProcessEnv },
27
+ handlers: {
28
+ notify: (method: string, params: Record<string, unknown>) => void;
29
+ request: (
30
+ id: number | string,
31
+ method: string,
32
+ params: Record<string, unknown>
33
+ ) => void;
34
+ exit: (error: string) => void;
35
+ }
36
+ ) {
37
+ this.child = new AgentChild(
38
+ "copilot",
39
+ ["--acp", "--no-auto-update", ...args],
40
+ {
41
+ ...options,
42
+ onRecord: (r) => {
43
+ const m = r as AcpMessage;
44
+ if (m.method && m.id !== undefined)
45
+ handlers.request(m.id, m.method, m.params ?? {});
46
+ else if (m.method) handlers.notify(m.method, m.params ?? {});
47
+ else if (typeof m.id === "number") this.settle(m);
48
+ },
49
+ onExit: (code, error) => {
50
+ const why =
51
+ error ??
52
+ `GitHub Copilot stopped (exit ${code ?? "signal"})${this.child.errorTail ? `: ${this.child.errorTail}` : ""}`;
53
+ for (const call of this.calls.values()) call.reject(new Error(why));
54
+ this.calls.clear();
55
+ handlers.exit(why);
56
+ },
57
+ }
58
+ );
59
+ }
60
+
61
+ private settle(m: AcpMessage): void {
62
+ const call = this.calls.get(m.id as number);
63
+ if (!call) return;
64
+ this.calls.delete(m.id as number);
65
+ if (m.error)
66
+ call.reject(new Error(m.error.message ?? "GitHub Copilot error"));
67
+ else call.resolve(m.result);
68
+ }
69
+
70
+ // A timeout of 0 waits as long as it takes (a prompt runs a whole turn).
71
+ request<T>(
72
+ method: string,
73
+ params: unknown = {},
74
+ timeoutMs = REQUEST_MS
75
+ ): Promise<T> {
76
+ const id = this.nextId++;
77
+ return new Promise<T>((resolve, reject) => {
78
+ const timer = timeoutMs
79
+ ? setTimeout(() => {
80
+ this.calls.delete(id);
81
+ reject(new Error(`GitHub Copilot didn't answer ${method}`));
82
+ }, timeoutMs)
83
+ : undefined;
84
+ this.calls.set(id, {
85
+ resolve: (r) => {
86
+ clearTimeout(timer);
87
+ resolve(r as T);
88
+ },
89
+ reject: (e) => {
90
+ clearTimeout(timer);
91
+ reject(e);
92
+ },
93
+ });
94
+ this.child.write({ jsonrpc: "2.0", id, method, params });
95
+ });
96
+ }
97
+
98
+ notify(method: string, params?: unknown): void {
99
+ this.child.write({ jsonrpc: "2.0", method, params });
100
+ }
101
+
102
+ reply(id: number | string, result: unknown): void {
103
+ this.child.write({ jsonrpc: "2.0", id, result });
104
+ }
105
+
106
+ replyError(id: number | string, message: string): void {
107
+ this.child.write({
108
+ jsonrpc: "2.0",
109
+ id,
110
+ error: { code: -32601, message },
111
+ });
112
+ }
113
+
114
+ // No file or terminal access offered: the agent uses its own.
115
+ async initialize(): Promise<void> {
116
+ await this.request("initialize", {
117
+ protocolVersion: 1,
118
+ clientCapabilities: {},
119
+ clientInfo: { name: "agentos", title: "AgentOS", version: "1.0.0" },
120
+ });
121
+ }
122
+
123
+ close(): void {
124
+ this.child.kill();
125
+ }
126
+ }
@@ -0,0 +1,174 @@
1
+ import type { ChatConversation, ChatDriver, ChatStartOptions } from "../driver";
2
+ import type { ChatImage, DriverEvent } from "../events";
3
+ import { InputQueue } from "../queue";
4
+ import { AGENT_DEFAULT_MODEL } from "../../providers/registry";
5
+ import { CopilotApprovals } from "./copilot-approvals";
6
+ import { CopilotMapper } from "./copilot-mapper";
7
+ import { CopilotRpc } from "./copilot-rpc";
8
+
9
+ type P = Record<string, unknown>;
10
+
11
+ export function copilotArgs(o: ChatStartOptions): string[] {
12
+ const args: string[] = [];
13
+ if (o.model && o.model !== AGENT_DEFAULT_MODEL) args.push("--model", o.model);
14
+ return args;
15
+ }
16
+
17
+ export function copilotPrompt(text: string, images?: ChatImage[]) {
18
+ return [
19
+ { type: "text", text },
20
+ ...(images ?? []).map((i) => ({
21
+ type: "image",
22
+ data: i.data,
23
+ mimeType: i.mediaType,
24
+ })),
25
+ ];
26
+ }
27
+
28
+ // The GitHub Copilot CLI over the Agent Client Protocol (`copilot --acp`).
29
+ // It has no system prompt to append to, so the instructions go ahead of the
30
+ // first message of a new conversation.
31
+ export const copilotDriver: ChatDriver = {
32
+ id: "copilot",
33
+ plan: false,
34
+ inProcessTools: false,
35
+ atRest: false,
36
+
37
+ // The ACP server lists no models or commands without a session; the agent
38
+ // picks its own model unless one is chosen.
39
+ async discover() {
40
+ return { commands: [], models: [] };
41
+ },
42
+
43
+ start(options): ChatConversation {
44
+ const out = new InputQueue<DriverEvent>();
45
+ const emit = (e: DriverEvent) => out.push(e);
46
+ const mapper = new CopilotMapper(emit);
47
+ const approvals = new CopilotApprovals(emit, options.access);
48
+ let sessionId: string | null = null;
49
+ let busy = false;
50
+ let intro = options.resumeId ? "" : (options.systemAppend ?? "").trim();
51
+ // Messages that can't join the running turn wait for it to end, in order.
52
+ const held: { text: string; images?: ChatImage[] }[] = [];
53
+ const rpc: CopilotRpc = new CopilotRpc(
54
+ copilotArgs(options),
55
+ { cwd: options.cwd, env: { ...process.env, ...options.env } },
56
+ {
57
+ notify(method, params) {
58
+ if (method === "session/update")
59
+ mapper.map((params.update ?? {}) as P);
60
+ },
61
+ request(id, method, params) {
62
+ if (method !== "session/request_permission")
63
+ return rpc.replyError(id, `${method} isn't supported`);
64
+ void approvals.handle(params).then((result) => rpc.reply(id, result));
65
+ },
66
+ exit(error) {
67
+ approvals.pending.expireAll();
68
+ mapper.endTurn({ error });
69
+ out.end();
70
+ },
71
+ }
72
+ );
73
+ emit({ type: "usage_start", totals: { ...mapper.totals } });
74
+
75
+ const ready = rpc.initialize().then(async () => {
76
+ const params = { cwd: options.cwd, mcpServers: [] };
77
+ if (options.resumeId) {
78
+ mapper.replaying = true;
79
+ try {
80
+ await rpc.request("session/load", {
81
+ ...params,
82
+ sessionId: options.resumeId,
83
+ });
84
+ sessionId = options.resumeId;
85
+ } catch {
86
+ mapper.items.error(
87
+ "That conversation is gone, so this one starts over"
88
+ );
89
+ intro = (options.systemAppend ?? "").trim();
90
+ } finally {
91
+ mapper.replaying = false;
92
+ }
93
+ }
94
+ if (!sessionId)
95
+ sessionId = (
96
+ await rpc.request<{ sessionId: string }>("session/new", params)
97
+ ).sessionId;
98
+ emit({ type: "resume_id", id: sessionId });
99
+ });
100
+ ready.catch((error: Error) => {
101
+ mapper.items.error(`GitHub Copilot couldn't start: ${error.message}`);
102
+ mapper.endTurn({});
103
+ out.end();
104
+ rpc.close();
105
+ });
106
+
107
+ async function run(text: string, images?: ChatImage[]) {
108
+ busy = true;
109
+ mapper.items.startTurn();
110
+ let end: { interrupted?: boolean; error?: string } = {};
111
+ try {
112
+ await ready;
113
+ const first = intro ? `${intro}\n\n${text}` : text;
114
+ intro = "";
115
+ const r = await rpc.request<P>(
116
+ "session/prompt",
117
+ { sessionId, prompt: copilotPrompt(first, images) },
118
+ 0
119
+ );
120
+ mapper.spent(r.usage as P | undefined);
121
+ end = { interrupted: r.stopReason === "cancelled" };
122
+ } catch (error) {
123
+ end = { error: (error as Error).message };
124
+ }
125
+ busy = false;
126
+ mapper.endTurn(end);
127
+ const next = held.shift();
128
+ if (next) void run(next.text, next.images);
129
+ }
130
+
131
+ function stop() {
132
+ approvals.pending.expireAll();
133
+ if (busy && sessionId) rpc.notify("session/cancel", { sessionId });
134
+ }
135
+
136
+ return {
137
+ send(text, images, sendOptions) {
138
+ if (!busy) void run(text, images);
139
+ else {
140
+ held.push({ text, images });
141
+ if (sendOptions?.now) stop();
142
+ }
143
+ return undefined;
144
+ },
145
+ // Stops the turn only: a message already sent still goes after it.
146
+ async interrupt() {
147
+ stop();
148
+ },
149
+ // The model is chosen when the CLI starts.
150
+ async setModel() {},
151
+ async setAccess(next) {
152
+ approvals.access = next;
153
+ },
154
+ async setPlan() {},
155
+ respond(id, answer) {
156
+ approvals.pending.respond(id, answer);
157
+ },
158
+ async stopTask() {},
159
+ async undo() {
160
+ return {
161
+ canUndo: false,
162
+ error: "GitHub Copilot can't undo from chat",
163
+ files: [],
164
+ };
165
+ },
166
+ close() {
167
+ approvals.pending.expireAll();
168
+ rpc.close();
169
+ out.end();
170
+ },
171
+ events: out,
172
+ };
173
+ },
174
+ };
@@ -4,6 +4,7 @@ import { claudeDriver } from "./claude";
4
4
  import { codexDriver } from "./codex";
5
5
  import { openCodeDriver } from "./opencode";
6
6
  import { piDriver } from "./pi";
7
+ import { copilotDriver } from "./copilot";
7
8
  import { demoDriver } from "./demo";
8
9
  import { demoMode } from "../../security/demo";
9
10
 
@@ -14,6 +15,7 @@ const DRIVERS: Record<(typeof CHAT_AGENT_TYPES)[number], ChatDriver> = {
14
15
  codex: codexDriver,
15
16
  pi: piDriver,
16
17
  opencode: openCodeDriver,
18
+ copilot: copilotDriver,
17
19
  };
18
20
 
19
21
  export function chatDriverFor(agentType: string): ChatDriver | null {
@@ -81,6 +81,8 @@ export interface ChatOrigin {
81
81
  body?: string;
82
82
  // Lines of an event batch that are the reader's answers to asks.
83
83
  decided?: string[];
84
+ // The tasks it is about, by name, set where the event is delivered.
85
+ subjects?: { id: string; name: string }[];
84
86
  // Only Away mode's own lines: the turn it starts isn't anyone's activity
85
87
  // (lib/away/activity).
86
88
  away?: true;
package/lib/chat/find.ts CHANGED
@@ -7,7 +7,12 @@
7
7
 
8
8
  import type { ChatItem } from "./events";
9
9
  import { lineDiff } from "./diff";
10
- import { groupTimeline, STANDALONE_TOOLS, type ToolItem } from "./group";
10
+ import {
11
+ groupTimeline,
12
+ STANDALONE_TOOLS,
13
+ type GroupOptions,
14
+ type ToolItem,
15
+ } from "./group";
11
16
  import { eventParts, originOf } from "./origin";
12
17
 
13
18
  export const MIN_QUERY = 2;
@@ -170,7 +175,11 @@ function rowKeys(item: ChatItem): string[] {
170
175
  * so it's on screen: a step inside its folded run, a message inside an undo.
171
176
  * Null when it isn't loaded (or the timeline never shows it).
172
177
  */
173
- export function locate(items: ChatItem[], id: string): Located | null {
178
+ export function locate(
179
+ items: ChatItem[],
180
+ id: string,
181
+ opts: GroupOptions = {}
182
+ ): Located | null {
174
183
  const at = items.findIndex((i) => i.id === id);
175
184
  if (at === -1) return null;
176
185
  const inside = (
@@ -182,16 +191,28 @@ export function locate(items: ChatItem[], id: string): Located | null {
182
191
  return { open: [...open, ...rowKeys(b.item)] };
183
192
  if (b.type === "tools" && b.tools.some((t) => t.id === id))
184
193
  return { open: [...open, `steps:${b.id}`, `tool:${id}`] };
194
+ if (b.type === "events") {
195
+ const turn = b.turns.find(
196
+ (t) => t.id === id || t.replies.some((i) => i.id === id)
197
+ );
198
+ if (!turn) continue;
199
+ const opened = [...open, `events:${b.id}`, `eventturn:${turn.id}`];
200
+ if (turn.id === id) return { open: [...opened, ...rowKeys(turn.user)] };
201
+ return inside(groupTimeline(turn.replies), opened) ?? { open: opened };
202
+ }
185
203
  if (b.type === "undone" && b.items.some((i) => i.id === id))
186
204
  return (
187
- inside(groupTimeline(b.items), [...open, `undone:${b.undo.id}`]) ?? {
205
+ inside(groupTimeline(b.items, opts), [
206
+ ...open,
207
+ `undone:${b.undo.id}`,
208
+ ]) ?? {
188
209
  open: [...open, `undone:${b.undo.id}`],
189
210
  }
190
211
  );
191
212
  }
192
213
  return null;
193
214
  };
194
- const blocks = groupTimeline(items);
215
+ const blocks = groupTimeline(items, opts);
195
216
  const blockOf = (b: (typeof blocks)[number]) =>
196
217
  b.type === "item" ? b.item.id : b.id;
197
218
  for (const b of blocks) {
package/lib/chat/group.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ChatItem } from "./events";
2
+ import { eventTurnAt, type EventTurn } from "./turns";
2
3
 
3
4
  export type ToolItem = Extract<ChatItem, { kind: "tool" }>;
4
5
  export type UndoItem = Extract<ChatItem, { kind: "undo" }>;
@@ -6,7 +7,14 @@ export type UndoItem = Extract<ChatItem, { kind: "undo" }>;
6
7
  export type TimelineBlock =
7
8
  | { type: "item"; item: ChatItem }
8
9
  | { type: "tools"; id: string; tools: ToolItem[] }
9
- | { type: "undone"; id: string; undo: UndoItem; items: ChatItem[] };
10
+ | { type: "undone"; id: string; undo: UndoItem; items: ChatItem[] }
11
+ // Consecutive turns an event started, with the reply to each.
12
+ | { type: "events"; id: string; turns: EventTurn[] };
13
+
14
+ export interface GroupOptions {
15
+ // Fold events and the replies to them (the orchestrator's chat).
16
+ foldEvents?: boolean;
17
+ }
10
18
 
11
19
  // Tools worth seeing on their own rather than as one step among many.
12
20
  export const STANDALONE_TOOLS = new Set(["Skill", "Task", "Agent"]);
@@ -14,17 +22,45 @@ export const STANDALONE_TOOLS = new Set(["Skill", "Task", "Agent"]);
14
22
  // Consecutive tool calls (and the reasoning between them) fold into one
15
23
  // collapsible block, so a long turn reads as what was said, not every step.
16
24
  // Undone messages fold into one block behind the undo that took them back.
17
- export function groupTimeline(items: ChatItem[]): TimelineBlock[] {
25
+ export function groupTimeline(
26
+ items: ChatItem[],
27
+ opts: GroupOptions = {}
28
+ ): TimelineBlock[] {
18
29
  const blocks: TimelineBlock[] = [];
19
30
  let tools: ToolItem[] = [];
20
31
  const flush = () => {
21
32
  if (tools.length) blocks.push({ type: "tools", id: tools[0].id, tools });
22
33
  tools = [];
23
34
  };
35
+ let turns: EventTurn[] = [];
36
+ const flushTurns = () => {
37
+ if (turns.length)
38
+ blocks.push({ type: "events", id: `ev-${turns[0].id}`, turns });
39
+ turns = [];
40
+ };
24
41
  const ends = undoneRanges(items);
42
+ // Where the next undone range starts: a turn stops short of it.
43
+ const starts = [...ends.keys()].sort((a, b) => a - b);
25
44
  for (let i = 0; i < items.length; i++) {
26
45
  const item = items[i];
27
46
  const end = ends.get(i);
47
+ if (opts.foldEvents && end === undefined && item.kind === "user") {
48
+ const limit = starts.find((s) => s > i) ?? items.length;
49
+ const found = eventTurnAt(items, i, limit);
50
+ if (found) {
51
+ flush();
52
+ turns.push(found.turn);
53
+ i = found.end - 1;
54
+ continue;
55
+ }
56
+ }
57
+ // What the timeline never shows doesn't break a run of events.
58
+ const hidden =
59
+ item.kind === "task" ||
60
+ (item.kind === "approval" &&
61
+ item.status !== "pending" &&
62
+ !(item.status === "answered" && item.questions));
63
+ if (!hidden) flushTurns();
28
64
  if (end !== undefined) {
29
65
  flush();
30
66
  blocks.push({
@@ -57,6 +93,7 @@ export function groupTimeline(items: ChatItem[]): TimelineBlock[] {
57
93
  }
58
94
  }
59
95
  flush();
96
+ flushTurns();
60
97
  return blocks;
61
98
  }
62
99
 
@@ -89,6 +126,17 @@ export function sameBlock(a: TimelineBlock, b: TimelineBlock): boolean {
89
126
  return sameItems(a.tools, b.tools);
90
127
  if (a.type === "undone" && b.type === "undone")
91
128
  return a.undo === b.undo && sameItems(a.items, b.items);
129
+ if (a.type === "events" && b.type === "events")
130
+ return (
131
+ a.id === b.id &&
132
+ a.turns.length === b.turns.length &&
133
+ a.turns.every(
134
+ (t, i) =>
135
+ t.user === b.turns[i].user &&
136
+ t.live === b.turns[i].live &&
137
+ sameItems(t.replies, b.turns[i].replies)
138
+ )
139
+ );
92
140
  return false;
93
141
  }
94
142