@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.
- package/app/api/fresh-start/route.test.ts +33 -0
- package/app/api/fresh-start/route.ts +3 -0
- package/app/api/sessions/route.ts +7 -1
- package/app/globals.css +14 -0
- package/components/Chat/ChatPanel.tsx +41 -29
- package/components/Chat/Forks.tsx +16 -1
- package/components/Chat/Messages.tsx +14 -0
- package/components/Chat/WorktreeRemovedNotice.tsx +17 -0
- package/components/Chat/composer/ComposerBody.tsx +10 -1
- package/components/Chat/composer/ForkHeader.tsx +26 -15
- package/components/Chat/fork-draft.test.ts +57 -0
- package/components/Chat/fork-draft.ts +34 -0
- package/components/Devices/DeviceRow.tsx +83 -69
- package/components/Devices/PasskeysSection.test.ts +89 -0
- package/components/Devices/PasskeysSection.tsx +36 -26
- package/components/Health/HealthPanel.tsx +41 -16
- package/components/Merge/GlobalMergeSection.tsx +2 -5
- package/components/Merge/MergeSettingsFields.tsx +39 -77
- package/components/Merge/ProjectMergeSection.tsx +2 -6
- package/components/Merge/WorktreeSweepSection.tsx +40 -14
- package/components/Models/AgentRoleCard.tsx +61 -66
- package/components/Models/AgentRolesSection.tsx +5 -8
- package/components/Models/ModelsSection.tsx +7 -18
- package/components/Models/PriceInput.tsx +59 -0
- package/components/PhoneNotify/PhoneNotifyForm.tsx +37 -58
- package/components/Settings/AppWideLimits.tsx +61 -0
- package/components/Settings/ContextSection.tsx +96 -0
- package/components/Settings/ExperimentsSection.test.ts +9 -7
- package/components/Settings/ExperimentsSection.tsx +22 -23
- package/components/Settings/InlineConfirm.tsx +51 -0
- package/components/Settings/OwnerNameRow.tsx +10 -40
- package/components/Settings/PickerSetting.tsx +59 -0
- package/components/Settings/SettingRow.tsx +131 -0
- package/components/Settings/SettingsDialog.test.ts +1 -0
- package/components/Settings/SettingsDialog.tsx +47 -2
- package/components/Settings/settings-fields.test.ts +103 -0
- package/components/Settings/settings-fields.tsx +427 -0
- package/components/Shares/SharedLinksPanel.tsx +62 -53
- package/components/Workspaces/HqFolderForm.tsx +21 -54
- package/components/Workspaces/McpServersForm.tsx +20 -19
- package/components/Workspaces/ProjectTiersForm.tsx +10 -8
- package/components/Workspaces/RulesForm.test.ts +127 -0
- package/components/Workspaces/RulesForm.tsx +54 -126
- package/components/Workspaces/WorkspaceModelsForm.tsx +27 -26
- package/components/Workspaces/WorkspaceSettings.tsx +61 -24
- package/content/whats-new.json +32 -0
- package/data/fresh-start/index.ts +1 -1
- package/data/workspaces/index.ts +9 -0
- package/hooks/useAutoSave.test.ts +116 -0
- package/hooks/useAutoSave.ts +98 -0
- package/lib/away/window-limits.ts +3 -1
- package/lib/chat/refresh.ts +10 -6
- package/lib/chat/socket.ts +7 -3
- package/lib/chat/stall-limits.ts +3 -1
- 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/note.ts +6 -0
- package/lib/fork/start.ts +6 -2
- package/lib/fork/types.ts +2 -0
- package/lib/fork/view.ts +14 -1
- package/lib/settings/defaults.ts +27 -0
- package/lib/settings/parse.ts +17 -0
- package/lib/settings-url.ts +1 -0
- package/lib/worktree-sweep/limits.ts +3 -0
- package/lib/worktree-sweep/settings.ts +4 -2
- package/package.json +1 -1
- package/components/Away/AwayWindowForm.tsx +0 -91
- package/components/Workspaces/FreshStartForm.tsx +0 -177
- package/components/Workspaces/StallForm.tsx +0 -101
- package/components/Workspaces/TaskLimitForm.tsx +0 -84
- 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
|
|
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 {
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
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
|
|
199
|
+
// Leaving Fork mode keeps the selection as a quote chip and the typed text.
|
|
194
200
|
const leaveFork = () => {
|
|
195
|
-
if (fork
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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: () =>
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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}`;
|