Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions apps/desktop/e2e/app.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,23 @@ test("⇧⌘N creates a worktree thread and the header shows its branch and path
await tid(page, "action-copy-path").click();
await expect(tid(page, "thread-actions")).toHaveCount(0);
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(pathTitle);

// If thread:send fails (e.g. missing worktree directory), the composer restores the typed text.
const hiddenPath = `${pathTitle}.tmp`;
fs.renameSync(pathTitle!, hiddenPath);
try {
const input = tid(page, "composer-input");
await input.focus();
await input.fill("Preserve this message on error");
await page.keyboard.press("Meta+Enter");
await expect(page.locator(".toast")).toContainText("working directory is missing");
await expect(input).toHaveValue("Preserve this message on error");
await page.locator(".toast button").click();
await input.fill("");
} finally {
fs.renameSync(hiddenPath, pathTitle!);
}

// Back to the first thread for the relaunch test.
await tid(page, "thread-row").nth(1).click();
await expect(tid(page, "context-kind")).toHaveAttribute("data-kind", "local");
Expand Down
72 changes: 52 additions & 20 deletions apps/desktop/src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -212,27 +212,59 @@ export function App() {
if (p) await refresh();
});
/** First send from a draft: create the thread with the draft's settings, then send. */
const sendDraft = (text: string) => draft && act(async () => {
const d = draft;
setCreating(true);
try {
const s = d.settings;
const t = await bridge.invoke("thread:create", { projectId: d.projectId, worktree: d.worktree, backend: s.backend, mode: s.mode, model: s.model || undefined, auto: s.auto });
if (s.plan || s.effort) await bridge.invoke("thread:update", { threadId: t.id, patch: { ...(s.plan ? { plan: true } : {}), ...(s.effort ? { effort: s.effort } : {}) } });
await refresh();
justCreated.current = t.id;
setDraft(null);
setSelected(t.id);
const r = await bridge.invoke("thread:send", { threadId: t.id, text });
if (!r.ok) setError(r.error ?? "send failed");
} finally {
setCreating(false);
const sendDraft = async (text: string): Promise<boolean> => {
if (!draft) return false;
const draftKey = `draft:${draftRevision}`;
let createdId: string | null = null;
const res = await act(async () => {
const d = draft;
setCreating(true);
try {
const s = d.settings;
const t = await bridge.invoke("thread:create", { projectId: d.projectId, worktree: d.worktree, backend: s.backend, mode: s.mode, model: s.model || undefined, auto: s.auto });
createdId = t.id;
if (s.plan || s.effort) await bridge.invoke("thread:update", { threadId: t.id, patch: { ...(s.plan ? { plan: true } : {}), ...(s.effort ? { effort: s.effort } : {}) } });
await refresh();
justCreated.current = t.id;
setDraft(null);
setSelected(t.id);
const r = await bridge.invoke("thread:send", { threadId: t.id, text });
if (!r.ok) {
setError(r.error ?? "send failed");
return false;
}
return true;
} finally {
setCreating(false);
}
});
if (res !== true) {
if (createdId) {
setUnsentFor(createdId, text);
} else {
setUnsentFor(draftKey, text);
}
return false;
}
});
const send = (text: string) => thread && act(async () => {
const r = await bridge.invoke("thread:send", { threadId: thread.id, text });
if (!r.ok) setError(r.error ?? "send failed");
});
return true;
};
const send = async (text: string): Promise<boolean> => {
if (!thread) return false;
const threadId = thread.id;
const res = await act(async () => {
const r = await bridge.invoke("thread:send", { threadId, text });
if (!r.ok) {
setError(r.error ?? "send failed");
return false;
}
return true;
});
if (res !== true) {
setUnsentFor(threadId, text);
return false;
}
return true;
};
const stop = () => thread && act(() => bridge.invoke("thread:stop", { threadId: thread.id }));
const answer = (itemId: string, a: "yes" | "no" | "always") => thread && act(() => bridge.invoke("thread:answer", { threadId: thread.id, itemId, answer: a }));
const updateThread = (patch: ThreadPatch) => thread && act(async () => {
Expand Down
24 changes: 19 additions & 5 deletions apps/desktop/src/renderer/components/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ interface Props {
onModel: (m: string, effort?: string) => void;
onEffort: (e: string | undefined) => void;
onAuto: (auto: boolean) => void;
onSend: (text: string) => void;
onSend: (text: string) => Promise<boolean | void> | boolean | void;
onStop: () => void;
/** Set by the ⌘⏎ / focus shortcuts in App. */
inputRef: React.RefObject<HTMLTextAreaElement | null>;
Expand All @@ -59,6 +59,7 @@ const MAX_INPUT = 180;
export function Composer({ text, onText: setText, busy, context, backend, mode, plan, model, effort, auto, models, modelsError, suggestion, onBackend, onMode, onPlan, onModel, onEffort, onAuto, onSend, onStop, inputRef }: Props) {
const fallbackRef = useRef<HTMLTextAreaElement>(null);
const ta = inputRef ?? fallbackRef;
const submittingRef = useRef(false);

useEffect(() => {
const el = ta.current;
Expand All @@ -67,11 +68,24 @@ export function Composer({ text, onText: setText, busy, context, backend, mode,
el.style.height = `${Math.min(MAX_INPUT, Math.max(MIN_INPUT, el.scrollHeight))}px`;
}, [text]);

const submit = () => {
const submit = async () => {
const t = text.trim();
if (!t || busy) return;
onSend(t);
if (!t || busy || submittingRef.current) return;
submittingRef.current = true;
const original = text;
setText("");
try {
const ok = await onSend(t);
if (ok === false) {
setText(original);
ta.current?.focus();
}
} catch {
setText(original);
ta.current?.focus();
} finally {
submittingRef.current = false;
}
};

// The suggestion is an offer, never a default: it fills the box only on an explicit click, Tab or → while
Expand Down Expand Up @@ -110,7 +124,7 @@ export function Composer({ text, onText: setText, busy, context, backend, mode,
}
if (e.key === "Enter" && (e.metaKey || e.ctrlKey || !e.shiftKey)) {
e.preventDefault();
submit();
void submit();
}
}}
spellCheck={false}
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/renderer/components/DraftView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ interface Props {
/** The project checkout's current branch; shown for a Local draft (a worktree's branch does not exist yet). */
branch?: string;
onChange: (draft: Draft) => void;
onSend: (text: string) => void;
onSend: (text: string) => Promise<boolean | void> | boolean | void;
inputRef: React.RefObject<HTMLTextAreaElement | null>;
text: string;
onText: (text: string) => void;
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/renderer/components/ThreadView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ interface Props {
/** Unsent composer text for this thread, kept by App across thread switches. */
text: string;
onText: (text: string) => void;
onSend: (text: string) => void;
onSend: (text: string) => Promise<boolean | void> | boolean | void;
onStop: () => void;
onAnswer: (itemId: string, answer: "yes" | "no" | "always") => void;
onUpdate: (patch: ThreadPatch) => void;
Expand Down
Loading