diff --git a/src/components/PromptInput.client.test.tsx b/src/components/PromptInput.client.test.tsx new file mode 100644 index 00000000..0451e01e --- /dev/null +++ b/src/components/PromptInput.client.test.tsx @@ -0,0 +1,105 @@ +import { render } from 'solid-js/web'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { PromptInput } from './PromptInput'; + +const { storeMock, setTaskPromptDraft } = vi.hoisted(() => ({ + storeMock: { tasks: {} as Record }, + setTaskPromptDraft: vi.fn(), +})); + +vi.mock('../lib/ipc', () => ({ invoke: vi.fn(async () => undefined), fireAndForget: vi.fn() })); +vi.mock('../lib/log', () => ({ debug: vi.fn(), warn: vi.fn() })); + +vi.mock('../store/store', () => ({ + store: storeMock, + setTaskPromptDraft, + sendPrompt: vi.fn(async () => undefined), + setInitialPrompt: vi.fn(), + clearInitialPrompt: vi.fn(), + registerFocusFn: vi.fn(), + unregisterFocusFn: vi.fn(), + registerAction: vi.fn(), + unregisterAction: vi.fn(), + getAgentOutputTail: () => '', + stripAnsi: (s: string) => s, + onAgentReady: vi.fn(), + offAgentReady: vi.fn(), + normalizeCurrentFrame: (s: string) => s, + looksLikeQuestion: () => false, + isAgentTrustQuestionAutoHandled: () => false, + isAutoTrustSettling: () => false, + isAgentAskingQuestion: () => false, + isAgentIdle: () => true, + setTaskLastInputAt: vi.fn(), + isPanelFocused: () => false, + setTaskControl: vi.fn(), + markTaskUserActivity: vi.fn(), + setTaskPromptDraftActive: vi.fn(), + setTaskTerminalInputPending: vi.fn(), + showNotification: vi.fn(), +})); + +vi.mock('../store/tasks', () => ({ + clearStagedNotification: vi.fn(), + setTaskTerminalInputPendingFromQuestion: vi.fn(), +})); + +const disposers: Array<() => void> = []; + +afterEach(() => { + while (disposers.length > 0) disposers.pop()?.(); + document.body.replaceChildren(); + storeMock.tasks = {}; + setTaskPromptDraft.mockClear(); +}); + +async function waitFor(probe: () => boolean): Promise { + for (let attempt = 0; attempt < 200; attempt++) { + if (probe()) return; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + throw new Error('Condition never became true'); +} + +function mount(taskId: string): HTMLTextAreaElement { + const container = document.createElement('div'); + document.body.append(container); + disposers.push( + render(() => , container), + ); + const el = container.querySelector('textarea.prompt-textarea'); + if (!el) throw new Error('textarea not rendered'); + return el; +} + +describe('PromptInput draft persistence', () => { + it('shows the draft restored from the store on mount', () => { + storeMock.tasks = { 'task-1': { id: 'task-1', promptDraft: 'half-written thought' } }; + expect(mount('task-1').value).toBe('half-written thought'); + }); + + it('starts empty when the task has no saved draft', () => { + storeMock.tasks = { 'task-1': { id: 'task-1' } }; + expect(mount('task-1').value).toBe(''); + }); + + it('writes typed text back to the store so autosave persists it', () => { + storeMock.tasks = { 'task-1': { id: 'task-1' } }; + const textarea = mount('task-1'); + + textarea.value = 'remember the migration'; + textarea.dispatchEvent(new Event('input', { bubbles: true })); + + expect(setTaskPromptDraft).toHaveBeenCalledWith('task-1', 'remember the migration'); + }); + + it('clears the stored draft once the prompt is sent', async () => { + storeMock.tasks = { 'task-1': { id: 'task-1', promptDraft: 'send me' } }; + const textarea = mount('task-1'); + + textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + await waitFor(() => setTaskPromptDraft.mock.calls.some(([, text]) => text === '')); + + expect(textarea.value).toBe(''); + }); +}); diff --git a/src/components/PromptInput.tsx b/src/components/PromptInput.tsx index 68773eff..00c9069f 100644 --- a/src/components/PromptInput.tsx +++ b/src/components/PromptInput.tsx @@ -24,6 +24,7 @@ import { isPanelFocused, setTaskControl, markTaskUserActivity, + setTaskPromptDraft, setTaskPromptDraftActive, setTaskTerminalInputPending, showNotification, @@ -108,7 +109,19 @@ const isQuestionBlockingAutoSend = (agentId: string, tail: string): boolean => looksLikeQuestion(tail) && !isAgentTrustQuestionAutoHandled(agentId, tail); export function PromptInput(props: PromptInputProps) { - const [text, setText] = createSignal(''); + // The draft lives in the store (persisted across restarts) as well as in this + // signal. The signal stays the render source so the textarea keeps its + // synchronous local feel; `setText` mirrors every change into the store. + // untrack: a one-time hydration. The task (and its restored draft) always + // exists in the store before this component mounts, so re-reading on later + // store writes would only risk clobbering what the user is typing. + const [text, setTextSignal] = createSignal( + untrack(() => store.tasks[props.taskId]?.promptDraft) ?? '', + ); + const setText = (value: string): void => { + setTextSignal(value); + setTaskPromptDraft(props.taskId, value); + }; const [sending, setSending] = createSignal(false); const [autoSentInitialPrompt, setAutoSentInitialPrompt] = createSignal(null); // Incremented when promptAppearedInOutput fails so the auto-send createEffect diff --git a/src/store/autosave.test.ts b/src/store/autosave.test.ts index 9fbac864..f88d6df3 100644 --- a/src/store/autosave.test.ts +++ b/src/store/autosave.test.ts @@ -79,4 +79,30 @@ describe('autosave snapshot includes new-task-default fields', () => { setStore('tasks', taskId, undefined as unknown as Task); } }); + + it('an unsent prompt draft changes the snapshot', () => { + const taskId = 'autosave-draft-task'; + const task: Task = { + id: taskId, + name: taskId, + projectId: 'p1', + branchName: 'feature/draft', + worktreePath: '/tmp/autosave-draft-task', + agentIds: [], + shellAgentIds: [], + notes: '', + lastPrompt: '', + gitIsolation: 'worktree', + }; + setStore('tasks', taskId, task); + setStore('taskOrder', (order) => [...order, taskId]); + try { + const before = persistedSnapshot(); + setStore('tasks', taskId, 'promptDraft', 'half-written thought'); + expect(persistedSnapshot()).not.toBe(before); + } finally { + setStore('taskOrder', (order) => order.filter((id) => id !== taskId)); + setStore('tasks', taskId, undefined as unknown as Task); + } + }); }); diff --git a/src/store/autosave.ts b/src/store/autosave.ts index d94933e1..8703731a 100644 --- a/src/store/autosave.ts +++ b/src/store/autosave.ts @@ -59,6 +59,7 @@ export function persistedSnapshot(): string { id, { notes: t.notes, + promptDraft: t.promptDraft, lastPrompt: t.lastPrompt, name: t.name, gitIsolation: t.gitIsolation, diff --git a/src/store/persistence.test.ts b/src/store/persistence.test.ts index c875299f..a3731c8f 100644 --- a/src/store/persistence.test.ts +++ b/src/store/persistence.test.ts @@ -383,6 +383,107 @@ describe('PR URL persistence', () => { }); }); +describe('prompt draft persistence', () => { + it('persists an unsent prompt draft on an active task', async () => { + setStore('taskOrder', ['task-1']); + setStore('collapsedTaskOrder', []); + setStore('tasks', { + 'task-1': { + id: 'task-1', + name: 'Task', + projectId: 'project-1', + branchName: 'task/task-1', + worktreePath: '/repo/.worktrees/task-1', + agentIds: [], + shellAgentIds: [], + notes: '', + lastPrompt: '', + gitIsolation: 'worktree', + promptDraft: 'remember to check the migration', + }, + }); + mockInvoke.mockResolvedValueOnce(undefined); + + await saveState(); + + const saved = JSON.parse(mockInvoke.mock.calls[0][1].json); + expect(saved.tasks['task-1'].promptDraft).toBe('remember to check the migration'); + }); + + it('restores an unsent prompt draft', async () => { + const def = agentDef(); + mockInvoke.mockResolvedValueOnce( + JSON.stringify({ + projects: [{ id: 'project-1', name: 'Repo', path: '/repo', color: 'hsl(0, 70%, 75%)' }], + lastProjectId: 'project-1', + lastAgentId: null, + taskOrder: ['task-1'], + collapsedTaskOrder: [], + tasks: { + 'task-1': { + ...persistedTask(def), + promptDraft: 'remember to check the migration', + }, + }, + activeTaskId: 'task-1', + sidebarVisible: true, + }), + ); + + await loadState(); + + expect(store.tasks['task-1'].promptDraft).toBe('remember to check the migration'); + }); + + it('restores an unsent prompt draft on a collapsed task', async () => { + const def = agentDef(); + mockInvoke.mockResolvedValueOnce( + JSON.stringify({ + projects: [{ id: 'project-1', name: 'Repo', path: '/repo', color: 'hsl(0, 70%, 75%)' }], + lastProjectId: 'project-1', + lastAgentId: null, + taskOrder: [], + collapsedTaskOrder: ['task-1'], + tasks: { + 'task-1': { + ...persistedTask(def), + collapsed: true, + promptDraft: 'draft on a collapsed task', + }, + }, + activeTaskId: null, + sidebarVisible: true, + }), + ); + + await loadState(); + + expect(store.tasks['task-1'].promptDraft).toBe('draft on a collapsed task'); + }); + + it('ignores a non-string promptDraft from a corrupt file', async () => { + const def = agentDef(); + mockInvoke.mockResolvedValueOnce( + JSON.stringify({ + projects: [{ id: 'project-1', name: 'Repo', path: '/repo', color: 'hsl(0, 70%, 75%)' }], + lastProjectId: 'project-1', + lastAgentId: null, + taskOrder: ['task-1'], + collapsedTaskOrder: [], + tasks: { + 'task-1': { ...persistedTask(def), promptDraft: 42 }, + }, + activeTaskId: 'task-1', + sidebarVisible: true, + }), + ); + + await loadState(); + + expect(store.tasks['task-1'].promptDraft).toBeUndefined(); + }); +}); + describe('AI terminal layout persistence', () => { it('persists a task tabbed layout choice', async () => { setStore('taskOrder', ['task-1']); diff --git a/src/store/persistence.ts b/src/store/persistence.ts index 38d87e0f..c7cba170 100644 --- a/src/store/persistence.ts +++ b/src/store/persistence.ts @@ -139,6 +139,7 @@ function toPersistedTask(task: Task, agentDefs: AgentDef[], collapsed?: boolean) branchName: task.branchName, worktreePath: task.worktreePath, notes: task.notes, + promptDraft: task.promptDraft, lastPrompt: task.lastPrompt, promptedAgentIds: task.promptedAgentIds, initialPrompt: task.initialPrompt, @@ -723,6 +724,7 @@ export async function loadState(): Promise { aiTerminalLayout: pt.aiTerminalLayout === 'tabs' ? 'tabs' : undefined, shellAgentIds, notes: pt.notes, + promptDraft: typeof pt.promptDraft === 'string' ? pt.promptDraft : undefined, lastPrompt: pt.lastPrompt, promptedAgentIds: restoredPromptedAgentIds(pt, agentIds), initialPrompt: typeof pt.initialPrompt === 'string' ? pt.initialPrompt : undefined, @@ -831,6 +833,7 @@ export async function loadState(): Promise { aiTerminalLayout: pt.aiTerminalLayout === 'tabs' ? 'tabs' : undefined, shellAgentIds: [], notes: pt.notes, + promptDraft: typeof pt.promptDraft === 'string' ? pt.promptDraft : undefined, lastPrompt: pt.lastPrompt, promptedAgentIds: restoredPromptedAgentIds(pt, []), initialPrompt: typeof pt.initialPrompt === 'string' ? pt.initialPrompt : undefined, diff --git a/src/store/store.ts b/src/store/store.ts index 5341a5b4..0172000e 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -61,6 +61,7 @@ export { toggleAITerminalLayout, setTaskLastInputAt, markTaskUserActivity, + setTaskPromptDraft, setTaskPromptDraftActive, setTaskTerminalInputPending, initMCPListeners, diff --git a/src/store/tasks.ts b/src/store/tasks.ts index 30baebe2..751785bf 100644 --- a/src/store/tasks.ts +++ b/src/store/tasks.ts @@ -1625,6 +1625,13 @@ export function markTaskUserActivity(taskId: string): void { scheduleTaskAutomationRelease(taskId); } +/** Store the unsent contents of the task's prompt box so a restart restores it. + * Empty text is stored as `undefined` to keep the persisted file free of noise. */ +export function setTaskPromptDraft(taskId: string, text: string): void { + if (!store.tasks[taskId]) return; + setStore('tasks', taskId, 'promptDraft', text || undefined); +} + export function setTaskPromptDraftActive(taskId: string, active: boolean): void { if (!store.tasks[taskId]) return; setStore('tasks', taskId, 'promptDraftActive', active || undefined); diff --git a/src/store/types.ts b/src/store/types.ts index 6d42c721..ec18482e 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -183,6 +183,10 @@ export interface Task { stagedNotification?: StagedNotification; userActivityHoldUntil?: number; promptDraftActive?: boolean; + /** Unsent text sitting in the task's "Send a prompt" box. Persisted so a + * restart (app or machine) doesn't discard what the user typed but never + * sent. Cleared on send. */ + promptDraft?: string; terminalInputPending?: boolean; terminalInputPendingFromQuestion?: boolean; // Coordinator fields @@ -226,6 +230,7 @@ export interface PersistedTask { branchName: string; worktreePath: string; notes: string; + promptDraft?: string; lastPrompt: string; promptedAgentIds?: string[]; initialPrompt?: string;