@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
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 row = overlap ? { ...s, overlap } : s;
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 { ForkDialog, ForksStrip } from "./Forks";
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 [forkOpen, setForkOpen] = useState(false);
111
- const withForkIf = (c: typeof commands) => (forksOn ? withFork(c) : c);
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 === dismissed ? null : 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({ running, interrupt, suggestion });
211
+ const escape = useRef({
212
+ running,
213
+ interrupt,
214
+ suggestion,
215
+ leaveFork: null as (() => void) | null,
216
+ });
194
217
  useEffect(() => {
195
- escape.current = { running, interrupt, suggestion };
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 rootRef={scrollRef} onQuote={quote} />
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 ? `Message ${sessionName}` : "Reconnecting…"}
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 (forksOn && isForkCommand(text)) {
692
- setForkOpen(true);
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
- const compact = composerCompacts(text, context, {
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
- text,
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={withForkIf(freshStart ? withFresh(commands) : 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, Plus, Square } from "lucide-react";
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 className="text-muted-foreground truncate text-xs">
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
- }