@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.
Files changed (69) hide show
  1. package/README.md +82 -3
  2. package/app/api/doctor/route.ts +8 -0
  3. package/app/api/sessions/[id]/simulator/route.ts +32 -0
  4. package/app/api/sessions/[id]/simulator/run/route.ts +37 -0
  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/chat/events.ts +2 -0
  35. package/lib/chat/find.ts +25 -4
  36. package/lib/chat/group.ts +50 -2
  37. package/lib/chat/turns.ts +111 -0
  38. package/lib/db/changes.ts +6 -0
  39. package/lib/db/migrations.ts +51 -0
  40. package/lib/doctor/checks.ts +222 -0
  41. package/lib/orchestrator/ask-view.ts +13 -0
  42. package/lib/orchestrator/brief.ts +5 -1
  43. package/lib/orchestrator/deliver.ts +3 -0
  44. package/lib/orchestrator/events.ts +19 -0
  45. package/lib/orchestrator/needs-you-items.ts +104 -0
  46. package/lib/orchestrator/overview.ts +3 -0
  47. package/lib/orchestrator/questions.ts +120 -0
  48. package/lib/orchestrator/serve.ts +11 -0
  49. package/lib/orchestrator/tool-names.ts +2 -0
  50. package/lib/orchestrator/tool-schemas.ts +12 -0
  51. package/lib/orchestrator/tools.ts +4 -0
  52. package/lib/project-config/agentos.schema.json +19 -0
  53. package/lib/project-config/brief.ts +3 -0
  54. package/lib/project-config/schema.ts +10 -0
  55. package/lib/sessions/end.ts +3 -0
  56. package/lib/sessions/stop.ts +3 -0
  57. package/lib/simulator/agent-device.ts +258 -0
  58. package/lib/simulator/available.ts +5 -0
  59. package/lib/simulator/brief.ts +18 -0
  60. package/lib/simulator/http.ts +16 -0
  61. package/lib/simulator/index.ts +458 -0
  62. package/lib/simulator/lease.ts +169 -0
  63. package/lib/simulator/simctl.ts +167 -0
  64. package/lib/welcome-url.ts +6 -1
  65. package/package.json +1 -1
  66. package/server.ts +16 -0
  67. package/stores/needsYouFocus.ts +13 -0
  68. package/stores/welcomeUi.test.ts +15 -0
  69. package/stores/welcomeUi.ts +6 -1
@@ -0,0 +1,69 @@
1
+ "use client";
2
+
3
+ import { AlertTriangle, Check, RefreshCw, X } from "lucide-react";
4
+ import { Button } from "@/components/ui/button";
5
+ import { useDoctorQuery, useRecheckDoctor } from "@/data/doctor";
6
+ import type { CheckStatus } from "@/lib/doctor/checks";
7
+ import { cn } from "@/lib/utils";
8
+
9
+ const ICON = { ok: Check, warn: AlertTriangle, fail: X } as const;
10
+ const TONE: Record<CheckStatus, string> = {
11
+ ok: "text-primary",
12
+ warn: "text-muted-foreground",
13
+ fail: "text-destructive",
14
+ };
15
+
16
+ /** What this machine needs to run AgentOS, one line per tool. */
17
+ export function HealthList() {
18
+ const { data: checks, isPending, isError } = useDoctorQuery();
19
+ const recheck = useRecheckDoctor();
20
+ return (
21
+ <div className="flex flex-col gap-2">
22
+ {isPending && (
23
+ <div className="bg-muted/40 h-24 animate-pulse rounded-lg" />
24
+ )}
25
+ {isError && (
26
+ <p className="text-destructive text-sm">
27
+ Couldn&apos;t run the health checks.
28
+ </p>
29
+ )}
30
+ <ul className="flex flex-col gap-2">
31
+ {checks?.map((c) => {
32
+ const Icon = ICON[c.status];
33
+ return (
34
+ <li
35
+ key={c.id}
36
+ className="bg-muted/40 flex min-h-11 items-start gap-3 rounded-lg px-3 py-2"
37
+ >
38
+ <Icon
39
+ aria-label={c.status}
40
+ className={cn("mt-0.5 size-4 shrink-0", TONE[c.status])}
41
+ />
42
+ <div className="min-w-0 flex-1">
43
+ <p className="text-sm font-medium">{c.label}</p>
44
+ <p className="text-muted-foreground text-xs break-words">
45
+ {c.detail}
46
+ </p>
47
+ {c.fix && c.status !== "ok" && (
48
+ <code className="bg-background mt-1 block rounded px-2 py-1 text-xs break-all">
49
+ {c.fix}
50
+ </code>
51
+ )}
52
+ </div>
53
+ </li>
54
+ );
55
+ })}
56
+ </ul>
57
+ <Button
58
+ size="sm"
59
+ variant="ghost"
60
+ className="self-end"
61
+ disabled={recheck.isPending}
62
+ onClick={() => recheck.mutate()}
63
+ >
64
+ <RefreshCw className={recheck.isPending ? "animate-spin" : ""} />
65
+ Check again
66
+ </Button>
67
+ </div>
68
+ );
69
+ }
@@ -41,10 +41,7 @@ export function ProjectStep() {
41
41
  const own = projects.filter((p) => !p.is_uncategorized);
42
42
 
43
43
  const add = (kind: AddProjectKind) =>
44
- addProjectActions.show(kind, "local", (id) => {
45
- welcomeUiActions.setProject(id);
46
- welcomeUiActions.go("finish");
47
- });
44
+ addProjectActions.show(kind, "local", welcomeUiActions.chooseProject);
48
45
 
49
46
  return (
50
47
  <div className="flex flex-col gap-3">
@@ -54,10 +51,7 @@ export function ProjectStep() {
54
51
  <li key={p.id}>
55
52
  <button
56
53
  type="button"
57
- onClick={() => {
58
- welcomeUiActions.setProject(p.id);
59
- welcomeUiActions.go("finish");
60
- }}
54
+ onClick={() => welcomeUiActions.chooseProject(p.id)}
61
55
  className={cn(
62
56
  "bg-muted/40 hover:bg-accent flex min-h-11 w-full items-center gap-3 rounded-lg px-3 py-2 text-left",
63
57
  projectId === p.id && "ring-primary/40 ring-2"
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { memo } from "react";
3
+ import { memo, useState } from "react";
4
4
  import { useSnapshot } from "valtio";
5
5
  import { Check } from "lucide-react";
6
6
  import {
@@ -16,6 +16,9 @@ import { cn } from "@/lib/utils";
16
16
  import { WELCOME_STEPS, type WelcomeStep } from "@/lib/welcome-url";
17
17
  import { welcomeUi, welcomeUiActions } from "@/stores/welcomeUi";
18
18
  import { AgentList } from "./AgentList";
19
+ import type { BrowserNotifications } from "@/components/Settings/NotificationsSection";
20
+ import { HostsDialog } from "@/components/Hosts";
21
+ import { DevicesStep } from "./DevicesStep";
19
22
  import { ProjectStep } from "./ProjectStep";
20
23
 
21
24
  const STEPS: Record<
@@ -32,6 +35,11 @@ const STEPS: Record<
32
35
  title: "Add your first project",
33
36
  hint: "A folder or repo for the first session to work in.",
34
37
  },
38
+ devices: {
39
+ label: "Devices",
40
+ title: "Use it from your phone",
41
+ hint: "All optional. Each can be done later from Settings.",
42
+ },
35
43
  finish: {
36
44
  label: "Start",
37
45
  title: "You're set",
@@ -61,8 +69,14 @@ function FinishStep() {
61
69
  * First-run setup: agents, then a first project, then a session. It stays
62
70
  * open until finished or skipped, and every step after the first can be skipped.
63
71
  */
64
- export const WelcomeDialog = memo(function WelcomeDialog() {
72
+ export const WelcomeDialog = memo(function WelcomeDialog({
73
+ notifications,
74
+ }: {
75
+ notifications: BrowserNotifications;
76
+ }) {
65
77
  useWelcomeUrl();
78
+ // Held here so the machines dialog outlives a step change.
79
+ const [hostsOpen, setHostsOpen] = useState(false);
66
80
  const { open, step, projectId } = useSnapshot(welcomeUi);
67
81
  const end = useEndWelcome();
68
82
  const at = WELCOME_STEPS.indexOf(step);
@@ -77,7 +91,10 @@ export const WelcomeDialog = memo(function WelcomeDialog() {
77
91
  onEscapeKeyDown={(e) => e.preventDefault()}
78
92
  className="max-h-[100dvh] overflow-y-auto max-sm:h-[100dvh] max-sm:max-w-full max-sm:rounded-none sm:max-h-[90dvh] sm:max-w-xl"
79
93
  >
80
- <nav aria-label="Setup steps" className="flex items-center gap-1">
94
+ <nav
95
+ aria-label="Setup steps"
96
+ className="flex min-w-0 items-center gap-1"
97
+ >
81
98
  {WELCOME_STEPS.map((s, i) => (
82
99
  <button
83
100
  key={s}
@@ -86,7 +103,7 @@ export const WelcomeDialog = memo(function WelcomeDialog() {
86
103
  onClick={() => welcomeUiActions.go(s)}
87
104
  aria-current={i === at ? "step" : undefined}
88
105
  className={cn(
89
- "flex min-h-11 flex-1 items-center justify-center gap-1.5 rounded-lg px-2 text-xs font-medium sm:min-h-8",
106
+ "flex min-h-11 min-w-0 flex-1 items-center justify-center gap-1 rounded-lg px-1 text-xs font-medium sm:min-h-8 sm:gap-1.5 sm:px-2",
90
107
  i === at
91
108
  ? "bg-foreground/[0.08] text-foreground"
92
109
  : "text-muted-foreground hover:bg-foreground/[0.06]"
@@ -99,7 +116,7 @@ export const WelcomeDialog = memo(function WelcomeDialog() {
99
116
  <Button
100
117
  variant="ghost"
101
118
  size="sm"
102
- className="min-h-11 sm:min-h-8"
119
+ className="min-h-11 shrink-0 px-2 sm:min-h-8"
103
120
  onClick={() => end()}
104
121
  >
105
122
  Skip setup
@@ -111,6 +128,12 @@ export const WelcomeDialog = memo(function WelcomeDialog() {
111
128
  </div>
112
129
  {step === "agents" && <AgentList />}
113
130
  {step === "project" && <ProjectStep />}
131
+ {step === "devices" && (
132
+ <DevicesStep
133
+ notifications={notifications}
134
+ onLinkMachine={() => setHostsOpen(true)}
135
+ />
136
+ )}
114
137
  {step === "finish" && <FinishStep />}
115
138
  <div className="flex items-center justify-between gap-2">
116
139
  <Button
@@ -122,11 +145,11 @@ export const WelcomeDialog = memo(function WelcomeDialog() {
122
145
  Back
123
146
  </Button>
124
147
  <div className="flex items-center gap-2">
125
- {step === "project" && (
148
+ {(step === "project" || step === "devices") && (
126
149
  <Button
127
150
  variant="ghost"
128
151
  className="min-h-11 sm:min-h-9"
129
- onClick={() => welcomeUiActions.go("finish")}
152
+ onClick={() => welcomeUiActions.go(WELCOME_STEPS[at + 1])}
130
153
  >
131
154
  Skip
132
155
  </Button>
@@ -148,6 +171,7 @@ export const WelcomeDialog = memo(function WelcomeDialog() {
148
171
  </div>
149
172
  </div>
150
173
  </DialogContent>
174
+ <HostsDialog open={hostsOpen} onClose={() => setHostsOpen(false)} />
151
175
  </Dialog>
152
176
  );
153
177
  });
@@ -0,0 +1,29 @@
1
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
+ import type { DoctorCheck } from "@/lib/doctor/checks";
3
+
4
+ export const doctorKeys = {
5
+ all: ["doctor"] as const,
6
+ };
7
+
8
+ async function fetchChecks(fresh: boolean) {
9
+ const res = await fetch(fresh ? "/api/doctor?fresh" : "/api/doctor");
10
+ if (!res.ok) throw new Error("Couldn't run the health checks");
11
+ return ((await res.json()) as { checks: DoctorCheck[] }).checks;
12
+ }
13
+
14
+ export function useDoctorQuery() {
15
+ return useQuery({
16
+ queryKey: doctorKeys.all,
17
+ queryFn: () => fetchChecks(false),
18
+ staleTime: 30_000,
19
+ refetchOnMount: "always",
20
+ });
21
+ }
22
+
23
+ export function useRecheckDoctor() {
24
+ const client = useQueryClient();
25
+ return useMutation({
26
+ mutationFn: () => fetchChecks(true),
27
+ onSuccess: (checks) => client.setQueryData(doctorKeys.all, checks),
28
+ });
29
+ }
@@ -162,3 +162,62 @@ export function usePauseOrchestrator(workspaceId: string) {
162
162
  queryClient.invalidateQueries({ queryKey: orchestratorKeys.all }),
163
163
  });
164
164
  }
165
+
166
+ function patchOverview(
167
+ queryClient: ReturnType<typeof useQueryClient>,
168
+ workspaceId: string,
169
+ change: (o: OrchestratorOverview) => OrchestratorOverview
170
+ ) {
171
+ queryClient.setQueryData<OrchestratorOverview[]>(
172
+ orchestratorKeys.all,
173
+ (prev) => prev?.map((o) => (o.workspaceId === workspaceId ? change(o) : o))
174
+ );
175
+ }
176
+
177
+ // Hiding an item from the Needs-you strip (and showing it again). It never
178
+ // answers an ask or a question; it is shared by his devices.
179
+ export function useDismissNeedsYou(workspaceId: string) {
180
+ const queryClient = useQueryClient();
181
+ const url = `/api/workspaces/${workspaceId}/orchestrator/needs-you/dismiss`;
182
+ return useMutation({
183
+ mutationFn: async ({ key, undo }: { key: string; undo?: boolean }) => {
184
+ if (!undo) return post(url, { key });
185
+ return json(
186
+ await fetch(url, {
187
+ method: "DELETE",
188
+ headers: { "Content-Type": "application/json" },
189
+ body: JSON.stringify({ key }),
190
+ })
191
+ );
192
+ },
193
+ onMutate: async ({ key, undo }) => {
194
+ await queryClient.cancelQueries({ queryKey: orchestratorKeys.all });
195
+ patchOverview(queryClient, workspaceId, (o) => ({
196
+ ...o,
197
+ dismissed: undo
198
+ ? o.dismissed.filter((k) => k !== key)
199
+ : [...o.dismissed, key],
200
+ }));
201
+ },
202
+ onSettled: () =>
203
+ queryClient.invalidateQueries({ queryKey: orchestratorKeys.all }),
204
+ });
205
+ }
206
+
207
+ // Saad answered a question the orchestrator put to him.
208
+ export function useResolveQuestion(workspaceId: string) {
209
+ const queryClient = useQueryClient();
210
+ return useMutation({
211
+ mutationFn: (id: number) =>
212
+ post(`/api/workspaces/${workspaceId}/orchestrator/questions/${id}`, {}),
213
+ onMutate: async (id) => {
214
+ await queryClient.cancelQueries({ queryKey: orchestratorKeys.all });
215
+ patchOverview(queryClient, workspaceId, (o) => ({
216
+ ...o,
217
+ questions: o.questions.filter((q) => q.id !== id),
218
+ }));
219
+ },
220
+ onSettled: () =>
221
+ queryClient.invalidateQueries({ queryKey: orchestratorKeys.all }),
222
+ });
223
+ }
@@ -55,6 +55,8 @@ function keysFor(topic: string): QueryKey[] {
55
55
  case "schedule_runs":
56
56
  return [scheduleKeys.all];
57
57
  case "orchestrator_asks":
58
+ case "orchestrator_questions":
59
+ case "needs_you_dismissed":
58
60
  return [orchestratorKeys.all];
59
61
  case "orchestrator_rules":
60
62
  return [ruleKeys.all];
@@ -0,0 +1,14 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { simulatorPollInterval } from "./index";
3
+
4
+ describe("simulatorPollInterval", () => {
5
+ it("polls while a lease is coming up or booted", () => {
6
+ for (const s of ["creating", "starting", "ready"] as const)
7
+ expect(simulatorPollInterval(s)).toBeGreaterThan(0);
8
+ });
9
+
10
+ it("does not poll with no lease, an idle-shutdown one, a failed one, or before the first read", () => {
11
+ for (const s of ["none", "shutdown", "failed", undefined] as const)
12
+ expect(simulatorPollInterval(s)).toBe(false);
13
+ });
14
+ });
@@ -0,0 +1,33 @@
1
+ import { useQuery } from "@tanstack/react-query";
2
+ import type { SimulatorStatus } from "@/lib/simulator";
3
+ import { simulatorKeys } from "./keys";
4
+
5
+ export { simulatorKeys };
6
+ export type { SimulatorStatus };
7
+
8
+ const POLL_MS = 10_000;
9
+
10
+ // A lease being brought up or booted changes under us (idle shutdown, a new
11
+ // screenshot); anything else only changes when the agent opens one, which the
12
+ // next mount picks up.
13
+ export function simulatorPollInterval(
14
+ state: SimulatorStatus["state"] | undefined
15
+ ): number | false {
16
+ return state === "creating" || state === "starting" || state === "ready"
17
+ ? POLL_MS
18
+ : false;
19
+ }
20
+
21
+ export function useSimulatorStatus(sessionId: string, enabled = true) {
22
+ return useQuery({
23
+ queryKey: simulatorKeys.status(sessionId),
24
+ queryFn: async (): Promise<SimulatorStatus> => {
25
+ const res = await fetch(`/api/sessions/${sessionId}/simulator`);
26
+ if (!res.ok) throw new Error("Could not read the simulator");
27
+ return res.json();
28
+ },
29
+ enabled,
30
+ staleTime: 30_000,
31
+ refetchInterval: (query) => simulatorPollInterval(query.state.data?.state),
32
+ });
33
+ }
@@ -0,0 +1,5 @@
1
+ export const simulatorKeys = {
2
+ all: ["simulator"] as const,
3
+ status: (sessionId: string) =>
4
+ [...simulatorKeys.all, "status", sessionId] as const,
5
+ };
@@ -0,0 +1,53 @@
1
+ "use client";
2
+
3
+ import { useMemo } from "react";
4
+ import { useQuery } from "@tanstack/react-query";
5
+ import { useSessionsQuery } from "@/data/sessions/queries";
6
+ import { useProjectsQuery } from "@/data/projects/queries";
7
+ import { fetchStatuses } from "@/data/statuses/queries";
8
+ import { statusKeys } from "@/data/sessions/keys";
9
+ import { useOrchestratorOverview } from "@/data/orchestrators";
10
+ import {
11
+ needsYouItems,
12
+ type NeedsYouItem,
13
+ type TaskNeed,
14
+ } from "@/lib/orchestrator/needs-you-items";
15
+
16
+ // Everything waiting on Saad in a workspace: its open asks and questions, the
17
+ // tasks the sidebar marks as needing him, and its chat waiting on an answer.
18
+ export function useNeedsYou(
19
+ workspaceId: string,
20
+ chatWaiting: boolean
21
+ ): NeedsYouItem[] {
22
+ const overview = useOrchestratorOverview(workspaceId);
23
+ const { data: sessions } = useSessionsQuery();
24
+ const { data: projects = [] } = useProjectsQuery();
25
+ const { data: statuses } = useQuery({
26
+ queryKey: statusKeys.all,
27
+ queryFn: fetchStatuses,
28
+ staleTime: 2000,
29
+ });
30
+ const tasks = useMemo(() => {
31
+ const members = new Set(
32
+ projects.filter((p) => p.workspace_id === workspaceId).map((p) => p.id)
33
+ );
34
+ return (sessions?.sessions ?? []).flatMap((s): TaskNeed[] => {
35
+ const need = statuses?.statuses[s.id]?.need;
36
+ return !s.role && s.project_id && members.has(s.project_id) && need
37
+ ? [{ sessionId: s.id, name: s.name, need }]
38
+ : [];
39
+ });
40
+ }, [sessions, projects, statuses, workspaceId]);
41
+ return useMemo(
42
+ () =>
43
+ needsYouItems({
44
+ asks: overview?.asks ?? [],
45
+ questions: overview?.questions ?? [],
46
+ dismissed: overview?.dismissed ?? [],
47
+ tasks,
48
+ chatWaiting,
49
+ chatKey: `approval:${workspaceId}`,
50
+ }),
51
+ [overview, tasks, chatWaiting, workspaceId]
52
+ );
53
+ }
@@ -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