diff --git a/apps/web/src/components/ChatPane.tsx b/apps/web/src/components/ChatPane.tsx index 79cc7de21c..f704a6cba5 100644 --- a/apps/web/src/components/ChatPane.tsx +++ b/apps/web/src/components/ChatPane.tsx @@ -540,7 +540,10 @@ interface Props { composerPlaceholder?: string; onSubmitQuestionForm?: QuestionFormSubmitHandler; questionFormSubmitDisabled?: boolean; - onContinueRemainingTasks?: (assistantMessage: ChatMessage, todos: TodoItem[]) => void; + onContinueRemainingTasks?: ( + assistantMessage: ChatMessage, + todos: TodoItem[], + ) => boolean | void | Promise; onAssistantFeedback?: (assistantMessage: ChatMessage, change: ChatMessageFeedbackChange) => void; // Client-side action for a brand-browser-assist od-card: open/focus the // Browser tab. Routed through the stable callbacks ref. @@ -2685,6 +2688,7 @@ export function ChatPane({ @@ -3395,14 +3399,32 @@ function includeVirtualRowByKey( function PinnedTodoSlot({ messages, streaming, + conversationId, onContinueRemainingTasks, containerRef, }: { messages: ChatMessage[]; streaming: boolean; - onContinueRemainingTasks?: (assistantMessage: ChatMessage, todos: TodoItem[]) => void; + conversationId: string | null; + onContinueRemainingTasks?: ( + assistantMessage: ChatMessage, + todos: TodoItem[], + ) => boolean | void | Promise; containerRef?: MutableRefObject; }) { + const storageKey = `od:chat:continued-todo:${conversationId ?? 'none'}`; + const [dismissal, setDismissal] = useState(() => ({ + storageKey, + snapshotKey: readContinuedTodoSnapshotKey(storageKey), + })); + useEffect(() => { + setDismissal({ + storageKey, + snapshotKey: readContinuedTodoSnapshotKey(storageKey), + }); + }, [storageKey]); + const dismissedSnapshotKey = + dismissal.storageKey === storageKey ? dismissal.snapshotKey : null; const input = latestTodoWriteInputForPinnedCard(messages); if (input == null) return null; @@ -3413,6 +3435,19 @@ function PinnedTodoSlot({ (event) => event.kind === 'tool_use' && isTodoWriteToolName(event.name), ), ); + const ownerTodoEvent = owner?.events + ? [...owner.events].reverse().find( + (event) => event.kind === 'tool_use' && isTodoWriteToolName(event.name), + ) + : undefined; + const snapshotKey = + owner && + ownerTodoEvent && + 'id' in ownerTodoEvent && + typeof ownerTodoEvent.id === 'string' + ? `${owner.id}:${ownerTodoEvent.id}` + : null; + if (snapshotKey != null && snapshotKey === dismissedSnapshotKey) return null; const unfinishedTodos = owner ? unfinishedTodosFromEvents(owner.events) : []; return ( @@ -3425,8 +3460,16 @@ function PinnedTodoSlot({ runStreaming={streaming} runSucceeded={!streaming} onContinue={ - owner && unfinishedTodos.length > 0 && onContinueRemainingTasks - ? () => onContinueRemainingTasks(owner, unfinishedTodos) + owner && snapshotKey && unfinishedTodos.length > 0 && onContinueRemainingTasks + ? () => { + void Promise.resolve(onContinueRemainingTasks(owner, unfinishedTodos)) + .then((accepted) => { + if (accepted === false) return; + setDismissal({ storageKey, snapshotKey }); + writeContinuedTodoSnapshotKey(storageKey, snapshotKey); + }) + .catch(() => {}); + } : undefined } /> @@ -3434,6 +3477,24 @@ function PinnedTodoSlot({ ); } +function readContinuedTodoSnapshotKey(storageKey: string): string | null { + if (typeof window === 'undefined') return null; + try { + return window.sessionStorage.getItem(storageKey); + } catch { + return null; + } +} + +function writeContinuedTodoSnapshotKey(storageKey: string, snapshotKey: string): void { + if (typeof window === 'undefined') return; + try { + window.sessionStorage.setItem(storageKey, snapshotKey); + } catch { + // sessionStorage may be unavailable in sandboxed or privacy-restricted contexts. + } +} + function QueuedSendStrip({ containerRef, editingId, diff --git a/apps/web/src/components/ProjectView.tsx b/apps/web/src/components/ProjectView.tsx index e652092e9c..39108906be 100644 --- a/apps/web/src/components/ProjectView.tsx +++ b/apps/web/src/components/ProjectView.tsx @@ -6506,8 +6506,8 @@ export function ProjectView({ const commentQueueOnSend = currentConversationBusy && !currentConversationQueueDisabled; const handleContinueRemainingTasks = useCallback( - (_assistantMessage: ChatMessage, todos: TodoItem[]) => { - if (currentConversationActionDisabled || todos.length === 0) return; + async (_assistantMessage: ChatMessage, todos: TodoItem[]) => { + if (currentConversationActionDisabled || todos.length === 0) return false; const remainingList = todos .map((todo, i) => { const label = @@ -6521,7 +6521,7 @@ export function ProjectView({ `${remainingList}\n\n` + 'Before making changes, inspect the current project files as needed. ' + 'Update TodoWrite as you complete each remaining task.'; - void handleSend(prompt, [], []); + return handleSend(prompt, [], []); }, [currentConversationActionDisabled, handleSend], ); diff --git a/apps/web/tests/components/ChatPane.streaming.test.tsx b/apps/web/tests/components/ChatPane.streaming.test.tsx index 31576673a1..c1010e6d55 100644 --- a/apps/web/tests/components/ChatPane.streaming.test.tsx +++ b/apps/web/tests/components/ChatPane.streaming.test.tsx @@ -216,6 +216,7 @@ function mockDataTransfer(): DataTransfer { } beforeEach(() => { + sessionStorage.clear(); MockResizeObserver.instances = []; vi.stubGlobal('ResizeObserver', MockResizeObserver); vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { @@ -1016,6 +1017,155 @@ Expected output: ], ); }); + + it('hides the stale pinned todo after continuing its remaining tasks', async () => { + const onContinueRemainingTasks = vi.fn(() => true); + const messages: ChatMessage[] = [ + { + id: 'assistant-1', + role: 'assistant', + content: '', + createdAt: 1, + endedAt: 2, + runStatus: 'failed', + events: [ + { + kind: 'tool_use', + id: 'todo-1', + name: 'TodoWrite', + input: { + todos: [ + { content: 'Build prototype', status: 'completed' }, + { content: 'Run QA', status: 'pending' }, + ], + }, + }, + ], + }, + ]; + + const { container, rerender } = render( + 'project-1'} + onSend={vi.fn()} + onStop={vi.fn()} + conversations={conversations} + activeConversationId="conv-1" + onSelectConversation={vi.fn()} + onDeleteConversation={vi.fn()} + projectMetadata={projectMetadata} + onContinueRemainingTasks={onContinueRemainingTasks} + />, + ); + + fireEvent.click(container.querySelector('.op-todo-continue')!); + + expect(onContinueRemainingTasks).toHaveBeenCalledOnce(); + await waitFor(() => { + expect(container.querySelector('.chat-pinned-todo')).toBeNull(); + }); + + rerender( + 'project-1'} + onSend={vi.fn()} + onStop={vi.fn()} + conversations={conversations} + activeConversationId="conv-1" + onSelectConversation={vi.fn()} + onDeleteConversation={vi.fn()} + projectMetadata={projectMetadata} + onContinueRemainingTasks={onContinueRemainingTasks} + />, + ); + + expect(container.querySelector('.chat-pinned-todo')).not.toBeNull(); + }); + + it('keeps a continued todo snapshot hidden after the conversation remounts', async () => { + const messages: ChatMessage[] = [ + { + id: 'assistant-1', + role: 'assistant', + content: '', + createdAt: 1, + endedAt: 2, + runStatus: 'failed', + events: [ + { + kind: 'tool_use', + id: 'todo-1', + name: 'update_plan', + input: { + plan: [ + { step: 'Build prototype', status: 'completed' }, + { step: 'Run QA', status: 'pending' }, + ], + }, + }, + ], + }, + ]; + const props = { + messages, + streaming: false, + error: null, + projectId: 'project-1', + projectFiles: [], + onEnsureProject: async () => 'project-1', + onSend: vi.fn(), + onStop: vi.fn(), + conversations, + activeConversationId: 'conv-1', + onSelectConversation: vi.fn(), + onDeleteConversation: vi.fn(), + projectMetadata, + onContinueRemainingTasks: vi.fn(() => true), + }; + + const firstMount = render(); + fireEvent.click(firstMount.container.querySelector('.op-todo-continue')!); + await waitFor(() => { + expect(firstMount.container.querySelector('.chat-pinned-todo')).toBeNull(); + }); + firstMount.unmount(); + + const secondMount = render(); + expect(secondMount.container.querySelector('.chat-pinned-todo')).toBeNull(); + }); + it('shows several queued prompts above the composer with compact controls', () => { const onRemoveQueuedSend = vi.fn(); const onSendQueuedNow = vi.fn();