Skip to content

Commit d5bdcc7

Browse files
committed
fix(web): keep task progress current
1 parent 8e8f32d commit d5bdcc7

4 files changed

Lines changed: 148 additions & 21 deletions

File tree

‎apps/web/src/components/chat/ChatComposer.tsx‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2360,13 +2360,21 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
23602360
steps={visibleTaskSteps}
23612361
/>
23622362
) : null;
2363+
const hasBlockingComposerTopDrawer =
2364+
activePendingApproval !== null || pendingUserInputs.length > 0;
23632365

23642366
useEffect(() => {
23652367
if (visibleTasksProgress === null || visibleTaskSteps === null) {
23662368
setIsTasksDrawerOpen(false);
23672369
}
23682370
}, [visibleTaskSteps, visibleTasksProgress]);
23692371

2372+
useEffect(() => {
2373+
if (hasBlockingComposerTopDrawer) {
2374+
setIsTasksDrawerOpen(false);
2375+
}
2376+
}, [hasBlockingComposerTopDrawer]);
2377+
23702378
useEffect(() => {
23712379
setIsTasksDrawerOpen(false);
23722380
}, [activeThreadId]);
@@ -2809,7 +2817,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
28092817
className="mx-auto w-full min-w-0 max-w-3xl"
28102818
data-chat-composer-form="true"
28112819
>
2812-
{showComposerTopDrawer && !isTasksDrawerOpen ? (
2820+
{showComposerTopDrawer && (!isTasksDrawerOpen || hasBlockingComposerTopDrawer) ? (
28132821
<div
28142822
className="chat-composer-top-drawer"
28152823
data-chat-composer-top-drawer="true"
@@ -2920,7 +2928,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
29202928
) : null}
29212929
</div>
29222930
) : null}
2923-
{isTasksDrawerOpen && visibleTasksProgress && visibleTaskSteps ? (
2931+
{isTasksDrawerOpen &&
2932+
!hasBlockingComposerTopDrawer &&
2933+
visibleTasksProgress &&
2934+
visibleTaskSteps ? (
29242935
<ComposerTasksDrawer
29252936
onDismiss={dismissTasks}
29262937
onCollapse={toggleTasksDrawer}

‎apps/web/src/components/chat/ComposerTasksBadge.tsx‎

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -148,15 +148,16 @@ export const ComposerTasksBadge = memo(function ComposerTasksBadge({
148148
</span>
149149
<TaskSegments className="w-20" steps={steps} />
150150
</button>
151-
<button
152-
type="button"
151+
<Button
152+
size="icon-micro"
153+
variant="ghost-muted"
153154
aria-label="Dismiss tasks for this turn"
154-
className="inline-flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground/65 hover:bg-muted/60 hover:text-foreground"
155+
className="shrink-0"
155156
onClick={onDismiss}
156157
onPointerDown={(event) => event.preventDefault()}
157158
>
158159
<XIcon aria-hidden className="size-3" />
159-
</button>
160+
</Button>
160161
</div>
161162
);
162163
});
@@ -188,15 +189,16 @@ export const ComposerTasksDrawer = memo(function ComposerTasksDrawer({
188189
{progress.completedSteps}/{progress.totalSteps}
189190
</span>
190191
</button>
191-
<button
192-
type="button"
192+
<Button
193+
size="icon-micro"
194+
variant="ghost-muted"
193195
aria-label="Dismiss tasks for this turn"
194-
className="inline-flex size-5 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground/65 hover:bg-muted/60 hover:text-foreground"
196+
className="shrink-0"
195197
onClick={onDismiss}
196198
onPointerDown={(event) => event.preventDefault()}
197199
>
198200
<XIcon aria-hidden className="size-3" />
199-
</button>
201+
</Button>
200202
</div>
201203
<div className="space-y-px px-3 pb-4 sm:px-4" role="list">
202204
{keyedTaskSteps(steps).map(({ key, step }) => (

‎apps/web/src/session-logic.test.ts‎

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,60 @@ describe("deriveActivePlanState", () => {
408408
steps: [{ step: "Write tests", status: "completed" }],
409409
});
410410
});
411+
412+
it("starts timing again after a plan is cleared and recreated", () => {
413+
const activities: OrchestrationThreadActivity[] = [
414+
makeActivity({
415+
id: "plan-old-start",
416+
createdAt: "2026-02-23T00:00:01.000Z",
417+
kind: "turn.plan.updated",
418+
summary: "Plan updated",
419+
tone: "info",
420+
turnId: "turn-1",
421+
payload: { plan: [{ step: "Check", status: "inProgress" }] },
422+
}),
423+
makeActivity({
424+
id: "plan-old-complete",
425+
createdAt: "2026-02-23T00:00:05.000Z",
426+
kind: "turn.plan.updated",
427+
summary: "Plan updated",
428+
tone: "info",
429+
turnId: "turn-1",
430+
payload: { plan: [{ step: "Check", status: "completed" }] },
431+
}),
432+
makeActivity({
433+
id: "plan-clear",
434+
createdAt: "2026-02-23T00:00:06.000Z",
435+
kind: "turn.plan.updated",
436+
summary: "Plan updated",
437+
tone: "info",
438+
turnId: "turn-1",
439+
payload: { plan: [] },
440+
}),
441+
makeActivity({
442+
id: "plan-new-start",
443+
createdAt: "2026-02-23T00:00:10.000Z",
444+
kind: "turn.plan.updated",
445+
summary: "Plan updated",
446+
tone: "info",
447+
turnId: "turn-1",
448+
payload: { plan: [{ step: "Check", status: "inProgress" }] },
449+
}),
450+
makeActivity({
451+
id: "plan-new-complete",
452+
createdAt: "2026-02-23T00:00:13.000Z",
453+
kind: "turn.plan.updated",
454+
summary: "Plan updated",
455+
tone: "info",
456+
turnId: "turn-1",
457+
payload: { plan: [{ step: "Check", status: "completed" }] },
458+
}),
459+
];
460+
461+
expect(deriveActivePlanState(activities, TurnId.make("turn-1"))?.steps).toEqual([
462+
{ durationMs: 3_000, step: "Check", status: "completed" },
463+
]);
464+
});
411465
});
412466

413467
describe("deriveTurnPlans", () => {
@@ -528,6 +582,58 @@ describe("deriveTurnPlans", () => {
528582
]);
529583
});
530584

585+
it("derives fallback durations in completion order", () => {
586+
const activities: OrchestrationThreadActivity[] = [
587+
makeActivity({
588+
id: "plan-start",
589+
createdAt: "2026-02-23T00:00:01.000Z",
590+
kind: "turn.plan.updated",
591+
summary: "Plan updated",
592+
tone: "info",
593+
turnId: "turn-1",
594+
payload: {
595+
plan: [
596+
{ step: "First", status: "pending" },
597+
{ step: "Second", status: "pending" },
598+
],
599+
},
600+
}),
601+
makeActivity({
602+
id: "plan-second-complete",
603+
createdAt: "2026-02-23T00:00:06.000Z",
604+
kind: "turn.plan.updated",
605+
summary: "Plan updated",
606+
tone: "info",
607+
turnId: "turn-1",
608+
payload: {
609+
plan: [
610+
{ step: "First", status: "pending" },
611+
{ step: "Second", status: "completed" },
612+
],
613+
},
614+
}),
615+
makeActivity({
616+
id: "plan-first-complete",
617+
createdAt: "2026-02-23T00:00:11.000Z",
618+
kind: "turn.plan.updated",
619+
summary: "Plan updated",
620+
tone: "info",
621+
turnId: "turn-1",
622+
payload: {
623+
plan: [
624+
{ step: "First", status: "completed" },
625+
{ step: "Second", status: "completed" },
626+
],
627+
},
628+
}),
629+
];
630+
631+
expect(deriveTurnPlans(activities)[0]?.plan.steps).toEqual([
632+
{ durationMs: 5_000, step: "First", status: "completed" },
633+
{ durationMs: 5_000, step: "Second", status: "completed" },
634+
]);
635+
});
636+
531637
it("drops a turn's chip when a later snapshot clears the plan", () => {
532638
const activities: OrchestrationThreadActivity[] = [
533639
makeActivity({

‎apps/web/src/session-logic.ts‎

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -614,21 +614,26 @@ function addPlanStepDurations(
614614
}
615615
}
616616

617+
const durationByKey = new Map<string, number>();
617618
let previousCompletedAt = planStartedAt;
619+
for (const [key, timing] of [...timings.entries()].toSorted(
620+
(left, right) => (left[1].completedAt ?? Infinity) - (right[1].completedAt ?? Infinity),
621+
)) {
622+
const completedAt = timing.completedAt;
623+
const startedAt = timing.startedAt ?? previousCompletedAt;
624+
if (completedAt === undefined) continue;
625+
if (startedAt !== undefined && completedAt > startedAt) {
626+
durationByKey.set(key, completedAt - startedAt);
627+
}
628+
previousCompletedAt = completedAt;
629+
}
630+
618631
return {
619632
...plan,
620633
steps: keyedSteps(plan.steps).map(({ key, step }) => {
621634
if (step.status !== "completed") return step;
622-
const timing = timings.get(key);
623-
const completedAt = timing?.completedAt;
624-
const startedAt = timing?.startedAt ?? previousCompletedAt;
625-
if (completedAt !== undefined) {
626-
previousCompletedAt = completedAt;
627-
}
628-
if (startedAt === undefined || completedAt === undefined || completedAt <= startedAt) {
629-
return step;
630-
}
631-
return { ...step, durationMs: completedAt - startedAt };
635+
const durationMs = durationByKey.get(key);
636+
return durationMs === undefined ? step : { ...step, durationMs };
632637
}),
633638
};
634639
}
@@ -655,7 +660,10 @@ export function deriveActivePlanState(
655660
const matchingActivities = allPlanActivities.filter(
656661
(activity) => activity.turnId === latest.turnId,
657662
);
658-
return addPlanStepDurations(plan, matchingActivities);
663+
const latestClearIndex = matchingActivities.findLastIndex(
664+
(activity) => planStateFromActivity(activity) === null,
665+
);
666+
return addPlanStepDurations(plan, matchingActivities.slice(latestClearIndex + 1));
659667
}
660668

661669
export interface TurnPlanEntry {

0 commit comments

Comments
 (0)