diff --git a/apps/desktop/src/components/tasks/task-sheet.tsx b/apps/desktop/src/components/tasks/task-sheet.tsx index 50d892b..ef27c0d 100644 --- a/apps/desktop/src/components/tasks/task-sheet.tsx +++ b/apps/desktop/src/components/tasks/task-sheet.tsx @@ -49,7 +49,7 @@ import { toastApiError } from "@/components/vault/vault-utils"; import { AttachmentTray, readAttachments, totalBytes, type PendingAttachment } from "@/components/chat/attachments"; import { api } from "@/lib/api"; import { modKey, openExternal } from "@/lib/desktop"; -import { draftKeys, saveDraft, useDraft } from "@/lib/drafts"; +import { clearDraft, draftKeys, saveDraft, useDraft } from "@/lib/drafts"; import { useGoals, useQuestions, useTasks } from "@/lib/hooks"; import { qk } from "@/lib/queryKeys"; import { QuestionCard, viewOfQuestion } from "@/components/chat/question-card"; @@ -1096,7 +1096,9 @@ function EditableTitle({ value, onSave, ...rest }: { value: string; onSave: (v: ); } -/** Click to edit; leaving the editor saves. Files pasted, dropped or picked are uploaded and linked in the Markdown. */ +const AUTOSAVE_DELAY = 700; + +/** Click to edit; the text saves as you type and when you leave. Files pasted, dropped or picked are uploaded and linked in the Markdown. */ function EditableDescription({ taskId, value: stored, @@ -1114,7 +1116,10 @@ function EditableDescription({ const [saved, setSaved] = useState(null); if (saved !== null && stored.trim() === saved) setSaved(null); const value = saved ?? stored; - const [draft, setDraft, kept] = useDraft(editing ? draftKey : undefined, value); + /** The text when the edit began: the draft's base. Fixed while editing, so a save coming back never resets the text. */ + const [start, setStart] = useState(stored); + const [draft, setDraft, kept] = useDraft(editing ? draftKey : undefined, start); + const [status, setStatus] = useState<"idle" | "saving" | "saved" | "failed">("idle"); const editor = useRef(null); /** The text's height when editing starts: the editor opens at least that tall, so nothing below jumps. */ const [shownHeight, setShownHeight] = useState(0); @@ -1123,6 +1128,73 @@ function EditableDescription({ const finishLater = useRef(false); const setText = useCallback((update: TextUpdate) => setDraft((d) => (typeof update === "function" ? update(d) : update)), [setDraft]); + /** The last text sent (null: the last save failed). One save at a time, the newest text waits for it. */ + const sent = useRef(stored.trim()); + const inFlight = useRef(false); + const queued = useRef(null); + const live = useRef({ editing, draft, stored, mounted: true }); + Object.assign(live.current, { editing, draft, stored }); + + const push = (next: string) => { + if (next === sent.current) return; + if (inFlight.current) { + queued.current = next; + return; + } + sent.current = next; + inFlight.current = true; + setSaved(next); + setStatus("saving"); + onSave(next) + .then( + () => { + if (queued.current === null) setStatus("saved"); + if (!live.current.mounted && queued.current === null) clearDraft(draftKey); + }, + () => { + sent.current = null; + setSaved(null); + setStatus("failed"); + const { editing, stored } = live.current; + // Not saved: the text comes back as a draft in the editor instead of being lost. + if (!editing && queued.current === null) { + saveDraft(draftKey, next, stored); + setStart(stored); + setEditing(true); + } + }, + ) + .finally(() => { + inFlight.current = false; + const q = queued.current; + queued.current = null; + if (q !== null) push(q); + }); + }; + const pushRef = useRef(push); + pushRef.current = push; + + useEffect(() => { + if (!editing || uploading) return; + const t = setTimeout(() => { + if (!editor.current?.uploading()) pushRef.current(withoutPlaceholders(live.current.draft)); + }, AUTOSAVE_DELAY); + return () => clearTimeout(t); + }, [draft, editing, uploading]); + + // Closed or switched to another task mid-edit: what's typed is saved, not left behind. + useEffect(() => { + const state = live.current; + state.mounted = true; + return () => { + state.mounted = false; + if (!state.editing) return; + const next = withoutPlaceholders(state.draft); + if (next === sent.current && !inFlight.current) clearDraft(draftKey); + else pushRef.current(next); + }; + }, [draftKey]); + const finish = () => { // Still uploading (or picking a file): the edit finishes when that's done. if (editor.current?.busy()) { @@ -1131,16 +1203,7 @@ function EditableDescription({ return; } finishLater.current = false; - const next = withoutPlaceholders(draft); - if (next !== value.trim()) { - setSaved(next); - onSave(next).catch(() => { - // Not saved: the text comes back as a draft in the editor instead of being lost. - setSaved(null); - saveDraft(draftKey, next, stored); - setEditing(true); - }); - } + push(withoutPlaceholders(draft)); kept.discard(); setEditing(false); }; @@ -1153,12 +1216,11 @@ function EditableDescription({ onUploading(uploading); return () => onUploading(false); }, [uploading, onUploading]); - const cancel = () => { - kept.discard(); - setEditing(false); - }; const edit = (shown: HTMLElement) => { setShownHeight(shown.getBoundingClientRect().height); + setStart(value); + sent.current = value.trim(); + setStatus("idle"); setEditing(true); }; @@ -1221,10 +1283,7 @@ function EditableDescription({ // keys that are. Not from the link popover either (a portal: its Esc closes just the popover). onKeyDown={(e) => { if (!e.currentTarget.contains(e.target as Node)) return; - if (e.key === "Escape" && !e.nativeEvent.isComposing) { - e.preventDefault(); - cancel(); - } else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + if ((e.key === "Escape" && !e.nativeEvent.isComposing) || (e.key === "Enter" && (e.metaKey || e.ctrlKey))) { e.preventDefault(); finish(); } @@ -1247,20 +1306,46 @@ function EditableDescription({ /> {/* Clicks here keep the focus in the text (WebKit doesn't focus buttons), so they don't end the edit. */}
e.preventDefault()}> - {uploading ? "Uploading…" : "Paste or drop screenshots and files anywhere in the text"} - -
); } +function SaveStatus({ status, onRetry }: { status: "idle" | "saving" | "saved" | "failed" | "uploading"; onRetry: () => void }) { + return ( + + {status === "uploading" || status === "saving" ? ( + <> + + {status === "uploading" ? "Uploading…" : "Saving…"} + + ) : status === "saved" ? ( + <> + + Saved + + ) : status === "failed" ? ( + <> + Not saved + + + ) : ( + Saves as you type · paste or drop screenshots and files + )} + + ); +} + function BlurInput({ value, placeholder, onSave }: { value: string; placeholder: string; onSave: (v: string) => void }) { const [draft, setDraft] = useState(value); useEffect(() => setDraft(value), [value]);