Skip to content

Commit 884777e

Browse files
Siri-Raygithub-actions[bot]
authored andcommitted
fix(web): dismiss stale todo after continuation starts (#6307)
(cherry picked from commit 517f39a)
1 parent da688bb commit 884777e

3 files changed

Lines changed: 218 additions & 7 deletions

File tree

apps/web/src/components/ChatPane.tsx

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -540,7 +540,10 @@ interface Props {
540540
composerPlaceholder?: string;
541541
onSubmitQuestionForm?: QuestionFormSubmitHandler;
542542
questionFormSubmitDisabled?: boolean;
543-
onContinueRemainingTasks?: (assistantMessage: ChatMessage, todos: TodoItem[]) => void;
543+
onContinueRemainingTasks?: (
544+
assistantMessage: ChatMessage,
545+
todos: TodoItem[],
546+
) => boolean | void | Promise<boolean | void>;
544547
onAssistantFeedback?: (assistantMessage: ChatMessage, change: ChatMessageFeedbackChange) => void;
545548
// Client-side action for a brand-browser-assist od-card: open/focus the
546549
// Browser tab. Routed through the stable callbacks ref.
@@ -2685,6 +2688,7 @@ export function ChatPane({
26852688
<PinnedTodoSlot
26862689
messages={displayMessages}
26872690
streaming={streaming}
2691+
conversationId={activeConversationId}
26882692
onContinueRemainingTasks={onContinueRemainingTasks}
26892693
containerRef={pinnedTodoRef}
26902694
/>
@@ -3395,14 +3399,32 @@ function includeVirtualRowByKey<T extends { key: string }>(
33953399
function PinnedTodoSlot({
33963400
messages,
33973401
streaming,
3402+
conversationId,
33983403
onContinueRemainingTasks,
33993404
containerRef,
34003405
}: {
34013406
messages: ChatMessage[];
34023407
streaming: boolean;
3403-
onContinueRemainingTasks?: (assistantMessage: ChatMessage, todos: TodoItem[]) => void;
3408+
conversationId: string | null;
3409+
onContinueRemainingTasks?: (
3410+
assistantMessage: ChatMessage,
3411+
todos: TodoItem[],
3412+
) => boolean | void | Promise<boolean | void>;
34043413
containerRef?: MutableRefObject<HTMLDivElement | null>;
34053414
}) {
3415+
const storageKey = `od:chat:continued-todo:${conversationId ?? 'none'}`;
3416+
const [dismissal, setDismissal] = useState(() => ({
3417+
storageKey,
3418+
snapshotKey: readContinuedTodoSnapshotKey(storageKey),
3419+
}));
3420+
useEffect(() => {
3421+
setDismissal({
3422+
storageKey,
3423+
snapshotKey: readContinuedTodoSnapshotKey(storageKey),
3424+
});
3425+
}, [storageKey]);
3426+
const dismissedSnapshotKey =
3427+
dismissal.storageKey === storageKey ? dismissal.snapshotKey : null;
34063428
const input = latestTodoWriteInputForPinnedCard(messages);
34073429
if (input == null) return null;
34083430

@@ -3413,6 +3435,19 @@ function PinnedTodoSlot({
34133435
(event) => event.kind === 'tool_use' && isTodoWriteToolName(event.name),
34143436
),
34153437
);
3438+
const ownerTodoEvent = owner?.events
3439+
? [...owner.events].reverse().find(
3440+
(event) => event.kind === 'tool_use' && isTodoWriteToolName(event.name),
3441+
)
3442+
: undefined;
3443+
const snapshotKey =
3444+
owner &&
3445+
ownerTodoEvent &&
3446+
'id' in ownerTodoEvent &&
3447+
typeof ownerTodoEvent.id === 'string'
3448+
? `${owner.id}:${ownerTodoEvent.id}`
3449+
: null;
3450+
if (snapshotKey != null && snapshotKey === dismissedSnapshotKey) return null;
34163451
const unfinishedTodos = owner ? unfinishedTodosFromEvents(owner.events) : [];
34173452

34183453
return (
@@ -3425,15 +3460,41 @@ function PinnedTodoSlot({
34253460
runStreaming={streaming}
34263461
runSucceeded={!streaming}
34273462
onContinue={
3428-
owner && unfinishedTodos.length > 0 && onContinueRemainingTasks
3429-
? () => onContinueRemainingTasks(owner, unfinishedTodos)
3463+
owner && snapshotKey && unfinishedTodos.length > 0 && onContinueRemainingTasks
3464+
? () => {
3465+
void Promise.resolve(onContinueRemainingTasks(owner, unfinishedTodos))
3466+
.then((accepted) => {
3467+
if (accepted === false) return;
3468+
setDismissal({ storageKey, snapshotKey });
3469+
writeContinuedTodoSnapshotKey(storageKey, snapshotKey);
3470+
})
3471+
.catch(() => {});
3472+
}
34303473
: undefined
34313474
}
34323475
/>
34333476
</div>
34343477
);
34353478
}
34363479

3480+
function readContinuedTodoSnapshotKey(storageKey: string): string | null {
3481+
if (typeof window === 'undefined') return null;
3482+
try {
3483+
return window.sessionStorage.getItem(storageKey);
3484+
} catch {
3485+
return null;
3486+
}
3487+
}
3488+
3489+
function writeContinuedTodoSnapshotKey(storageKey: string, snapshotKey: string): void {
3490+
if (typeof window === 'undefined') return;
3491+
try {
3492+
window.sessionStorage.setItem(storageKey, snapshotKey);
3493+
} catch {
3494+
// sessionStorage may be unavailable in sandboxed or privacy-restricted contexts.
3495+
}
3496+
}
3497+
34373498
function QueuedSendStrip({
34383499
containerRef,
34393500
editingId,

apps/web/src/components/ProjectView.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6506,8 +6506,8 @@ export function ProjectView({
65066506
const commentQueueOnSend = currentConversationBusy && !currentConversationQueueDisabled;
65076507

65086508
const handleContinueRemainingTasks = useCallback(
6509-
(_assistantMessage: ChatMessage, todos: TodoItem[]) => {
6510-
if (currentConversationActionDisabled || todos.length === 0) return;
6509+
async (_assistantMessage: ChatMessage, todos: TodoItem[]) => {
6510+
if (currentConversationActionDisabled || todos.length === 0) return false;
65116511
const remainingList = todos
65126512
.map((todo, i) => {
65136513
const label =
@@ -6521,7 +6521,7 @@ export function ProjectView({
65216521
`${remainingList}\n\n` +
65226522
'Before making changes, inspect the current project files as needed. ' +
65236523
'Update TodoWrite as you complete each remaining task.';
6524-
void handleSend(prompt, [], []);
6524+
return handleSend(prompt, [], []);
65256525
},
65266526
[currentConversationActionDisabled, handleSend],
65276527
);

apps/web/tests/components/ChatPane.streaming.test.tsx

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ function mockDataTransfer(): DataTransfer {
216216
}
217217

218218
beforeEach(() => {
219+
sessionStorage.clear();
219220
MockResizeObserver.instances = [];
220221
vi.stubGlobal('ResizeObserver', MockResizeObserver);
221222
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
@@ -1016,6 +1017,155 @@ Expected output:
10161017
],
10171018
);
10181019
});
1020+
1021+
it('hides the stale pinned todo after continuing its remaining tasks', async () => {
1022+
const onContinueRemainingTasks = vi.fn(() => true);
1023+
const messages: ChatMessage[] = [
1024+
{
1025+
id: 'assistant-1',
1026+
role: 'assistant',
1027+
content: '',
1028+
createdAt: 1,
1029+
endedAt: 2,
1030+
runStatus: 'failed',
1031+
events: [
1032+
{
1033+
kind: 'tool_use',
1034+
id: 'todo-1',
1035+
name: 'TodoWrite',
1036+
input: {
1037+
todos: [
1038+
{ content: 'Build prototype', status: 'completed' },
1039+
{ content: 'Run QA', status: 'pending' },
1040+
],
1041+
},
1042+
},
1043+
],
1044+
},
1045+
];
1046+
1047+
const { container, rerender } = render(
1048+
<ChatPane
1049+
messages={messages}
1050+
streaming={false}
1051+
error={null}
1052+
projectId="project-1"
1053+
projectFiles={[]}
1054+
onEnsureProject={async () => 'project-1'}
1055+
onSend={vi.fn()}
1056+
onStop={vi.fn()}
1057+
conversations={conversations}
1058+
activeConversationId="conv-1"
1059+
onSelectConversation={vi.fn()}
1060+
onDeleteConversation={vi.fn()}
1061+
projectMetadata={projectMetadata}
1062+
onContinueRemainingTasks={onContinueRemainingTasks}
1063+
/>,
1064+
);
1065+
1066+
fireEvent.click(container.querySelector<HTMLButtonElement>('.op-todo-continue')!);
1067+
1068+
expect(onContinueRemainingTasks).toHaveBeenCalledOnce();
1069+
await waitFor(() => {
1070+
expect(container.querySelector('.chat-pinned-todo')).toBeNull();
1071+
});
1072+
1073+
rerender(
1074+
<ChatPane
1075+
messages={[
1076+
...messages,
1077+
{
1078+
id: 'assistant-2',
1079+
role: 'assistant',
1080+
content: '',
1081+
createdAt: 3,
1082+
runStatus: 'running',
1083+
events: [
1084+
{
1085+
kind: 'tool_use',
1086+
id: 'todo-2',
1087+
name: 'TodoWrite',
1088+
input: {
1089+
todos: [
1090+
{ content: 'Build prototype', status: 'completed' },
1091+
{ content: 'Run QA', status: 'pending' },
1092+
],
1093+
},
1094+
},
1095+
],
1096+
},
1097+
]}
1098+
streaming
1099+
error={null}
1100+
projectId="project-1"
1101+
projectFiles={[]}
1102+
onEnsureProject={async () => 'project-1'}
1103+
onSend={vi.fn()}
1104+
onStop={vi.fn()}
1105+
conversations={conversations}
1106+
activeConversationId="conv-1"
1107+
onSelectConversation={vi.fn()}
1108+
onDeleteConversation={vi.fn()}
1109+
projectMetadata={projectMetadata}
1110+
onContinueRemainingTasks={onContinueRemainingTasks}
1111+
/>,
1112+
);
1113+
1114+
expect(container.querySelector('.chat-pinned-todo')).not.toBeNull();
1115+
});
1116+
1117+
it('keeps a continued todo snapshot hidden after the conversation remounts', async () => {
1118+
const messages: ChatMessage[] = [
1119+
{
1120+
id: 'assistant-1',
1121+
role: 'assistant',
1122+
content: '',
1123+
createdAt: 1,
1124+
endedAt: 2,
1125+
runStatus: 'failed',
1126+
events: [
1127+
{
1128+
kind: 'tool_use',
1129+
id: 'todo-1',
1130+
name: 'update_plan',
1131+
input: {
1132+
plan: [
1133+
{ step: 'Build prototype', status: 'completed' },
1134+
{ step: 'Run QA', status: 'pending' },
1135+
],
1136+
},
1137+
},
1138+
],
1139+
},
1140+
];
1141+
const props = {
1142+
messages,
1143+
streaming: false,
1144+
error: null,
1145+
projectId: 'project-1',
1146+
projectFiles: [],
1147+
onEnsureProject: async () => 'project-1',
1148+
onSend: vi.fn(),
1149+
onStop: vi.fn(),
1150+
conversations,
1151+
activeConversationId: 'conv-1',
1152+
onSelectConversation: vi.fn(),
1153+
onDeleteConversation: vi.fn(),
1154+
projectMetadata,
1155+
onContinueRemainingTasks: vi.fn(() => true),
1156+
};
1157+
1158+
const firstMount = render(<ChatPane {...props} />);
1159+
fireEvent.click(firstMount.container.querySelector<HTMLButtonElement>('.op-todo-continue')!);
1160+
await waitFor(() => {
1161+
expect(firstMount.container.querySelector('.chat-pinned-todo')).toBeNull();
1162+
});
1163+
firstMount.unmount();
1164+
1165+
const secondMount = render(<ChatPane {...props} />);
1166+
expect(secondMount.container.querySelector('.chat-pinned-todo')).toBeNull();
1167+
});
1168+
10191169
it('shows several queued prompts above the composer with compact controls', () => {
10201170
const onRemoveQueuedSend = vi.fn();
10211171
const onSendQueuedNow = vi.fn();

0 commit comments

Comments
 (0)