@saadnvd1/agent-os 0.3.833 → 0.3.839

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 (74) hide show
  1. package/app/api/fresh-start/route.test.ts +33 -0
  2. package/app/api/fresh-start/route.ts +3 -0
  3. package/app/api/sessions/route.ts +7 -1
  4. package/app/globals.css +14 -0
  5. package/components/Chat/ChatPanel.tsx +41 -29
  6. package/components/Chat/Forks.tsx +16 -1
  7. package/components/Chat/Messages.tsx +14 -0
  8. package/components/Chat/WorktreeRemovedNotice.tsx +17 -0
  9. package/components/Chat/composer/ComposerBody.tsx +10 -1
  10. package/components/Chat/composer/ForkHeader.tsx +26 -15
  11. package/components/Chat/fork-draft.test.ts +57 -0
  12. package/components/Chat/fork-draft.ts +34 -0
  13. package/components/Devices/DeviceRow.tsx +83 -69
  14. package/components/Devices/PasskeysSection.test.ts +89 -0
  15. package/components/Devices/PasskeysSection.tsx +36 -26
  16. package/components/Health/HealthPanel.tsx +41 -16
  17. package/components/Merge/GlobalMergeSection.tsx +2 -5
  18. package/components/Merge/MergeSettingsFields.tsx +39 -77
  19. package/components/Merge/ProjectMergeSection.tsx +2 -6
  20. package/components/Merge/WorktreeSweepSection.tsx +40 -14
  21. package/components/Models/AgentRoleCard.tsx +61 -66
  22. package/components/Models/AgentRolesSection.tsx +5 -8
  23. package/components/Models/ModelsSection.tsx +7 -18
  24. package/components/Models/PriceInput.tsx +59 -0
  25. package/components/PhoneNotify/PhoneNotifyForm.tsx +37 -58
  26. package/components/Settings/AppWideLimits.tsx +61 -0
  27. package/components/Settings/ContextSection.tsx +96 -0
  28. package/components/Settings/ExperimentsSection.test.ts +9 -7
  29. package/components/Settings/ExperimentsSection.tsx +22 -23
  30. package/components/Settings/InlineConfirm.tsx +51 -0
  31. package/components/Settings/OwnerNameRow.tsx +10 -40
  32. package/components/Settings/PickerSetting.tsx +59 -0
  33. package/components/Settings/SettingRow.tsx +131 -0
  34. package/components/Settings/SettingsDialog.test.ts +1 -0
  35. package/components/Settings/SettingsDialog.tsx +47 -2
  36. package/components/Settings/settings-fields.test.ts +103 -0
  37. package/components/Settings/settings-fields.tsx +427 -0
  38. package/components/Shares/SharedLinksPanel.tsx +62 -53
  39. package/components/Workspaces/HqFolderForm.tsx +21 -54
  40. package/components/Workspaces/McpServersForm.tsx +20 -19
  41. package/components/Workspaces/ProjectTiersForm.tsx +10 -8
  42. package/components/Workspaces/RulesForm.test.ts +127 -0
  43. package/components/Workspaces/RulesForm.tsx +54 -126
  44. package/components/Workspaces/WorkspaceModelsForm.tsx +27 -26
  45. package/components/Workspaces/WorkspaceSettings.tsx +61 -24
  46. package/content/whats-new.json +32 -0
  47. package/data/fresh-start/index.ts +1 -1
  48. package/data/workspaces/index.ts +9 -0
  49. package/hooks/useAutoSave.test.ts +116 -0
  50. package/hooks/useAutoSave.ts +98 -0
  51. package/lib/away/window-limits.ts +3 -1
  52. package/lib/chat/refresh.ts +10 -6
  53. package/lib/chat/socket.ts +7 -3
  54. package/lib/chat/stall-limits.ts +3 -1
  55. package/lib/chat/start-error.ts +13 -0
  56. package/lib/chat/worker/cwd.ts +77 -0
  57. package/lib/chat/worker/host.ts +11 -2
  58. package/lib/chat/worker/main.ts +11 -2
  59. package/lib/db/types.ts +2 -0
  60. package/lib/fork/note.ts +6 -0
  61. package/lib/fork/start.ts +6 -2
  62. package/lib/fork/types.ts +2 -0
  63. package/lib/fork/view.ts +14 -1
  64. package/lib/settings/defaults.ts +27 -0
  65. package/lib/settings/parse.ts +17 -0
  66. package/lib/settings-url.ts +1 -0
  67. package/lib/worktree-sweep/limits.ts +3 -0
  68. package/lib/worktree-sweep/settings.ts +4 -2
  69. package/package.json +1 -1
  70. package/components/Away/AwayWindowForm.tsx +0 -91
  71. package/components/Workspaces/FreshStartForm.tsx +0 -177
  72. package/components/Workspaces/StallForm.tsx +0 -101
  73. package/components/Workspaces/TaskLimitForm.tsx +0 -84
  74. package/components/Workspaces/fresh-start.ts +0 -19
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { NextRequest } from "next/server";
3
+ import { refreshSettings } from "@/lib/chat/refresh";
4
+ import { SETTING_DEFAULTS } from "@/lib/settings/defaults";
5
+ import { GET, PATCH } from "./route";
6
+
7
+ const patch = (body: unknown) =>
8
+ PATCH(
9
+ new NextRequest("http://localhost/api/fresh-start", {
10
+ method: "PATCH",
11
+ body: JSON.stringify(body),
12
+ })
13
+ );
14
+
15
+ describe("PATCH /api/fresh-start", () => {
16
+ it("changes only the field it's sent", async () => {
17
+ const res = await patch({ compactTokens: 120_000 });
18
+ expect(res.status).toBe(200);
19
+ expect(refreshSettings()).toEqual({
20
+ ...SETTING_DEFAULTS.fresh,
21
+ compactTokens: 120_000,
22
+ });
23
+ expect(await (await GET()).json()).toMatchObject({
24
+ interactiveAutoCompactTokens: 300_000,
25
+ });
26
+ });
27
+
28
+ it("rejects an out-of-range value and saves nothing", async () => {
29
+ const res = await patch({ taskTokens: 10_000 });
30
+ expect(res.status).toBe(400);
31
+ expect(refreshSettings().taskTokens).toBe(0);
32
+ });
33
+ });
@@ -10,6 +10,9 @@ export async function GET() {
10
10
  return NextResponse.json(refreshSettings());
11
11
  }
12
12
 
13
+ // One field at a time, or several: only those named change.
14
+ export const PATCH = PUT;
15
+
13
16
  export async function PUT(request: NextRequest) {
14
17
  try {
15
18
  const body = await request.json();
@@ -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
+ }
@@ -8,17 +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
18
  import { isFreshCommand, withFresh } from "@/lib/chat/commands";
18
19
  import { useExperiment } from "@/data/experiments";
19
20
  import { useStartForks } from "@/data/fork";
20
21
  import { toast } from "sonner";
21
- import { ForksStrip, forkLabel } from "./Forks";
22
+ import {
23
+ leaveForkMode,
24
+ placeholderFor,
25
+ suggestionFor,
26
+ type ForkDraft,
27
+ } from "./fork-draft";
28
+ import { ForksStrip, forkLabel, startedToast } from "./Forks";
22
29
  import { ReplyingTo } from "./ReplyingTo";
23
30
  import { compactsFirst } from "@/lib/chat/pending";
24
31
  import { useFreshStart } from "@/data/sessions";
@@ -106,12 +113,11 @@ export function ChatPanel({
106
113
  const forksOn = useExperiment("forks");
107
114
  const startFork = useStartForks(sessionId);
108
115
  // Set while a selection is being forked; per session, like asIs below.
109
- const [forkDraft, setForkDraft] = useState<{
110
- sessionId: string;
111
- quote: string;
112
- own: boolean;
113
- } | null>(null);
114
- const fork = forksOn && forkDraft?.sessionId === sessionId ? forkDraft : null;
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;
115
121
  const {
116
122
  items,
117
123
  hasMore,
@@ -181,7 +187,7 @@ export function ChatPanel({
181
187
 
182
188
  // The suggestion the reader set aside (typed over it, or Esc) stays hidden.
183
189
  const [dismissed, setDismissed] = useState<string | null>(null);
184
- const shownSuggestion = suggestion === dismissed ? null : suggestion;
190
+ const shownSuggestion = suggestionFor(suggestion, dismissed, !!fork);
185
191
  const dismissSuggestion = useCallback((s: string) => setDismissed(s), []);
186
192
  const history = useMemo(() => sentPrompts(items), [items]);
187
193
  const [insert, setInsert] = useState<{
@@ -190,15 +196,13 @@ export function ChatPanel({
190
196
  append?: boolean;
191
197
  }>();
192
198
 
193
- // Leaving Fork mode keeps the selection as a normal quote in the draft.
199
+ // Leaving Fork mode keeps the selection as a quote chip and the typed text.
194
200
  const leaveFork = () => {
195
- if (fork?.quote)
196
- setInsert({
197
- text: quoteMarkdown(fork.quote),
198
- at: Date.now(),
199
- append: true,
200
- });
201
- setForkDraft(null);
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
+ });
202
206
  };
203
207
 
204
208
  // Esc sets the composer's ghost text aside, or else stops the turn
@@ -269,7 +273,7 @@ export function ChatPanel({
269
273
  focusComposer
270
274
  );
271
275
  const forkQuote = (text: string) => {
272
- setForkDraft({ sessionId, quote: text, own: false });
276
+ setForkDraft({ sessionId, quote: text, own: false, mode: true });
273
277
  focusComposer();
274
278
  };
275
279
  const togglePlan = plan === null ? undefined : () => setPlan(!plan);
@@ -688,6 +692,7 @@ export function ChatPanel({
688
692
  onFork={forksOn ? forkQuote : undefined}
689
693
  />
690
694
  <PausedBanner sessionId={sessionId} />
695
+ <WorktreeRemovedNotice sessionId={sessionId} />
691
696
  <ForksStrip sessionId={sessionId} />
692
697
  <Queue
693
698
  queue={queue}
@@ -725,13 +730,7 @@ export function ChatPanel({
725
730
  insert={insert}
726
731
  running={running}
727
732
  disabled={!connected}
728
- placeholder={
729
- !connected
730
- ? "Reconnecting…"
731
- : fork
732
- ? "What should the fork do?"
733
- : `Message ${sessionName}`
734
- }
733
+ placeholder={placeholderFor(connected, !!fork, sessionName)}
735
734
  fork={
736
735
  fork
737
736
  ? {
@@ -743,6 +742,14 @@ export function ChatPanel({
743
742
  }
744
743
  : undefined
745
744
  }
745
+ quoteChip={
746
+ quoteChip
747
+ ? {
748
+ text: quoteChip.quote,
749
+ onDrop: () => setForkDraft(null),
750
+ }
751
+ : undefined
752
+ }
746
753
  onSend={(text, images, files) => {
747
754
  if (freshStart && isFreshCommand(text)) {
748
755
  startFresh.mutate(sessionId);
@@ -758,8 +765,8 @@ export function ChatPanel({
758
765
  labels: [forkLabel(text, fork.quote)],
759
766
  },
760
767
  {
761
- onSuccess: () => {
762
- toast.success("Started fork");
768
+ onSuccess: (res) => {
769
+ toast.success(startedToast(res.children.map((c) => c.n)));
763
770
  setForkDraft(null);
764
771
  },
765
772
  onError: (e) => {
@@ -771,14 +778,19 @@ export function ChatPanel({
771
778
  return;
772
779
  }
773
780
  toEnd();
774
- 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, {
775
787
  state,
776
788
  thresholdTokens: compactTokens,
777
789
  asIs,
778
790
  });
779
791
  setAsIs(false);
780
792
  send(
781
- text,
793
+ body,
782
794
  images.length ? images : undefined,
783
795
  compact,
784
796
  files.length ? files : undefined
@@ -10,6 +10,7 @@ import {
10
10
  useStartForks,
11
11
  type ChildView,
12
12
  } from "@/data/fork";
13
+ import { formatElapsed } from "@/lib/chat/elapsed";
13
14
  import { Button } from "@/components/ui/button";
14
15
  import { StatusPill } from "@/components/ui/status-pill";
15
16
  import { SessionAnchor } from "./SessionAnchor";
@@ -34,6 +35,9 @@ export const forkLabel = (typed: string, quote: string): string => {
34
35
  return line.length > 60 ? `${line.slice(0, 59)}…` : line;
35
36
  };
36
37
 
38
+ export const startedToast = (ns: number[]): string =>
39
+ ns.length === 1 ? `Started fork ${ns[0]}` : `Started forks ${ns.join(", ")}`;
40
+
37
41
  // What a fork that ended with nothing to read says in place of a result.
38
42
  export const emptyNote = (c: ChildView) =>
39
43
  c.summary && c.summary !== "ended without a result"
@@ -49,13 +53,23 @@ export function useRunAgain(sessionId: string) {
49
53
  start.mutate(
50
54
  { instructions: [c.instruction], worktree: c.worktree },
51
55
  {
52
- onSuccess: () => toast.success("Started fork"),
56
+ onSuccess: (res) =>
57
+ toast.success(startedToast(res.children.map((x) => x.n))),
53
58
  onError: (e) => toast.error(e.message),
54
59
  }
55
60
  ),
56
61
  };
57
62
  }
58
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
+
59
73
  function Row({
60
74
  c,
61
75
  onStop,
@@ -72,6 +86,7 @@ function Row({
72
86
  const empty = c.outcome === "empty";
73
87
  const note =
74
88
  (empty ? emptyNote(c) : c.summary) ??
89
+ (c.state === "running" ? runningNote(c) : null) ??
75
90
  (c.asks
76
91
  ? `Asks: ${c.asks}`
77
92
  : c.state === "queued"
@@ -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(
@@ -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,7 +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, type ForkComposer } from "./ForkHeader";
35
+ import { ForkHeader, QuoteChip, type ForkComposer } from "./ForkHeader";
36
36
  import { useCoarsePointer } from "./useCoarsePointer";
37
37
  import { useMentions } from "./useMentions";
38
38
  import { Attachments } from "./Attachments";
@@ -56,6 +56,8 @@ export interface ComposerProps {
56
56
  // The send button's label, when Enter does something else than send.
57
57
  // Fork mode: the quote chip, pill and own-checkout choice.
58
58
  fork?: ForkComposer;
59
+ // After Fork mode: the selection stays as a chip on a normal reply.
60
+ quoteChip?: { text: string; onDrop: () => void };
59
61
  onSend: (text: string, images: ChatImage[], files: ChatFile[]) => void;
60
62
  // Where attached files are kept: the session's id, or "draft" before one exists.
61
63
  attachScope: string;
@@ -99,6 +101,7 @@ export function ComposerBody({
99
101
  disabled,
100
102
  placeholder,
101
103
  fork,
104
+ quoteChip,
102
105
  onSend,
103
106
  onStop,
104
107
  commands = [],
@@ -276,6 +279,7 @@ export function ComposerBody({
276
279
 
277
280
  const empty =
278
281
  !fork?.quote &&
282
+ !quoteChip &&
279
283
  !text.trim() &&
280
284
  !images.length &&
281
285
  !files.length &&
@@ -371,6 +375,11 @@ export function ComposerBody({
371
375
  )}
372
376
  >
373
377
  {fork && <ForkHeader fork={fork} />}
378
+ {quoteChip && (
379
+ <div className="mb-1.5">
380
+ <QuoteChip text={quoteChip.text} onDrop={quoteChip.onDrop} />
381
+ </div>
382
+ )}
374
383
  <Attachments
375
384
  images={images}
376
385
  files={files}
@@ -35,21 +35,32 @@ export function ForkHeader({ fork }: { fork: ForkComposer }) {
35
35
  ↵ start · Esc cancel
36
36
  </span>
37
37
  </div>
38
- {fork.quote && (
39
- <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">
40
- <Quote aria-hidden className="mt-1 size-3.5 shrink-0" />
41
- <p className="line-clamp-2 min-w-0 flex-1">{fork.quote}</p>
42
- <button
43
- type="button"
44
- onMouseDown={(e) => e.preventDefault()}
45
- onClick={fork.onDropQuote}
46
- aria-label="Remove quote"
47
- className="hover:text-foreground flex size-8 shrink-0 items-center justify-center"
48
- >
49
- <X aria-hidden className="size-4" />
50
- </button>
51
- </div>
52
- )}
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>
53
64
  </div>
54
65
  );
55
66
  }
@@ -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}`;