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
69 changes: 65 additions & 4 deletions apps/web/src/components/ChatPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean | void>;
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.
Expand Down Expand Up @@ -2685,6 +2688,7 @@ export function ChatPane({
<PinnedTodoSlot
messages={displayMessages}
streaming={streaming}
conversationId={activeConversationId}
onContinueRemainingTasks={onContinueRemainingTasks}
containerRef={pinnedTodoRef}
/>
Expand Down Expand Up @@ -3395,14 +3399,32 @@ function includeVirtualRowByKey<T extends { key: string }>(
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<boolean | void>;
containerRef?: MutableRefObject<HTMLDivElement | null>;
}) {
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;

Expand All @@ -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 (
Expand All @@ -3425,15 +3460,41 @@ 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
}
/>
</div>
);
}

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,
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/components/ProjectView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6515,8 +6515,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 =
Expand All @@ -6530,7 +6530,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],
);
Expand Down
150 changes: 150 additions & 0 deletions apps/web/tests/components/ChatPane.streaming.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ function mockDataTransfer(): DataTransfer {
}

beforeEach(() => {
sessionStorage.clear();
MockResizeObserver.instances = [];
vi.stubGlobal('ResizeObserver', MockResizeObserver);
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
Expand Down Expand Up @@ -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(
<ChatPane
messages={messages}
streaming={false}
error={null}
projectId="project-1"
projectFiles={[]}
onEnsureProject={async () => '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<HTMLButtonElement>('.op-todo-continue')!);

expect(onContinueRemainingTasks).toHaveBeenCalledOnce();
await waitFor(() => {
expect(container.querySelector('.chat-pinned-todo')).toBeNull();
});

rerender(
<ChatPane
messages={[
...messages,
{
id: 'assistant-2',
role: 'assistant',
content: '',
createdAt: 3,
runStatus: 'running',
events: [
{
kind: 'tool_use',
id: 'todo-2',
name: 'TodoWrite',
input: {
todos: [
{ content: 'Build prototype', status: 'completed' },
{ content: 'Run QA', status: 'pending' },
],
},
},
],
},
]}
streaming
error={null}
projectId="project-1"
projectFiles={[]}
onEnsureProject={async () => '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(<ChatPane {...props} />);
fireEvent.click(firstMount.container.querySelector<HTMLButtonElement>('.op-todo-continue')!);
await waitFor(() => {
expect(firstMount.container.querySelector('.chat-pinned-todo')).toBeNull();
});
firstMount.unmount();

const secondMount = render(<ChatPane {...props} />);
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();
Expand Down
Loading