@saadnvd1/agent-os 0.3.828 → 0.3.836

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 (39) hide show
  1. package/README.md +2 -0
  2. package/app/api/sessions/route.ts +7 -1
  3. package/app/globals.css +14 -0
  4. package/app/layout.tsx +3 -0
  5. package/components/Chat/ChatPanel.tsx +110 -27
  6. package/components/Chat/EventRow.tsx +15 -1
  7. package/components/Chat/ForkResults.tsx +109 -0
  8. package/components/Chat/Forks.tsx +69 -103
  9. package/components/Chat/Messages.tsx +14 -0
  10. package/components/Chat/QuoteButton.tsx +59 -21
  11. package/components/Chat/TimelineRows.tsx +1 -0
  12. package/components/Chat/WorktreeRemovedNotice.tsx +17 -0
  13. package/components/Chat/composer/ComposerBody.tsx +23 -2
  14. package/components/Chat/composer/ForkHeader.tsx +66 -0
  15. package/components/Chat/composer/Toolbar.tsx +43 -7
  16. package/components/Chat/fork-draft.test.ts +57 -0
  17. package/components/Chat/fork-draft.ts +34 -0
  18. package/components/Chat/quote-selection.ts +6 -2
  19. package/content/whats-new.json +24 -0
  20. package/data/fork/index.ts +6 -1
  21. package/lib/chat/commands.ts +0 -15
  22. package/lib/chat/events.ts +3 -0
  23. package/lib/chat/socket.ts +7 -3
  24. package/lib/chat/start-error.ts +13 -0
  25. package/lib/chat/worker/cwd.ts +77 -0
  26. package/lib/chat/worker/host.ts +11 -2
  27. package/lib/chat/worker/main.ts +11 -2
  28. package/lib/db/types.ts +2 -0
  29. package/lib/fork/brief.ts +4 -2
  30. package/lib/fork/deliver.ts +6 -1
  31. package/lib/fork/ledger.ts +5 -0
  32. package/lib/fork/note.ts +6 -0
  33. package/lib/fork/settle.ts +8 -4
  34. package/lib/fork/start.ts +6 -2
  35. package/lib/fork/types.ts +7 -1
  36. package/lib/fork/view.ts +18 -2
  37. package/package.json +1 -1
  38. package/scripts/lib/build-swap.sh +1 -1
  39. package/scripts/update-e2e.test.ts +2 -0
@@ -9,6 +9,7 @@ import {
9
9
  Check,
10
10
  ChevronRight,
11
11
  Download,
12
+ GitFork,
12
13
  Circle,
13
14
  CircleDot,
14
15
  Quote,
@@ -41,6 +42,7 @@ import {
41
42
  } from "@/components/Bookmarks/BookmarkButton";
42
43
  import { useOwnerName } from "@/data/client-config/queries";
43
44
  import { ownerVerb } from "@/lib/owner-label";
45
+ import { FORK_NOTE_PREFIX } from "@/lib/fork/note";
44
46
 
45
47
  type Of<K extends ChatItem["kind"]> = Extract<ChatItem, { kind: K }>;
46
48
 
@@ -481,12 +483,24 @@ const NOTE_LABEL = {
481
483
  away: "Away",
482
484
  } as const;
483
485
 
486
+ // Where forks were started: one quiet line, not a boxed note.
487
+ function ForkMarker({ item }: { item: Of<"note"> }) {
488
+ return (
489
+ <div className="text-muted-foreground flex min-w-0 items-center gap-2 text-xs">
490
+ <GitFork aria-hidden className="size-3.5 shrink-0" />
491
+ <span className="truncate">{item.text}</span>
492
+ <i className="bg-border h-px w-3 shrink-0" />
493
+ </div>
494
+ );
495
+ }
496
+
484
497
  export function NoteLine({ item }: { item: Of<"note"> }) {
485
498
  const name = useOwnerName();
486
499
  const label =
487
500
  item.tone === "escalation"
488
501
  ? ownerVerb(name, "decides", "decide")
489
502
  : NOTE_LABEL[item.tone];
503
+ if (item.id.startsWith(FORK_NOTE_PREFIX)) return <ForkMarker item={item} />;
490
504
  return (
491
505
  <div
492
506
  className={cn(
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { useEffect, useId, useState, type RefObject } from "react";
4
- import { Quote } from "lucide-react";
4
+ import { GitFork, Quote } from "lucide-react";
5
5
  import { cn } from "@/lib/utils";
6
6
  import { useCoarsePointer } from "./composer/useCoarsePointer";
7
7
  import { useKeybindings } from "@/hooks/useKeybindings";
@@ -19,7 +19,7 @@ interface Picked {
19
19
  at: Placement | null;
20
20
  }
21
21
 
22
- function readSelection(root: HTMLElement): Picked | null {
22
+ function readSelection(root: HTMLElement, half?: number): Picked | null {
23
23
  const sel = window.getSelection();
24
24
  const pick = sel && quotePick(sel, root);
25
25
  if (!pick) return null;
@@ -30,7 +30,10 @@ function readSelection(root: HTMLElement): Picked | null {
30
30
  left: Math.min(...rects.map((r) => r.left)),
31
31
  right: Math.max(...rects.map((r) => r.right)),
32
32
  };
33
- return { text: pick.text, at: placeQuote(box, root.getBoundingClientRect()) };
33
+ return {
34
+ text: pick.text,
35
+ at: placeQuote(box, root.getBoundingClientRect(), undefined, half),
36
+ };
34
37
  }
35
38
 
36
39
  // "Quote" for a selection in a reply. With a mouse it floats over the
@@ -39,12 +42,16 @@ function readSelection(root: HTMLElement): Picked | null {
39
42
  export function QuoteButton({
40
43
  rootRef,
41
44
  onQuote,
45
+ onFork,
42
46
  }: {
43
47
  rootRef: RefObject<HTMLElement | null>;
44
48
  onQuote: (text: string) => void;
49
+ // Only given while forks are on: a second button beside Quote.
50
+ onFork?: (text: string) => void;
45
51
  }) {
46
52
  const [picked, setPicked] = useState<Picked | null>(null);
47
53
  const coarse = useCoarsePointer();
54
+ const wide = !!onFork;
48
55
 
49
56
  useEffect(() => {
50
57
  let frame = 0;
@@ -52,7 +59,7 @@ export function QuoteButton({
52
59
  cancelAnimationFrame(frame);
53
60
  frame = requestAnimationFrame(() => {
54
61
  const root = rootRef.current;
55
- setPicked(root ? readSelection(root) : null);
62
+ setPicked(root ? readSelection(root, wide ? 110 : undefined) : null);
56
63
  });
57
64
  };
58
65
  document.addEventListener("selectionchange", update);
@@ -62,7 +69,7 @@ export function QuoteButton({
62
69
  document.removeEventListener("selectionchange", update);
63
70
  window.removeEventListener("scroll", update, true);
64
71
  };
65
- }, [rootRef]);
72
+ }, [rootRef, wide]);
66
73
 
67
74
  const shown = !!picked && (coarse || !!picked.at);
68
75
  const quote = quoteAction(picked?.text, {
@@ -70,27 +77,58 @@ export function QuoteButton({
70
77
  clearSelection: () => window.getSelection()?.removeAllRanges(),
71
78
  hide: () => setPicked(null),
72
79
  });
80
+ const fork = quoteAction(picked?.text, {
81
+ onQuote: onFork ?? onQuote,
82
+ clearSelection: () => window.getSelection()?.removeAllRanges(),
83
+ hide: () => setPicked(null),
84
+ });
73
85
  // One source per button: each open chat pane has its own.
74
- useKeybindings(`chat-quote:${useId()}`, [quoteEnter(shown, quote)]);
86
+ useKeybindings(`chat-quote:${useId()}`, [
87
+ quoteEnter(shown, quote),
88
+ ...(onFork
89
+ ? [{ ...quoteEnter(shown, fork), id: "chat.fork", keys: "f" }]
90
+ : []),
91
+ ]);
75
92
 
76
93
  if (!picked) return null;
94
+ const pill =
95
+ "bg-card popover-surface hover:bg-muted flex min-h-11 items-center gap-1.5 rounded-full px-3.5 text-sm font-medium md:min-h-8 md:px-3 pointer-coarse:min-h-11";
77
96
  const button = (
78
- <button
79
- type="button"
80
- // Pressing it mustn't clear the selection before it's read.
81
- onMouseDown={(e) => e.preventDefault()}
82
- onClick={quote}
83
- className="bg-card popover-surface hover:bg-muted flex min-h-11 items-center gap-1.5 rounded-full px-3.5 text-sm font-medium md:min-h-8 md:px-3 pointer-coarse:min-h-11"
84
- >
85
- <Quote className="h-3.5 w-3.5" />
86
- Quote
87
- <kbd
88
- aria-hidden
89
- className="text-muted-foreground bg-muted hidden rounded px-1 font-mono text-[10px] leading-4 md:inline"
97
+ <div className="flex items-center gap-1.5">
98
+ <button
99
+ type="button"
100
+ // Pressing it mustn't clear the selection before it's read.
101
+ onMouseDown={(e) => e.preventDefault()}
102
+ onClick={quote}
103
+ className={pill}
90
104
  >
91
- ↵
92
- </kbd>
93
- </button>
105
+ <Quote className="h-3.5 w-3.5" />
106
+ Quote
107
+ <kbd
108
+ aria-hidden
109
+ className="text-muted-foreground bg-muted hidden rounded px-1 font-mono text-[10px] leading-4 md:inline"
110
+ >
111
+ ↵
112
+ </kbd>
113
+ </button>
114
+ {onFork && (
115
+ <button
116
+ type="button"
117
+ onMouseDown={(e) => e.preventDefault()}
118
+ onClick={fork}
119
+ className={pill}
120
+ >
121
+ <GitFork className="h-3.5 w-3.5" />
122
+ Fork
123
+ <kbd
124
+ aria-hidden
125
+ className="text-muted-foreground bg-muted hidden rounded px-1 font-mono text-[10px] leading-4 md:inline"
126
+ >
127
+ F
128
+ </kbd>
129
+ </button>
130
+ )}
131
+ </div>
94
132
  );
95
133
  if (coarse)
96
134
  return <div className="mb-2 flex justify-center select-none">{button}</div>;
@@ -55,6 +55,7 @@ function Item({
55
55
  return (
56
56
  <UserTurn
57
57
  item={item}
58
+ onQuote={actions.onQuote}
58
59
  onUndo={
59
60
  item.checkpoint && actions.onUndo
60
61
  ? () => actions.onUndo?.(item.id)
@@ -0,0 +1,17 @@
1
+ "use client";
2
+
3
+ import { useSessionsQuery } from "@/data/sessions";
4
+ import { Notice } from "@/components/ui/notice";
5
+
6
+ // Above the composer once a finished task's worktree is removed: its chat
7
+ // still answers from the project's checkout, but can't change anything.
8
+ export function WorktreeRemovedNotice({ sessionId }: { sessionId: string }) {
9
+ const { data } = useSessionsQuery();
10
+ const session = data?.sessions.find((s) => s.id === sessionId);
11
+ if (!session?.worktree_removed) return null;
12
+ return (
13
+ <Notice className="mb-2">
14
+ Merged · worktree removed. Questions work; changes need a new task.
15
+ </Notice>
16
+ );
17
+ }
@@ -32,6 +32,7 @@ import { offeredSuggestion } from "@/lib/chat/suggestion";
32
32
  import { historyStep } from "@/lib/chat/history";
33
33
  import { markdownParagraphs } from "@/lib/chat/quote";
34
34
  import { insertMarkdown } from "./insert";
35
+ import { ForkHeader, QuoteChip, type ForkComposer } from "./ForkHeader";
35
36
  import { useCoarsePointer } from "./useCoarsePointer";
36
37
  import { useMentions } from "./useMentions";
37
38
  import { Attachments } from "./Attachments";
@@ -52,6 +53,11 @@ export interface ComposerProps {
52
53
  running: boolean;
53
54
  disabled?: boolean;
54
55
  placeholder: string;
56
+ // The send button's label, when Enter does something else than send.
57
+ // Fork mode: the quote chip, pill and own-checkout choice.
58
+ fork?: ForkComposer;
59
+ // After Fork mode: the selection stays as a chip on a normal reply.
60
+ quoteChip?: { text: string; onDrop: () => void };
55
61
  onSend: (text: string, images: ChatImage[], files: ChatFile[]) => void;
56
62
  // Where attached files are kept: the session's id, or "draft" before one exists.
57
63
  attachScope: string;
@@ -94,6 +100,8 @@ export function ComposerBody({
94
100
  running,
95
101
  disabled,
96
102
  placeholder,
103
+ fork,
104
+ quoteChip,
97
105
  onSend,
98
106
  onStop,
99
107
  commands = [],
@@ -270,7 +278,12 @@ export function ComposerBody({
270
278
  const { dragging, dropProps, addFiles } = attach;
271
279
 
272
280
  const empty =
273
- !text.trim() && !images.length && !files.length && !attach.uploads.length;
281
+ !fork?.quote &&
282
+ !quoteChip &&
283
+ !text.trim() &&
284
+ !images.length &&
285
+ !files.length &&
286
+ !attach.uploads.length;
274
287
  const submit = () => {
275
288
  if (empty || disabled || attach.uploading) return;
276
289
  const used = leadingCommand(text)?.slice(1);
@@ -358,9 +371,15 @@ export function ComposerBody({
358
371
  {...dropProps}
359
372
  className={cn(
360
373
  "bg-card popover-surface rounded-2xl p-2 transition-shadow",
361
- dragging && "ring-primary ring-2"
374
+ (dragging || fork) && "ring-primary ring-2"
362
375
  )}
363
376
  >
377
+ {fork && <ForkHeader fork={fork} />}
378
+ {quoteChip && (
379
+ <div className="mb-1.5">
380
+ <QuoteChip text={quoteChip.text} onDrop={quoteChip.onDrop} />
381
+ </div>
382
+ )}
364
383
  <Attachments
365
384
  images={images}
366
385
  files={files}
@@ -393,6 +412,8 @@ export function ComposerBody({
393
412
  onStop={onStop}
394
413
  canSend={!disabled && !empty && !attach.uploading}
395
414
  onSend={submit}
415
+ sendLabel={fork ? "Start fork" : undefined}
416
+ fork={fork}
396
417
  />
397
418
  </div>
398
419
  </div>
@@ -0,0 +1,66 @@
1
+ "use client";
2
+
3
+ import { GitFork, Quote, X } from "lucide-react";
4
+
5
+ export interface ForkComposer {
6
+ // The selection being forked; the instruction is this plus what's typed.
7
+ quote: string;
8
+ own: boolean;
9
+ onOwn: () => void;
10
+ onLeave: () => void;
11
+ onDropQuote: () => void;
12
+ }
13
+
14
+ // Top of the composer in Fork mode: the pill that marks it, what the keys do,
15
+ // and the selection as a chip.
16
+ export function ForkHeader({ fork }: { fork: ForkComposer }) {
17
+ return (
18
+ <div className="mb-1.5 space-y-1.5">
19
+ <div className="flex items-center gap-2">
20
+ <span className="bg-primary/10 text-primary flex h-8 items-center gap-1.5 rounded-lg pr-1 pl-2.5 text-xs font-medium">
21
+ <GitFork aria-hidden className="size-3.5" />
22
+ Fork
23
+ <button
24
+ type="button"
25
+ onMouseDown={(e) => e.preventDefault()}
26
+ onClick={fork.onLeave}
27
+ aria-label="Cancel fork"
28
+ className="hover:bg-primary/15 flex size-6 items-center justify-center rounded-md"
29
+ >
30
+ <X aria-hidden className="size-3.5" />
31
+ </button>
32
+ </span>
33
+ <span className="flex-1" />
34
+ <span className="text-muted-foreground hidden text-xs md:inline">
35
+ ↵ start · Esc cancel
36
+ </span>
37
+ </div>
38
+ {fork.quote && <QuoteChip text={fork.quote} onDrop={fork.onDropQuote} />}
39
+ </div>
40
+ );
41
+ }
42
+
43
+ // The selection as a chip above the box; it goes out ahead of what's typed.
44
+ export function QuoteChip({
45
+ text,
46
+ onDrop,
47
+ }: {
48
+ text: string;
49
+ onDrop: () => void;
50
+ }) {
51
+ return (
52
+ <div className="bg-muted/60 text-muted-foreground flex items-start gap-2 rounded-lg py-1.5 pr-1 pl-2.5 text-sm">
53
+ <Quote aria-hidden className="mt-1 size-3.5 shrink-0" />
54
+ <p className="line-clamp-2 min-w-0 flex-1">{text}</p>
55
+ <button
56
+ type="button"
57
+ onMouseDown={(e) => e.preventDefault()}
58
+ onClick={onDrop}
59
+ aria-label="Remove quote"
60
+ className="hover:text-foreground flex size-8 shrink-0 items-center justify-center"
61
+ >
62
+ <X aria-hidden className="size-4" />
63
+ </button>
64
+ </div>
65
+ );
66
+ }
@@ -3,6 +3,8 @@
3
3
  import { useRef, useState } from "react";
4
4
  import {
5
5
  ArrowUp,
6
+ Folder,
7
+ GitFork,
6
8
  Paperclip,
7
9
  ListChecks,
8
10
  MonitorUp,
@@ -41,6 +43,8 @@ export function Toolbar({
41
43
  onStop,
42
44
  canSend,
43
45
  onSend,
46
+ sendLabel = "Send",
47
+ fork,
44
48
  }: {
45
49
  plain: boolean;
46
50
  onTogglePlain: () => void;
@@ -65,6 +69,9 @@ export function Toolbar({
65
69
  onStop: () => void;
66
70
  canSend: boolean;
67
71
  onSend: () => void;
72
+ sendLabel?: string;
73
+ // Fork mode: the own-checkout choice, and a send button that says what it does.
74
+ fork?: { own: boolean; onOwn: () => void };
68
75
  }) {
69
76
  const fileRef = useRef<HTMLInputElement>(null);
70
77
  const formatting = plain ? "Formatting off" : "Formatting on";
@@ -103,7 +110,7 @@ export function Toolbar({
103
110
  e.target.value = "";
104
111
  }}
105
112
  />
106
- {canCapture && (
113
+ {canCapture && !fork && (
107
114
  <Button
108
115
  type="button"
109
116
  variant="ghost"
@@ -135,10 +142,28 @@ export function Toolbar({
135
142
  <span className={plan ? "" : "hidden sm:inline"}>Plan</span>
136
143
  </button>
137
144
  )}
138
- {onSetAccess && access && (
145
+ {fork && (
146
+ <button
147
+ type="button"
148
+ aria-pressed={fork.own}
149
+ title="Own checkout lets the fork edit code; off, it reads this checkout and leaves it alone"
150
+ onMouseDown={(e) => e.preventDefault()}
151
+ onClick={fork.onOwn}
152
+ className={cn(
153
+ "flex h-10 shrink-0 items-center gap-1 rounded-lg px-2 text-xs",
154
+ fork.own
155
+ ? "bg-primary/10 text-primary"
156
+ : "text-muted-foreground hover:text-foreground hover:bg-foreground/[0.05]"
157
+ )}
158
+ >
159
+ <Folder className="h-3.5 w-3.5" />
160
+ {fork.own ? "Own checkout" : "Reads only"}
161
+ </button>
162
+ )}
163
+ {!fork && onSetAccess && access && (
139
164
  <AccessPicker access={access} onPick={onSetAccess} />
140
165
  )}
141
- {onSetModel && (
166
+ {!fork && onSetModel && (
142
167
  <ModelPicker
143
168
  models={models}
144
169
  model={model}
@@ -150,7 +175,7 @@ export function Toolbar({
150
175
  />
151
176
  )}
152
177
  <span className="flex-1" />
153
- {accessory}
178
+ {!fork && accessory}
154
179
  <Button
155
180
  type="button"
156
181
  variant="ghost"
@@ -183,14 +208,25 @@ export function Toolbar({
183
208
  <Button
184
209
  type="button"
185
210
  size="icon"
186
- aria-label="Send"
187
- className="h-10 w-10 shrink-0 rounded-xl"
211
+ aria-label={sendLabel}
212
+ title={sendLabel}
213
+ className={cn(
214
+ "h-10 shrink-0 rounded-xl",
215
+ fork ? "w-auto gap-1.5 px-3.5" : "w-10"
216
+ )}
188
217
  disabled={!canSend}
189
218
  // Tapping send mustn't blur the editor and drop the keyboard.
190
219
  onMouseDown={(e) => e.preventDefault()}
191
220
  onClick={onSend}
192
221
  >
193
- <ArrowUp className="h-4 w-4" />
222
+ {fork ? (
223
+ <>
224
+ <GitFork className="h-3.5 w-3.5" />
225
+ Start fork
226
+ </>
227
+ ) : (
228
+ <ArrowUp className="h-4 w-4" />
229
+ )}
194
230
  </Button>
195
231
  )}
196
232
  </div>
@@ -0,0 +1,57 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ FORK_PLACEHOLDER,
4
+ leaveForkMode,
5
+ placeholderFor,
6
+ suggestionFor,
7
+ type ForkDraft,
8
+ } from "./fork-draft";
9
+ import { forkLabel, runningNote, startedToast } from "./Forks";
10
+ import { startedText } from "@/lib/fork/note";
11
+ import type { ChildView } from "@/lib/fork/types";
12
+
13
+ const draft: ForkDraft = {
14
+ sessionId: "s",
15
+ quote: "the text",
16
+ own: true,
17
+ mode: true,
18
+ };
19
+
20
+ describe("Fork mode composer", () => {
21
+ it("hides the parent's suggestion and uses the fork placeholder", () => {
22
+ expect(
23
+ suggestionFor("what does --rollback --to do", null, true)
24
+ ).toBeNull();
25
+ expect(suggestionFor("next", null, false)).toBe("next");
26
+ expect(suggestionFor("next", "next", false)).toBeNull();
27
+ expect(placeholderFor(true, true, "Work")).toBe(FORK_PLACEHOLDER);
28
+ expect(placeholderFor(true, false, "Work")).toBe("Message Work");
29
+ expect(placeholderFor(false, true, "Work")).toBe("Reconnecting…");
30
+ });
31
+
32
+ it("keeps the quote and drops only the Fork mode on leaving", () => {
33
+ expect(leaveForkMode(draft)).toEqual({ ...draft, mode: false });
34
+ expect(leaveForkMode({ ...draft, quote: "" })).toBeNull();
35
+ });
36
+ });
37
+
38
+ describe("fork copy", () => {
39
+ it("words the started marker and toast", () => {
40
+ expect(startedText([1], ["Check it"])).toBe("Fork 1 started · Check it");
41
+ expect(startedText([2, 3], ["a", "b"])).toBe("Forks 2, 3 started · a · b");
42
+ expect(startedToast([4])).toBe("Started fork 4");
43
+ expect(forkLabel("", "> q")).toBe("> q");
44
+ });
45
+
46
+ it("says how long a running fork has gone and what it's doing", () => {
47
+ const c = {
48
+ startedAt: 1_000,
49
+ activity: "Read sync/worker.ts",
50
+ } as ChildView;
51
+ expect(runningNote(c, 1_000 + 134_000)).toBe(
52
+ "2m 14s · Read sync/worker.ts"
53
+ );
54
+ expect(runningNote({ ...c, activity: null }, 6_000)).toBe("5s");
55
+ expect(runningNote({ ...c, startedAt: null, activity: null })).toBeNull();
56
+ });
57
+ });
@@ -0,0 +1,34 @@
1
+ // A selection being forked, and what's left of it after leaving Fork mode.
2
+ export interface ForkDraft {
3
+ sessionId: string;
4
+ quote: string;
5
+ own: boolean;
6
+ // Off after leaving Fork mode: the quote stays as a chip on a normal reply.
7
+ mode: boolean;
8
+ }
9
+
10
+ export const FORK_PLACEHOLDER = "What should the fork do?";
11
+
12
+ // Esc or the pill's x: only the Fork pill goes; the quote chip stays when
13
+ // there is one, and what was typed stays in the composer.
14
+ export const leaveForkMode = (draft: ForkDraft): ForkDraft | null =>
15
+ draft.quote ? { ...draft, mode: false } : null;
16
+
17
+ // The parent's guess has no place in Fork mode, which asks its own question.
18
+ export const suggestionFor = (
19
+ suggestion: string | null,
20
+ dismissed: string | null,
21
+ inForkMode: boolean
22
+ ): string | null =>
23
+ inForkMode || suggestion === dismissed ? null : suggestion;
24
+
25
+ export const placeholderFor = (
26
+ connected: boolean,
27
+ inForkMode: boolean,
28
+ sessionName: string
29
+ ): string =>
30
+ !connected
31
+ ? "Reconnecting…"
32
+ : inForkMode
33
+ ? FORK_PLACEHOLDER
34
+ : `Message ${sessionName}`;
@@ -103,9 +103,13 @@ export interface Placement {
103
103
 
104
104
  // Where the floating button goes, kept inside the visible chat (`view`),
105
105
  // which ends above the composer. Null when the selection is scrolled out.
106
- export function placeQuote(sel: Box, view: Box, size = 48): Placement | null {
106
+ export function placeQuote(
107
+ sel: Box,
108
+ view: Box,
109
+ size = 48,
110
+ half = 60
111
+ ): Placement | null {
107
112
  if (sel.bottom < view.top || sel.top > view.bottom) return null;
108
- const half = 60;
109
113
  const mid = (sel.left + sel.right) / 2;
110
114
  const x = Math.min(Math.max(mid, view.left + half), view.right - half);
111
115
  if (sel.top - size >= view.top) return { x, y: sel.top, side: "above" };
@@ -1945,6 +1945,30 @@
1945
1945
  "area": "Updates",
1946
1946
  "text": "Automatic updates no longer get stuck after the first one because of a file the build rewrites.",
1947
1947
  "url": "https://github.com/saadnvd1/agent-os/pull/555"
1948
+ },
1949
+ {
1950
+ "id": 558,
1951
+ "date": "2026-11-02",
1952
+ "kind": "improved",
1953
+ "area": "AgentOS",
1954
+ "text": "Select text in a chat reply and tap Fork to hand it to a background copy of the session; its result comes back as a card.",
1955
+ "url": "https://github.com/saadnvd1/agent-os/pull/558"
1956
+ },
1957
+ {
1958
+ "id": 560,
1959
+ "date": "2026-11-02",
1960
+ "kind": "fixed",
1961
+ "area": "Chat",
1962
+ "text": "You can ask follow-up questions in a task's chat after its work has merged, with a note that changes need a new task.",
1963
+ "url": "https://github.com/saadnvd1/agent-os/pull/560"
1964
+ },
1965
+ {
1966
+ "id": 561,
1967
+ "date": "2026-11-02",
1968
+ "kind": "improved",
1969
+ "area": "AgentOS",
1970
+ "text": "Forking from a selection is tidier: the fork pill can be dismissed while keeping your quote and text, and started forks show a one-line marker and what they're doing.",
1971
+ "url": "https://github.com/saadnvd1/agent-os/pull/561"
1948
1972
  }
1949
1973
  ]
1950
1974
  }
@@ -53,7 +53,12 @@ export function useForkChildren(sessionId: string, enabled: boolean) {
53
53
  export function useStartForks(sessionId: string) {
54
54
  const queryClient = useQueryClient();
55
55
  return useMutation({
56
- mutationFn: async (input: { instructions: string[]; worktree: boolean }) =>
56
+ mutationFn: async (input: {
57
+ instructions: string[];
58
+ worktree: boolean;
59
+ labels?: string[];
60
+ delivery?: "wake" | "next";
61
+ }) =>
57
62
  json<{ children: { id: string; n: number; label: string }[] }>(
58
63
  await fetch("/api/fork", {
59
64
  method: "POST",
@@ -83,21 +83,6 @@ export function withFresh(commands: ChatCommand[]): ChatCommand[] {
83
83
  : [FRESH_COMMAND, ...commands];
84
84
  }
85
85
 
86
- // AgentOS's own command when forks are on: opens the fork sheet.
87
- export const FORK_COMMAND: ChatCommand = {
88
- name: "fork",
89
- description: "Start background copies of this session",
90
- };
91
-
92
- export const isForkCommand = (text: string): boolean =>
93
- text.trim().toLowerCase() === "/fork";
94
-
95
- export function withFork(commands: ChatCommand[]): ChatCommand[] {
96
- return commands.some((c) => c.name === "fork")
97
- ? commands
98
- : [FORK_COMMAND, ...commands];
99
- }
100
-
101
86
  export interface MenuGroup {
102
87
  label: string;
103
88
  commands: ChatCommand[];
@@ -106,6 +106,9 @@ export interface ChatOrigin {
106
106
  body?: string;
107
107
  // Lines of an event batch that are the reader's answers to asks.
108
108
  decided?: string[];
109
+ // A fork delivery: the parent and the children it carries, shown as result
110
+ // cards (lib/fork/deliver).
111
+ forks?: { parent: string; ids: string[] };
109
112
  // The tasks it is about, by name, set where the event is delivered.
110
113
  subjects?: { id: string; name: string }[];
111
114
  // Only Away mode's own lines: the turn it starts isn't anyone's activity
@@ -32,6 +32,7 @@ import {
32
32
  import { setComposerModel } from "../models/session-model";
33
33
  import { stopChatTurn } from "./stop";
34
34
  import { clientSend } from "./client-send";
35
+ import { startFailureMessage } from "./start-error";
35
36
  import { searchChat } from "./search";
36
37
  import { DEMO_CHAT_READS, handleDemoChat } from "./demo";
37
38
  import { titleChatFromMessage } from "../session-titles";
@@ -97,7 +98,7 @@ export function serveChatSocket(
97
98
  const fail = (err: unknown) => {
98
99
  console.error(`[chat ${sessionId}] message failed:`, err);
99
100
  if (!sessionExists(sessionId)) return gone();
100
- reply(errorItem(err instanceof Error ? err.message : String(err)));
101
+ reply(errorItem(startFailureMessage(err)));
101
102
  };
102
103
 
103
104
  if (!sessionExists(sessionId)) return gone();
@@ -157,9 +158,12 @@ function onChatMessage(
157
158
  reply({
158
159
  type: "send_failed",
159
160
  id,
160
- message: err instanceof Error ? err.message : String(err),
161
+ message: startFailureMessage(err),
161
162
  });
162
- fail(err);
163
+ // A message with an id shows its failure on itself; the rest of the
164
+ // chat hears of it once, here.
165
+ if (id) console.error(`[chat ${sessionId}] send failed:`, err);
166
+ else fail(err);
163
167
  }
164
168
  );
165
169
  // "Session 4" is named after its first message.