@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
package/README.md
CHANGED
|
@@ -68,6 +68,8 @@ It ends by offering `agent-os setup`. If another device can't reach the machine
|
|
|
68
68
|
|
|
69
69
|
Then open AgentOS, add a project from ⌘K, and press ⌥N. The [install guide](docs/guides/install.md) covers Windows (WSL2), the supported agents, first run and the `agent-os` commands. To use it from your phone, see the [phone guide](docs/guides/phone.md).
|
|
70
70
|
|
|
71
|
+
AgentOS keeps itself up to date: once a release is two hours old and nothing has been working for 10 minutes, it installs it, and puts the previous version back if the new one doesn't come up healthy. Turn it off or change the idle time in **Settings → Updates**, or pin it with `AGENTOS_AUTO_UPDATE=off` and `AGENTOS_AUTO_UPDATE_IDLE_MIN`. Details are in [Automatic updates](docs/guides/install.md#automatic-updates).
|
|
72
|
+
|
|
71
73
|
## Connect
|
|
72
74
|
|
|
73
75
|
AgentOS Connect is live. It gives your machine a private address at runagentos.com, so your phone reaches it from any network with nothing to open on your router and no VPN.
|
|
@@ -10,6 +10,7 @@ import { ModelUnavailableError } from "@/lib/models/agent-binding";
|
|
|
10
10
|
import { namesLine, waitBadges } from "@/lib/bus/waits/store";
|
|
11
11
|
import { claimsSnapshot } from "@/lib/claims";
|
|
12
12
|
import { isProjectRef } from "@/lib/tasks/project-ref";
|
|
13
|
+
import { worktreeRemoved } from "@/lib/chat/worker/cwd";
|
|
13
14
|
import { omitNulls } from "@/lib/sessions/omit-null";
|
|
14
15
|
|
|
15
16
|
// The sidebar's Done shelf lists this many finished tasks; ?done=all lists
|
|
@@ -34,7 +35,12 @@ export async function GET(request?: NextRequest) {
|
|
|
34
35
|
const sessions = rows.map((s) => {
|
|
35
36
|
const b = badges.get(s.id);
|
|
36
37
|
const overlap = claims.bySession.get(s.id);
|
|
37
|
-
const
|
|
38
|
+
const gone = worktreeRemoved(s);
|
|
39
|
+
const row = {
|
|
40
|
+
...s,
|
|
41
|
+
...(overlap && { overlap }),
|
|
42
|
+
...(gone && { worktree_removed: true }),
|
|
43
|
+
};
|
|
38
44
|
return omitNulls(
|
|
39
45
|
b
|
|
40
46
|
? {
|
package/app/globals.css
CHANGED
|
@@ -334,3 +334,17 @@ body {
|
|
|
334
334
|
::highlight(conv-search) {
|
|
335
335
|
background-color: hsl(var(--find-match));
|
|
336
336
|
}
|
|
337
|
+
|
|
338
|
+
/* Toast close button inside the toast's right edge, not half outside the left. */
|
|
339
|
+
[data-sonner-toaster]
|
|
340
|
+
[data-sonner-toast][data-styled="true"]
|
|
341
|
+
[data-close-button] {
|
|
342
|
+
left: auto;
|
|
343
|
+
right: 6px;
|
|
344
|
+
top: 50%;
|
|
345
|
+
margin-top: -10px;
|
|
346
|
+
transform: none;
|
|
347
|
+
background: transparent;
|
|
348
|
+
border-color: transparent;
|
|
349
|
+
color: hsl(var(--muted-foreground));
|
|
350
|
+
}
|
package/app/layout.tsx
CHANGED
|
@@ -97,6 +97,9 @@ export default function RootLayout({
|
|
|
97
97
|
</SerwistProvider>
|
|
98
98
|
<Toaster
|
|
99
99
|
position="top-center"
|
|
100
|
+
// Below the header, not over its title.
|
|
101
|
+
offset={{ top: 56 }}
|
|
102
|
+
mobileOffset={{ top: 64 }}
|
|
100
103
|
closeButton
|
|
101
104
|
toastOptions={{
|
|
102
105
|
className: "!bg-card !border-border !text-foreground !shadow-lg",
|
|
@@ -8,20 +8,24 @@ import {
|
|
|
8
8
|
useRef,
|
|
9
9
|
useState,
|
|
10
10
|
} from "react";
|
|
11
|
-
import { ArrowDown, Loader2 } from "lucide-react";
|
|
11
|
+
import { ArrowDown, GitFork, Loader2 } from "lucide-react";
|
|
12
12
|
import type { ClipboardEvent } from "react";
|
|
13
13
|
import { useChat } from "@/data/chat/useChat";
|
|
14
14
|
import { PausedBanner } from "./PausedBanner";
|
|
15
|
+
import { WorktreeRemovedNotice } from "./WorktreeRemovedNotice";
|
|
15
16
|
import { groupTimeline } from "@/lib/chat/group";
|
|
16
17
|
import type { ChatItem } from "@/lib/chat/events";
|
|
17
|
-
import {
|
|
18
|
-
isForkCommand,
|
|
19
|
-
isFreshCommand,
|
|
20
|
-
withFork,
|
|
21
|
-
withFresh,
|
|
22
|
-
} from "@/lib/chat/commands";
|
|
18
|
+
import { isFreshCommand, withFresh } from "@/lib/chat/commands";
|
|
23
19
|
import { useExperiment } from "@/data/experiments";
|
|
24
|
-
import {
|
|
20
|
+
import { useStartForks } from "@/data/fork";
|
|
21
|
+
import { toast } from "sonner";
|
|
22
|
+
import {
|
|
23
|
+
leaveForkMode,
|
|
24
|
+
placeholderFor,
|
|
25
|
+
suggestionFor,
|
|
26
|
+
type ForkDraft,
|
|
27
|
+
} from "./fork-draft";
|
|
28
|
+
import { ForksStrip, forkLabel, startedToast } from "./Forks";
|
|
25
29
|
import { ReplyingTo } from "./ReplyingTo";
|
|
26
30
|
import { compactsFirst } from "@/lib/chat/pending";
|
|
27
31
|
import { useFreshStart } from "@/data/sessions";
|
|
@@ -107,8 +111,13 @@ export function ChatPanel({
|
|
|
107
111
|
append?: boolean;
|
|
108
112
|
}>();
|
|
109
113
|
const forksOn = useExperiment("forks");
|
|
110
|
-
const
|
|
111
|
-
|
|
114
|
+
const startFork = useStartForks(sessionId);
|
|
115
|
+
// Set while a selection is being forked; per session, like asIs below.
|
|
116
|
+
const [forkDraft, setForkDraft] = useState<ForkDraft | null>(null);
|
|
117
|
+
const draftHere = forkDraft?.sessionId === sessionId ? forkDraft : null;
|
|
118
|
+
const fork = forksOn && draftHere?.mode ? draftHere : null;
|
|
119
|
+
const quoteChip =
|
|
120
|
+
draftHere && !draftHere.mode && draftHere.quote ? draftHere : null;
|
|
112
121
|
const {
|
|
113
122
|
items,
|
|
114
123
|
hasMore,
|
|
@@ -178,7 +187,7 @@ export function ChatPanel({
|
|
|
178
187
|
|
|
179
188
|
// The suggestion the reader set aside (typed over it, or Esc) stays hidden.
|
|
180
189
|
const [dismissed, setDismissed] = useState<string | null>(null);
|
|
181
|
-
const shownSuggestion = suggestion
|
|
190
|
+
const shownSuggestion = suggestionFor(suggestion, dismissed, !!fork);
|
|
182
191
|
const dismissSuggestion = useCallback((s: string) => setDismissed(s), []);
|
|
183
192
|
const history = useMemo(() => sentPrompts(items), [items]);
|
|
184
193
|
const [insert, setInsert] = useState<{
|
|
@@ -187,12 +196,31 @@ export function ChatPanel({
|
|
|
187
196
|
append?: boolean;
|
|
188
197
|
}>();
|
|
189
198
|
|
|
199
|
+
// Leaving Fork mode keeps the selection as a quote chip and the typed text.
|
|
200
|
+
const leaveFork = () => {
|
|
201
|
+
if (!fork) return;
|
|
202
|
+
setForkDraft(leaveForkMode(fork));
|
|
203
|
+
toast("Fork mode off · your text is kept", {
|
|
204
|
+
icon: <GitFork aria-hidden className="size-4" />,
|
|
205
|
+
});
|
|
206
|
+
};
|
|
207
|
+
|
|
190
208
|
// Esc sets the composer's ghost text aside, or else stops the turn
|
|
191
209
|
// (lib/chat/escape), from anywhere in this panel.
|
|
192
210
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
193
|
-
const escape = useRef({
|
|
211
|
+
const escape = useRef({
|
|
212
|
+
running,
|
|
213
|
+
interrupt,
|
|
214
|
+
suggestion,
|
|
215
|
+
leaveFork: null as (() => void) | null,
|
|
216
|
+
});
|
|
194
217
|
useEffect(() => {
|
|
195
|
-
escape.current = {
|
|
218
|
+
escape.current = {
|
|
219
|
+
running,
|
|
220
|
+
interrupt,
|
|
221
|
+
suggestion,
|
|
222
|
+
leaveFork: fork ? leaveFork : null,
|
|
223
|
+
};
|
|
196
224
|
});
|
|
197
225
|
useEffect(() => {
|
|
198
226
|
const onKey = (e: KeyboardEvent) => {
|
|
@@ -203,6 +231,17 @@ export function ChatPanel({
|
|
|
203
231
|
const overlay = !!document.querySelector(OVERLAY_SELECTOR);
|
|
204
232
|
const ghost = !!root.querySelector(GHOST_SELECTOR);
|
|
205
233
|
const composer = (e.target as Element).closest?.(".chat-composer");
|
|
234
|
+
// Fork mode ends first; the typed text stays as a normal draft.
|
|
235
|
+
if (
|
|
236
|
+
e.key === "Escape" &&
|
|
237
|
+
escape.current.leaveFork &&
|
|
238
|
+
composer &&
|
|
239
|
+
!overlay
|
|
240
|
+
) {
|
|
241
|
+
e.preventDefault();
|
|
242
|
+
escape.current.leaveFork();
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
206
245
|
const action = escapeAction(
|
|
207
246
|
e,
|
|
208
247
|
escape.current.running,
|
|
@@ -233,6 +272,10 @@ export function ChatPanel({
|
|
|
233
272
|
setInsert({ text: quoteMarkdown(t), at: Date.now(), append: true }),
|
|
234
273
|
focusComposer
|
|
235
274
|
);
|
|
275
|
+
const forkQuote = (text: string) => {
|
|
276
|
+
setForkDraft({ sessionId, quote: text, own: false, mode: true });
|
|
277
|
+
focusComposer();
|
|
278
|
+
};
|
|
236
279
|
const togglePlan = plan === null ? undefined : () => setPlan(!plan);
|
|
237
280
|
const sendText = (text: string) => {
|
|
238
281
|
toEnd();
|
|
@@ -643,8 +686,13 @@ export function ChatPanel({
|
|
|
643
686
|
onStop={stopTask}
|
|
644
687
|
onLoadOutput={loadTaskOutput}
|
|
645
688
|
/>
|
|
646
|
-
<QuoteButton
|
|
689
|
+
<QuoteButton
|
|
690
|
+
rootRef={scrollRef}
|
|
691
|
+
onQuote={quote}
|
|
692
|
+
onFork={forksOn ? forkQuote : undefined}
|
|
693
|
+
/>
|
|
647
694
|
<PausedBanner sessionId={sessionId} />
|
|
695
|
+
<WorktreeRemovedNotice sessionId={sessionId} />
|
|
648
696
|
<ForksStrip sessionId={sessionId} />
|
|
649
697
|
<Queue
|
|
650
698
|
queue={queue}
|
|
@@ -682,25 +730,67 @@ export function ChatPanel({
|
|
|
682
730
|
insert={insert}
|
|
683
731
|
running={running}
|
|
684
732
|
disabled={!connected}
|
|
685
|
-
placeholder={connected
|
|
733
|
+
placeholder={placeholderFor(connected, !!fork, sessionName)}
|
|
734
|
+
fork={
|
|
735
|
+
fork
|
|
736
|
+
? {
|
|
737
|
+
quote: fork.quote,
|
|
738
|
+
own: fork.own,
|
|
739
|
+
onOwn: () => setForkDraft({ ...fork, own: !fork.own }),
|
|
740
|
+
onLeave: leaveFork,
|
|
741
|
+
onDropQuote: () => setForkDraft({ ...fork, quote: "" }),
|
|
742
|
+
}
|
|
743
|
+
: undefined
|
|
744
|
+
}
|
|
745
|
+
quoteChip={
|
|
746
|
+
quoteChip
|
|
747
|
+
? {
|
|
748
|
+
text: quoteChip.quote,
|
|
749
|
+
onDrop: () => setForkDraft(null),
|
|
750
|
+
}
|
|
751
|
+
: undefined
|
|
752
|
+
}
|
|
686
753
|
onSend={(text, images, files) => {
|
|
687
754
|
if (freshStart && isFreshCommand(text)) {
|
|
688
755
|
startFresh.mutate(sessionId);
|
|
689
756
|
return;
|
|
690
757
|
}
|
|
691
|
-
if (
|
|
692
|
-
|
|
758
|
+
if (fork) {
|
|
759
|
+
startFork.mutate(
|
|
760
|
+
{
|
|
761
|
+
instructions: [(quoteMarkdown(fork.quote) + text).trim()],
|
|
762
|
+
worktree: fork.own,
|
|
763
|
+
// The result comes back by itself, as a card, without a message from the reader.
|
|
764
|
+
delivery: "wake",
|
|
765
|
+
labels: [forkLabel(text, fork.quote)],
|
|
766
|
+
},
|
|
767
|
+
{
|
|
768
|
+
onSuccess: (res) => {
|
|
769
|
+
toast.success(startedToast(res.children.map((c) => c.n)));
|
|
770
|
+
setForkDraft(null);
|
|
771
|
+
},
|
|
772
|
+
onError: (e) => {
|
|
773
|
+
toast.error(e.message);
|
|
774
|
+
setInsert({ text, at: Date.now() });
|
|
775
|
+
},
|
|
776
|
+
}
|
|
777
|
+
);
|
|
693
778
|
return;
|
|
694
779
|
}
|
|
695
780
|
toEnd();
|
|
696
|
-
|
|
781
|
+
// A quote kept from Fork mode goes out ahead of what was typed.
|
|
782
|
+
const body = quoteChip
|
|
783
|
+
? (quoteMarkdown(quoteChip.quote) + text).trim()
|
|
784
|
+
: text;
|
|
785
|
+
if (quoteChip) setForkDraft(null);
|
|
786
|
+
const compact = composerCompacts(body, context, {
|
|
697
787
|
state,
|
|
698
788
|
thresholdTokens: compactTokens,
|
|
699
789
|
asIs,
|
|
700
790
|
});
|
|
701
791
|
setAsIs(false);
|
|
702
792
|
send(
|
|
703
|
-
|
|
793
|
+
body,
|
|
704
794
|
images.length ? images : undefined,
|
|
705
795
|
compact,
|
|
706
796
|
files.length ? files : undefined
|
|
@@ -711,7 +801,7 @@ export function ChatPanel({
|
|
|
711
801
|
}
|
|
712
802
|
}}
|
|
713
803
|
onStop={interrupt}
|
|
714
|
-
commands={
|
|
804
|
+
commands={freshStart ? withFresh(commands) : commands}
|
|
715
805
|
onRefreshCommands={refreshCommands}
|
|
716
806
|
refreshingCommands={refreshingCommands}
|
|
717
807
|
models={models}
|
|
@@ -731,13 +821,6 @@ export function ChatPanel({
|
|
|
731
821
|
focusKey={active ? sessionId : null}
|
|
732
822
|
/>
|
|
733
823
|
</div>
|
|
734
|
-
{forksOn && (
|
|
735
|
-
<ForkDialog
|
|
736
|
-
sessionId={sessionId}
|
|
737
|
-
open={forkOpen}
|
|
738
|
-
onOpenChange={setForkOpen}
|
|
739
|
-
/>
|
|
740
|
-
)}
|
|
741
824
|
{undoPreview && (
|
|
742
825
|
<UndoDialog
|
|
743
826
|
preview={undoPreview.preview}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from "lucide-react";
|
|
14
14
|
import type { ChatItem, ChatOrigin, ChatOriginKind } from "@/lib/chat/events";
|
|
15
15
|
import { eventParts, originOf } from "@/lib/chat/origin";
|
|
16
|
+
import { ForkResults } from "./ForkResults";
|
|
16
17
|
import { SessionAnchor } from "./SessionAnchor";
|
|
17
18
|
import { cn } from "@/lib/utils";
|
|
18
19
|
import { UserMessage } from "./Messages";
|
|
@@ -155,13 +156,24 @@ function Row({
|
|
|
155
156
|
export function EventRow({
|
|
156
157
|
item,
|
|
157
158
|
origin,
|
|
159
|
+
onQuote,
|
|
158
160
|
}: {
|
|
159
161
|
item: UserItem;
|
|
160
162
|
origin: ChatOrigin;
|
|
163
|
+
onQuote?: (text: string) => void;
|
|
161
164
|
}) {
|
|
162
165
|
const { body, decided } = eventParts(item, origin);
|
|
163
166
|
const decidedLabel = `${ownerInUi(useOwnerName(), true)} decided`;
|
|
164
167
|
const decision = origin.kind === "decision";
|
|
168
|
+
if (origin.forks)
|
|
169
|
+
return (
|
|
170
|
+
<ForkResults
|
|
171
|
+
parent={origin.forks.parent}
|
|
172
|
+
ids={origin.forks.ids}
|
|
173
|
+
fallback={body}
|
|
174
|
+
onQuote={onQuote}
|
|
175
|
+
/>
|
|
176
|
+
);
|
|
165
177
|
return (
|
|
166
178
|
<div className="flex flex-col gap-1">
|
|
167
179
|
{decided.map((d, i) => (
|
|
@@ -207,11 +219,13 @@ export function EventRow({
|
|
|
207
219
|
export function UserTurn({
|
|
208
220
|
item,
|
|
209
221
|
onUndo,
|
|
222
|
+
onQuote,
|
|
210
223
|
}: {
|
|
211
224
|
item: UserItem;
|
|
212
225
|
onUndo?: () => void;
|
|
226
|
+
onQuote?: (text: string) => void;
|
|
213
227
|
}) {
|
|
214
228
|
const origin = originOf(item);
|
|
215
|
-
if (origin) return <EventRow item={item} origin={origin} />;
|
|
229
|
+
if (origin) return <EventRow item={item} origin={origin} onQuote={onQuote} />;
|
|
216
230
|
return <UserMessage item={item} onUndo={onUndo} />;
|
|
217
231
|
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { GitFork, Quote, RotateCcw } from "lucide-react";
|
|
4
|
+
import { useForkChildren, type ChildView } from "@/data/fork";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { StatusPill } from "@/components/ui/status-pill";
|
|
7
|
+
import { SessionAnchor } from "./SessionAnchor";
|
|
8
|
+
import { emptyNote, stateLabel, useRunAgain } from "./Forks";
|
|
9
|
+
|
|
10
|
+
const took = (c: ChildView) => {
|
|
11
|
+
if (!c.startedAt || !c.settledAt) return null;
|
|
12
|
+
const s = Math.max(1, Math.round((c.settledAt - c.startedAt) / 1000));
|
|
13
|
+
return s < 60
|
|
14
|
+
? `${s}s`
|
|
15
|
+
: `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function Card({
|
|
19
|
+
c,
|
|
20
|
+
onQuote,
|
|
21
|
+
again,
|
|
22
|
+
}: {
|
|
23
|
+
c: ChildView;
|
|
24
|
+
onQuote?: (text: string) => void;
|
|
25
|
+
again: ReturnType<typeof useRunAgain>;
|
|
26
|
+
}) {
|
|
27
|
+
const { text, tone } = stateLabel(c);
|
|
28
|
+
const empty = c.outcome === "empty";
|
|
29
|
+
const body = empty ? emptyNote(c) : (c.excerpt ?? c.summary ?? "");
|
|
30
|
+
return (
|
|
31
|
+
<section
|
|
32
|
+
aria-label={`Fork ${c.n} result`}
|
|
33
|
+
className="bg-card popover-surface space-y-2 rounded-2xl p-3"
|
|
34
|
+
>
|
|
35
|
+
<div className="flex items-center gap-2">
|
|
36
|
+
<GitFork aria-hidden className="text-muted-foreground size-4" />
|
|
37
|
+
<span className="text-muted-foreground font-mono text-[11px] tracking-wider uppercase">
|
|
38
|
+
Fork {c.n}
|
|
39
|
+
</span>
|
|
40
|
+
<StatusPill tone={tone}>{text}</StatusPill>
|
|
41
|
+
<span className="text-muted-foreground ml-auto text-xs">{took(c)}</span>
|
|
42
|
+
</div>
|
|
43
|
+
<p className="text-sm font-medium">{c.label.replace(/^\d+\.\s*/, "")}</p>
|
|
44
|
+
<p
|
|
45
|
+
className={
|
|
46
|
+
empty
|
|
47
|
+
? "text-sm text-amber-600 dark:text-amber-400"
|
|
48
|
+
: "text-muted-foreground line-clamp-6 text-sm whitespace-pre-wrap"
|
|
49
|
+
}
|
|
50
|
+
>
|
|
51
|
+
{body}
|
|
52
|
+
</p>
|
|
53
|
+
<div className="flex flex-wrap gap-2">
|
|
54
|
+
<Button asChild size="sm" className="min-h-11">
|
|
55
|
+
<SessionAnchor sessionId={c.id}>Open fork</SessionAnchor>
|
|
56
|
+
</Button>
|
|
57
|
+
{empty ? (
|
|
58
|
+
<Button
|
|
59
|
+
size="sm"
|
|
60
|
+
variant="secondary"
|
|
61
|
+
className="min-h-11"
|
|
62
|
+
disabled={again.pending}
|
|
63
|
+
onClick={() => again.run(c)}
|
|
64
|
+
>
|
|
65
|
+
<RotateCcw aria-hidden className="size-4" /> Run again
|
|
66
|
+
</Button>
|
|
67
|
+
) : (
|
|
68
|
+
onQuote &&
|
|
69
|
+
body && (
|
|
70
|
+
<Button
|
|
71
|
+
size="sm"
|
|
72
|
+
variant="secondary"
|
|
73
|
+
className="min-h-11"
|
|
74
|
+
onClick={() => onQuote(c.excerpt ?? c.summary ?? "")}
|
|
75
|
+
>
|
|
76
|
+
<Quote aria-hidden className="size-4" /> Quote
|
|
77
|
+
</Button>
|
|
78
|
+
)
|
|
79
|
+
)}
|
|
80
|
+
</div>
|
|
81
|
+
</section>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// A delivery of fork results, as one card per fork in the parent's chat.
|
|
86
|
+
export function ForkResults({
|
|
87
|
+
parent,
|
|
88
|
+
ids,
|
|
89
|
+
onQuote,
|
|
90
|
+
fallback,
|
|
91
|
+
}: {
|
|
92
|
+
parent: string;
|
|
93
|
+
ids: string[];
|
|
94
|
+
fallback: string;
|
|
95
|
+
onQuote?: (text: string) => void;
|
|
96
|
+
}) {
|
|
97
|
+
const { data } = useForkChildren(parent, true);
|
|
98
|
+
const again = useRunAgain(parent);
|
|
99
|
+
const cards = (data?.children ?? []).filter((c) => ids.includes(c.id));
|
|
100
|
+
if (!cards.length)
|
|
101
|
+
return <p className="text-muted-foreground text-xs">{fallback}</p>;
|
|
102
|
+
return (
|
|
103
|
+
<div className="flex flex-col gap-2">
|
|
104
|
+
{cards.map((c) => (
|
|
105
|
+
<Card key={c.id} c={c} onQuote={onQuote} again={again} />
|
|
106
|
+
))}
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -1,28 +1,18 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useState } from "react";
|
|
4
|
-
import { GitFork,
|
|
4
|
+
import { GitFork, Square } from "lucide-react";
|
|
5
5
|
import { toast } from "sonner";
|
|
6
6
|
import { useExperiment } from "@/data/experiments";
|
|
7
|
-
import { useClientConfig } from "@/data/client-config/queries";
|
|
8
7
|
import {
|
|
9
8
|
useCancelForks,
|
|
10
9
|
useForkChildren,
|
|
11
10
|
useStartForks,
|
|
12
11
|
type ChildView,
|
|
13
12
|
} from "@/data/fork";
|
|
13
|
+
import { formatElapsed } from "@/lib/chat/elapsed";
|
|
14
14
|
import { Button } from "@/components/ui/button";
|
|
15
|
-
import {
|
|
16
|
-
Dialog,
|
|
17
|
-
DialogContent,
|
|
18
|
-
DialogDescription,
|
|
19
|
-
DialogFooter,
|
|
20
|
-
DialogHeader,
|
|
21
|
-
DialogTitle,
|
|
22
|
-
} from "@/components/ui/dialog";
|
|
23
15
|
import { StatusPill } from "@/components/ui/status-pill";
|
|
24
|
-
import { Switch } from "@/components/ui/switch";
|
|
25
|
-
import { Textarea } from "@/components/ui/textarea";
|
|
26
16
|
import { SessionAnchor } from "./SessionAnchor";
|
|
27
17
|
|
|
28
18
|
type Tone = "amber" | "muted" | "green" | "primary" | "red";
|
|
@@ -34,22 +24,69 @@ export function stateLabel(c: ChildView): { text: string; tone: Tone } {
|
|
|
34
24
|
if (c.state === "needs_input") return { text: "Needs you", tone: "amber" };
|
|
35
25
|
if (c.outcome === "failed") return { text: "Failed", tone: "red" };
|
|
36
26
|
if (c.outcome === "cancelled") return { text: "Stopped", tone: "muted" };
|
|
27
|
+
if (c.outcome === "empty") return { text: "No result", tone: "amber" };
|
|
37
28
|
return { text: "Done", tone: "green" };
|
|
38
29
|
}
|
|
39
30
|
|
|
31
|
+
// The strip's name for a fork: what was asked, else what was quoted, so a
|
|
32
|
+
// quote-first instruction isn't labelled with its "> ".
|
|
33
|
+
export const forkLabel = (typed: string, quote: string): string => {
|
|
34
|
+
const line = (typed.trim() || quote.trim()).split("\n")[0].trim();
|
|
35
|
+
return line.length > 60 ? `${line.slice(0, 59)}…` : line;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const startedToast = (ns: number[]): string =>
|
|
39
|
+
ns.length === 1 ? `Started fork ${ns[0]}` : `Started forks ${ns.join(", ")}`;
|
|
40
|
+
|
|
41
|
+
// What a fork that ended with nothing to read says in place of a result.
|
|
42
|
+
export const emptyNote = (c: ChildView) =>
|
|
43
|
+
c.summary && c.summary !== "ended without a result"
|
|
44
|
+
? `Ended with “${c.summary}”, not an answer`
|
|
45
|
+
: "Ended without a result";
|
|
46
|
+
|
|
47
|
+
// Starts the same instruction again, for a fork that came back with nothing.
|
|
48
|
+
export function useRunAgain(sessionId: string) {
|
|
49
|
+
const start = useStartForks(sessionId);
|
|
50
|
+
return {
|
|
51
|
+
pending: start.isPending,
|
|
52
|
+
run: (c: ChildView) =>
|
|
53
|
+
start.mutate(
|
|
54
|
+
{ instructions: [c.instruction], worktree: c.worktree },
|
|
55
|
+
{
|
|
56
|
+
onSuccess: (res) =>
|
|
57
|
+
toast.success(startedToast(res.children.map((x) => x.n))),
|
|
58
|
+
onError: (e) => toast.error(e.message),
|
|
59
|
+
}
|
|
60
|
+
),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// "2m 14s · reading sync/worker.ts": how long it has gone and its latest step.
|
|
65
|
+
export const runningNote = (c: ChildView, now = Date.now()): string | null => {
|
|
66
|
+
const parts = [
|
|
67
|
+
c.startedAt ? formatElapsed(now - c.startedAt) : null,
|
|
68
|
+
c.activity,
|
|
69
|
+
].filter(Boolean);
|
|
70
|
+
return parts.length ? parts.join(" · ") : null;
|
|
71
|
+
};
|
|
72
|
+
|
|
40
73
|
function Row({
|
|
41
74
|
c,
|
|
42
75
|
onStop,
|
|
43
76
|
stopping,
|
|
77
|
+
again,
|
|
44
78
|
}: {
|
|
45
79
|
c: ChildView;
|
|
80
|
+
again: ReturnType<typeof useRunAgain>;
|
|
46
81
|
onStop: () => void;
|
|
47
82
|
stopping: boolean;
|
|
48
83
|
}) {
|
|
49
84
|
const { text, tone } = stateLabel(c);
|
|
50
85
|
const open = c.state !== "settled";
|
|
86
|
+
const empty = c.outcome === "empty";
|
|
51
87
|
const note =
|
|
52
|
-
c.summary ??
|
|
88
|
+
(empty ? emptyNote(c) : c.summary) ??
|
|
89
|
+
(c.state === "running" ? runningNote(c) : null) ??
|
|
53
90
|
(c.asks
|
|
54
91
|
? `Asks: ${c.asks}`
|
|
55
92
|
: c.state === "queued"
|
|
@@ -65,7 +102,13 @@ function Row({
|
|
|
65
102
|
{c.n}. {c.label.replace(/^\d+\.\s*/, "")}
|
|
66
103
|
</p>
|
|
67
104
|
{note && (
|
|
68
|
-
<p
|
|
105
|
+
<p
|
|
106
|
+
className={
|
|
107
|
+
empty
|
|
108
|
+
? "truncate text-xs text-amber-600 dark:text-amber-400"
|
|
109
|
+
: "text-muted-foreground truncate text-xs"
|
|
110
|
+
}
|
|
111
|
+
>
|
|
69
112
|
{note}
|
|
70
113
|
{c.resultKind === "implicit" && c.outcome === "ok"
|
|
71
114
|
? " · ended without reporting"
|
|
@@ -73,6 +116,16 @@ function Row({
|
|
|
73
116
|
</p>
|
|
74
117
|
)}
|
|
75
118
|
</div>
|
|
119
|
+
{empty && (
|
|
120
|
+
<button
|
|
121
|
+
type="button"
|
|
122
|
+
disabled={again.pending}
|
|
123
|
+
onClick={() => again.run(c)}
|
|
124
|
+
className="text-primary flex min-h-11 shrink-0 items-center px-2 text-sm"
|
|
125
|
+
>
|
|
126
|
+
Run again
|
|
127
|
+
</button>
|
|
128
|
+
)}
|
|
76
129
|
<SessionAnchor
|
|
77
130
|
sessionId={c.id}
|
|
78
131
|
className="text-primary flex min-h-11 shrink-0 items-center px-2 text-sm"
|
|
@@ -100,6 +153,7 @@ export function ForksStrip({ sessionId }: { sessionId: string }) {
|
|
|
100
153
|
const on = useExperiment("forks");
|
|
101
154
|
const { data } = useForkChildren(sessionId, on);
|
|
102
155
|
const cancel = useCancelForks(sessionId);
|
|
156
|
+
const again = useRunAgain(sessionId);
|
|
103
157
|
const [collapsed, setCollapsed] = useState(false);
|
|
104
158
|
if (!on || !data) return null;
|
|
105
159
|
if (data.asChild) return <ForkOfBanner info={data.asChild} />;
|
|
@@ -150,6 +204,7 @@ export function ForksStrip({ sessionId }: { sessionId: string }) {
|
|
|
150
204
|
key={c.id}
|
|
151
205
|
c={c}
|
|
152
206
|
stopping={cancel.isPending}
|
|
207
|
+
again={again}
|
|
153
208
|
onStop={() => stop(String(c.n))}
|
|
154
209
|
/>
|
|
155
210
|
))}
|
|
@@ -187,92 +242,3 @@ function ForkOfBanner({
|
|
|
187
242
|
</section>
|
|
188
243
|
);
|
|
189
244
|
}
|
|
190
|
-
|
|
191
|
-
// "/fork": instructions for N copies of this session.
|
|
192
|
-
export function ForkDialog({
|
|
193
|
-
sessionId,
|
|
194
|
-
open,
|
|
195
|
-
onOpenChange,
|
|
196
|
-
}: {
|
|
197
|
-
sessionId: string;
|
|
198
|
-
open: boolean;
|
|
199
|
-
onOpenChange: (open: boolean) => void;
|
|
200
|
-
}) {
|
|
201
|
-
const start = useStartForks(sessionId);
|
|
202
|
-
const max = useClientConfig().data?.forks?.maxPerFork ?? 8;
|
|
203
|
-
const [texts, setTexts] = useState<string[]>([""]);
|
|
204
|
-
const [worktree, setWorktree] = useState(false);
|
|
205
|
-
const filled = texts.map((t) => t.trim()).filter(Boolean);
|
|
206
|
-
const submit = () =>
|
|
207
|
-
start.mutate(
|
|
208
|
-
{ instructions: filled, worktree },
|
|
209
|
-
{
|
|
210
|
-
onSuccess: (r) => {
|
|
211
|
-
toast.success(
|
|
212
|
-
`Started ${r.children.length} ${r.children.length === 1 ? "fork" : "forks"}`
|
|
213
|
-
);
|
|
214
|
-
setTexts([""]);
|
|
215
|
-
setWorktree(false);
|
|
216
|
-
onOpenChange(false);
|
|
217
|
-
},
|
|
218
|
-
onError: (e) => toast.error(e.message),
|
|
219
|
-
}
|
|
220
|
-
);
|
|
221
|
-
return (
|
|
222
|
-
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
223
|
-
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-lg">
|
|
224
|
-
<DialogHeader>
|
|
225
|
-
<DialogTitle>Fork this session</DialogTitle>
|
|
226
|
-
<DialogDescription>
|
|
227
|
-
Each copy knows what this session knows and works in the background.
|
|
228
|
-
You keep going; results are added to your next message.
|
|
229
|
-
</DialogDescription>
|
|
230
|
-
</DialogHeader>
|
|
231
|
-
<div className="space-y-2">
|
|
232
|
-
{texts.map((t, i) => (
|
|
233
|
-
<Textarea
|
|
234
|
-
key={i}
|
|
235
|
-
value={t}
|
|
236
|
-
rows={2}
|
|
237
|
-
aria-label={`Fork ${i + 1}`}
|
|
238
|
-
placeholder={`What should fork ${i + 1} do?`}
|
|
239
|
-
onChange={(e) =>
|
|
240
|
-
setTexts(texts.map((x, j) => (j === i ? e.target.value : x)))
|
|
241
|
-
}
|
|
242
|
-
/>
|
|
243
|
-
))}
|
|
244
|
-
{texts.length < max && (
|
|
245
|
-
<Button
|
|
246
|
-
type="button"
|
|
247
|
-
variant="ghost"
|
|
248
|
-
size="sm"
|
|
249
|
-
className="min-h-11"
|
|
250
|
-
onClick={() => setTexts([...texts, ""])}
|
|
251
|
-
>
|
|
252
|
-
<Plus aria-hidden className="size-4" /> Another
|
|
253
|
-
</Button>
|
|
254
|
-
)}
|
|
255
|
-
<label className="flex min-h-11 items-center justify-between gap-3 text-sm">
|
|
256
|
-
<span>
|
|
257
|
-
Own checkout
|
|
258
|
-
<span className="text-muted-foreground block text-xs">
|
|
259
|
-
For forks that edit code. Off: they read this checkout and leave
|
|
260
|
-
it alone.
|
|
261
|
-
</span>
|
|
262
|
-
</span>
|
|
263
|
-
<Switch checked={worktree} onCheckedChange={setWorktree} />
|
|
264
|
-
</label>
|
|
265
|
-
</div>
|
|
266
|
-
<DialogFooter>
|
|
267
|
-
<Button
|
|
268
|
-
className="min-h-11"
|
|
269
|
-
disabled={!filled.length || start.isPending}
|
|
270
|
-
onClick={submit}
|
|
271
|
-
>
|
|
272
|
-
{filled.length > 1 ? `Start ${filled.length} forks` : "Start fork"}
|
|
273
|
-
</Button>
|
|
274
|
-
</DialogFooter>
|
|
275
|
-
</DialogContent>
|
|
276
|
-
</Dialog>
|
|
277
|
-
);
|
|
278
|
-
}
|