Skip to content
Merged
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
105 changes: 105 additions & 0 deletions src/components/PromptInput.client.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown> },
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<void> {
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(() => <PromptInput taskId={taskId} taskName="Task" agentId="agent-1" />, container),
);
const el = container.querySelector<HTMLTextAreaElement>('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('');
});
});
15 changes: 14 additions & 1 deletion src/components/PromptInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
isPanelFocused,
setTaskControl,
markTaskUserActivity,
setTaskPromptDraft,
setTaskPromptDraftActive,
setTaskTerminalInputPending,
showNotification,
Expand Down Expand Up @@ -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<string | null>(null);
// Incremented when promptAppearedInOutput fails so the auto-send createEffect
Expand Down
26 changes: 26 additions & 0 deletions src/store/autosave.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});
});
1 change: 1 addition & 0 deletions src/store/autosave.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export function persistedSnapshot(): string {
id,
{
notes: t.notes,
promptDraft: t.promptDraft,
lastPrompt: t.lastPrompt,
name: t.name,
gitIsolation: t.gitIsolation,
Expand Down
101 changes: 101 additions & 0 deletions src/store/persistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down
3 changes: 3 additions & 0 deletions src/store/persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -723,6 +724,7 @@ export async function loadState(): Promise<void> {
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,
Expand Down Expand Up @@ -831,6 +833,7 @@ export async function loadState(): Promise<void> {
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,
Expand Down
1 change: 1 addition & 0 deletions src/store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export {
toggleAITerminalLayout,
setTaskLastInputAt,
markTaskUserActivity,
setTaskPromptDraft,
setTaskPromptDraftActive,
setTaskTerminalInputPending,
initMCPListeners,
Expand Down
7 changes: 7 additions & 0 deletions src/store/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
5 changes: 5 additions & 0 deletions src/store/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -226,6 +230,7 @@ export interface PersistedTask {
branchName: string;
worktreePath: string;
notes: string;
promptDraft?: string;
lastPrompt: string;
promptedAgentIds?: string[];
initialPrompt?: string;
Expand Down
Loading