@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.
- package/README.md +2 -0
- package/app/api/sessions/route.ts +7 -1
- package/app/globals.css +14 -0
- package/app/layout.tsx +3 -0
- package/components/Chat/ChatPanel.tsx +110 -27
- package/components/Chat/EventRow.tsx +15 -1
- package/components/Chat/ForkResults.tsx +109 -0
- package/components/Chat/Forks.tsx +69 -103
- package/components/Chat/Messages.tsx +14 -0
- package/components/Chat/QuoteButton.tsx +59 -21
- package/components/Chat/TimelineRows.tsx +1 -0
- package/components/Chat/WorktreeRemovedNotice.tsx +17 -0
- package/components/Chat/composer/ComposerBody.tsx +23 -2
- package/components/Chat/composer/ForkHeader.tsx +66 -0
- package/components/Chat/composer/Toolbar.tsx +43 -7
- package/components/Chat/fork-draft.test.ts +57 -0
- package/components/Chat/fork-draft.ts +34 -0
- package/components/Chat/quote-selection.ts +6 -2
- package/content/whats-new.json +24 -0
- package/data/fork/index.ts +6 -1
- package/lib/chat/commands.ts +0 -15
- package/lib/chat/events.ts +3 -0
- package/lib/chat/socket.ts +7 -3
- package/lib/chat/start-error.ts +13 -0
- package/lib/chat/worker/cwd.ts +77 -0
- package/lib/chat/worker/host.ts +11 -2
- package/lib/chat/worker/main.ts +11 -2
- package/lib/db/types.ts +2 -0
- package/lib/fork/brief.ts +4 -2
- package/lib/fork/deliver.ts +6 -1
- package/lib/fork/ledger.ts +5 -0
- package/lib/fork/note.ts +6 -0
- package/lib/fork/settle.ts +8 -4
- package/lib/fork/start.ts +6 -2
- package/lib/fork/types.ts +7 -1
- package/lib/fork/view.ts +18 -2
- package/package.json +1 -1
- package/scripts/lib/build-swap.sh +1 -1
- 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 {
|
|
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()}`, [
|
|
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
|
-
<
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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>;
|
|
@@ -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
|
-
!
|
|
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
|
-
{
|
|
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=
|
|
187
|
-
|
|
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
|
-
|
|
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(
|
|
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" };
|
package/content/whats-new.json
CHANGED
|
@@ -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
|
}
|
package/data/fork/index.ts
CHANGED
|
@@ -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: {
|
|
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",
|
package/lib/chat/commands.ts
CHANGED
|
@@ -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[];
|
package/lib/chat/events.ts
CHANGED
|
@@ -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
|
package/lib/chat/socket.ts
CHANGED
|
@@ -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(
|
|
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:
|
|
161
|
+
message: startFailureMessage(err),
|
|
161
162
|
});
|
|
162
|
-
|
|
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.
|