authorgravatar for harry@found.comHarry <harry@found.com> 2026-04-02 15:59:27-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-02 22:59:27+00:00
log48481aa9233666ae05a369e0ed7d16e06e024586
tree3c1eece0588ebf99206776d48a1e0e137e6b08ac
parentda107f315c71e376a196ff8eb2b6c4f1aa15c163
signature Signed by PGP key B5690EEEBB952194

Fix stale send spinner after completed turns (#1700)


3 files changed, 118 insertions(+), 16 deletions(-)

apps/web/src/components/ChatView.tsx+15-10
...@@ -52,6 +52,7 @@ import {...@@ -52,6 +52,7 @@ import {
52 deriveWorkLogEntries,52 deriveWorkLogEntries,
53 hasActionableProposedPlan,53 hasActionableProposedPlan,
54 hasToolActivityForTurn,54 hasToolActivityForTurn,
55 isSessionActivelyRunningTurn,
55 isLatestTurnSettled,56 isLatestTurnSettled,
56 formatElapsed,57 formatElapsed,
57} from "../session-logic";58} from "../session-logic";
...@@ -1120,7 +1121,11 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1120,7 +1121,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
1120 activePendingUserInput: activePendingUserInput?.requestId ?? null,1121 activePendingUserInput: activePendingUserInput?.requestId ?? null,
1121 threadError: activeThread?.error,1122 threadError: activeThread?.error,
1122 });1123 });
1123 const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint;1124 const isTurnRunning = isSessionActivelyRunningTurn(
1125 activeLatestTurn,
1126 activeThread?.session ?? null,
1127 );
1128 const isWorking = isTurnRunning || isSendBusy || isConnecting || isRevertingCheckpoint;
1124 const nowIso = new Date(nowTick).toISOString();1129 const nowIso = new Date(nowTick).toISOString();
1125 const activeWorkStartedAt = deriveActiveWorkStartedAt(1130 const activeWorkStartedAt = deriveActiveWorkStartedAt(
1126 activeLatestTurn,1131 activeLatestTurn,
...@@ -1137,7 +1142,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1137,7 +1142,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1137 if (activePendingProgress) {1142 if (activePendingProgress) {
1138 return `pending:${activePendingProgress.questionIndex}:${activePendingProgress.isLastQuestion}:${activePendingIsResponding}`;1143 return `pending:${activePendingProgress.questionIndex}:${activePendingProgress.isLastQuestion}:${activePendingIsResponding}`;
1139 }1144 }
1140 if (phase === "running") {1145 if (isTurnRunning) {
1141 return "running";1146 return "running";
1142 }1147 }
1143 if (showPlanFollowUpPrompt) {1148 if (showPlanFollowUpPrompt) {
...@@ -1151,7 +1156,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1151,7 +1156,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1151 isConnecting,1156 isConnecting,
1152 isPreparingWorktree,1157 isPreparingWorktree,
1153 isSendBusy,1158 isSendBusy,
1154 phase,1159 isTurnRunning,
1155 prompt,1160 prompt,
1156 showPlanFollowUpPrompt,1161 showPlanFollowUpPrompt,
1157 ]);1162 ]);
...@@ -2311,10 +2316,10 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2311,10 +2316,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
2311 scheduleStickToBottom();2316 scheduleStickToBottom();
2312 }, [messageCount, scheduleStickToBottom]);2317 }, [messageCount, scheduleStickToBottom]);
2313 useEffect(() => {2318 useEffect(() => {
2314 if (phase !== "running") return;2319 if (!isTurnRunning) return;
2315 if (!shouldAutoScrollRef.current) return;2320 if (!shouldAutoScrollRef.current) return;
2316 scheduleStickToBottom();2321 scheduleStickToBottom();
2317 }, [phase, scheduleStickToBottom, timelineEntries]);2322 }, [isTurnRunning, scheduleStickToBottom, timelineEntries]);
23182323
2319 useEffect(() => {2324 useEffect(() => {
2320 setExpandedWorkGroups({});2325 setExpandedWorkGroups({});
...@@ -2533,14 +2538,14 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2533,14 +2538,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
2533 : "local";2538 : "local";
25342539
2535 useEffect(() => {2540 useEffect(() => {
2536 if (phase !== "running") return;2541 if (!isTurnRunning) return;
2537 const timer = window.setInterval(() => {2542 const timer = window.setInterval(() => {
2538 setNowTick(Date.now());2543 setNowTick(Date.now());
2539 }, 1000);2544 }, 1000);
2540 return () => {2545 return () => {
2541 window.clearInterval(timer);2546 window.clearInterval(timer);
2542 };2547 };
2543 }, [phase]);2548 }, [isTurnRunning]);
25442549
2545 useEffect(() => {2550 useEffect(() => {
2546 if (!activeThreadId) return;2551 if (!activeThreadId) return;
...@@ -2760,7 +2765,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2760,7 +2765,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2760 const api = readNativeApi();2765 const api = readNativeApi();
2761 if (!api || !activeThread || isRevertingCheckpoint) return;2766 if (!api || !activeThread || isRevertingCheckpoint) return;
27622767
2763 if (phase === "running" || isSendBusy || isConnecting) {2768 if (isTurnRunning || isSendBusy || isConnecting) {
2764 setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints.");2769 setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints.");
2765 return;2770 return;
2766 }2771 }
...@@ -2793,7 +2798,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -2793,7 +2798,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
2793 }2798 }
2794 setIsRevertingCheckpoint(false);2799 setIsRevertingCheckpoint(false);
2795 },2800 },
2796 [activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError],2801 [activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, isTurnRunning, setThreadError],
2797 );2802 );
27982803
2799 const onSend = async (e?: { preventDefault: () => void }) => {2804 const onSend = async (e?: { preventDefault: () => void }) => {
...@@ -4382,7 +4387,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -4382,7 +4387,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
4382 }4387 }
4383 : null4388 : null
4384 }4389 }
4385 isRunning={phase === "running"}4390 isRunning={isTurnRunning}
4386 showPlanFollowUpPrompt={4391 showPlanFollowUpPrompt={
4387 pendingUserInputs.length === 0 && showPlanFollowUpPrompt4392 pendingUserInputs.length === 0 && showPlanFollowUpPrompt
4388 }4393 }
apps/web/src/session-logic.test.ts+85-3
...@@ -20,6 +20,7 @@ import {...@@ -20,6 +20,7 @@ import {
20 findSidebarProposedPlan,20 findSidebarProposedPlan,
21 hasActionableProposedPlan,21 hasActionableProposedPlan,
22 hasToolActivityForTurn,22 hasToolActivityForTurn,
23 isSessionActivelyRunningTurn,
23 isLatestTurnSettled,24 isLatestTurnSettled,
24} from "./session-logic";25} from "./session-logic";
2526
...@@ -1072,15 +1073,30 @@ describe("isLatestTurnSettled", () => {...@@ -1072,15 +1073,30 @@ describe("isLatestTurnSettled", () => {
1072 } as const;1073 } as const;
10731074
1074 it("returns false while the same turn is still active in a running session", () => {1075 it("returns false while the same turn is still active in a running session", () => {
1076 expect(
1077 isLatestTurnSettled(
1078 {
1079 ...latestTurn,
1080 completedAt: null,
1081 },
1082 {
1083 orchestrationStatus: "running",
1084 activeTurnId: TurnId.makeUnsafe("turn-1"),
1085 },
1086 ),
1087 ).toBe(false);
1088 });
1089
1090 it("returns true when the turn completed but the session status stayed stale-running", () => {
1075 expect(1091 expect(
1076 isLatestTurnSettled(latestTurn, {1092 isLatestTurnSettled(latestTurn, {
1077 orchestrationStatus: "running",1093 orchestrationStatus: "running",
1078 activeTurnId: TurnId.makeUnsafe("turn-1"),1094 activeTurnId: TurnId.makeUnsafe("turn-1"),
1079 }),1095 }),
1080 ).toBe(false);1096 ).toBe(true);
1081 });1097 });
10821098
1083 it("returns false while any turn is running to avoid stale latest-turn banners", () => {1099 it("returns false while a different turn is still running", () => {
1084 expect(1100 expect(
1085 isLatestTurnSettled(latestTurn, {1101 isLatestTurnSettled(latestTurn, {
1086 orchestrationStatus: "running",1102 orchestrationStatus: "running",
...@@ -1112,6 +1128,56 @@ describe("isLatestTurnSettled", () => {...@@ -1112,6 +1128,56 @@ describe("isLatestTurnSettled", () => {
1112 });1128 });
1113});1129});
11141130
1131describe("isSessionActivelyRunningTurn", () => {
1132 const completedTurn = {
1133 turnId: TurnId.makeUnsafe("turn-1"),
1134 startedAt: "2026-02-27T21:10:00.000Z",
1135 completedAt: "2026-02-27T21:10:06.000Z",
1136 } as const;
1137
1138 it("returns true when the current turn has not completed yet", () => {
1139 expect(
1140 isSessionActivelyRunningTurn(
1141 {
1142 ...completedTurn,
1143 completedAt: null,
1144 },
1145 {
1146 orchestrationStatus: "running",
1147 activeTurnId: TurnId.makeUnsafe("turn-1"),
1148 },
1149 ),
1150 ).toBe(true);
1151 });
1152
1153 it("returns false when the same turn already completed and only the session is stale", () => {
1154 expect(
1155 isSessionActivelyRunningTurn(completedTurn, {
1156 orchestrationStatus: "running",
1157 activeTurnId: TurnId.makeUnsafe("turn-1"),
1158 }),
1159 ).toBe(false);
1160 });
1161
1162 it("returns true when a different turn is still active", () => {
1163 expect(
1164 isSessionActivelyRunningTurn(completedTurn, {
1165 orchestrationStatus: "running",
1166 activeTurnId: TurnId.makeUnsafe("turn-2"),
1167 }),
1168 ).toBe(true);
1169 });
1170
1171 it("returns false when the session is not running", () => {
1172 expect(
1173 isSessionActivelyRunningTurn(completedTurn, {
1174 orchestrationStatus: "ready",
1175 activeTurnId: undefined,
1176 }),
1177 ).toBe(false);
1178 });
1179});
1180
1115describe("deriveActiveWorkStartedAt", () => {1181describe("deriveActiveWorkStartedAt", () => {
1116 const latestTurn = {1182 const latestTurn = {
1117 turnId: TurnId.makeUnsafe("turn-1"),1183 turnId: TurnId.makeUnsafe("turn-1"),
...@@ -1122,7 +1188,10 @@ describe("deriveActiveWorkStartedAt", () => {...@@ -1122,7 +1188,10 @@ describe("deriveActiveWorkStartedAt", () => {
1122 it("prefers the in-flight turn start when the latest turn is not settled", () => {1188 it("prefers the in-flight turn start when the latest turn is not settled", () => {
1123 expect(1189 expect(
1124 deriveActiveWorkStartedAt(1190 deriveActiveWorkStartedAt(
1125 latestTurn,1191 {
1192 ...latestTurn,
1193 completedAt: null,
1194 },
1126 {1195 {
1127 orchestrationStatus: "running",1196 orchestrationStatus: "running",
1128 activeTurnId: TurnId.makeUnsafe("turn-1"),1197 activeTurnId: TurnId.makeUnsafe("turn-1"),
...@@ -1132,6 +1201,19 @@ describe("deriveActiveWorkStartedAt", () => {...@@ -1132,6 +1201,19 @@ describe("deriveActiveWorkStartedAt", () => {
1132 ).toBe("2026-02-27T21:10:00.000Z");1201 ).toBe("2026-02-27T21:10:00.000Z");
1133 });1202 });
11341203
1204 it("falls back to sendStartedAt when only the session status is stale-running", () => {
1205 expect(
1206 deriveActiveWorkStartedAt(
1207 latestTurn,
1208 {
1209 orchestrationStatus: "running",
1210 activeTurnId: TurnId.makeUnsafe("turn-1"),
1211 },
1212 "2026-02-27T21:11:00.000Z",
1213 ),
1214 ).toBe("2026-02-27T21:11:00.000Z");
1215 });
1216
1135 it("falls back to sendStartedAt once the latest turn is settled", () => {1217 it("falls back to sendStartedAt once the latest turn is settled", () => {
1136 expect(1218 expect(
1137 deriveActiveWorkStartedAt(1219 deriveActiveWorkStartedAt(
apps/web/src/session-logic.ts+18-3
...@@ -128,15 +128,30 @@ export function formatElapsed(startIso: string, endIso: string | undefined): str...@@ -128,15 +128,30 @@ export function formatElapsed(startIso: string, endIso: string | undefined): str
128type LatestTurnTiming = Pick<OrchestrationLatestTurn, "turnId" | "startedAt" | "completedAt">;128type LatestTurnTiming = Pick<OrchestrationLatestTurn, "turnId" | "startedAt" | "completedAt">;
129type SessionActivityState = Pick<ThreadSession, "orchestrationStatus" | "activeTurnId">;129type SessionActivityState = Pick<ThreadSession, "orchestrationStatus" | "activeTurnId">;
130130
131export function isSessionActivelyRunningTurn(
132 latestTurn: LatestTurnTiming | null,
133 session: SessionActivityState | null,
134): boolean {
135 if (!session || session.orchestrationStatus !== "running") return false;
136 if (!latestTurn) return true;
137
138 const activeTurnId = session.activeTurnId;
139 if (activeTurnId === undefined) {
140 return latestTurn.completedAt === null;
141 }
142 if (latestTurn.turnId !== activeTurnId) {
143 return true;
144 }
145 return latestTurn.completedAt === null;
146}
147
131export function isLatestTurnSettled(148export function isLatestTurnSettled(
132 latestTurn: LatestTurnTiming | null,149 latestTurn: LatestTurnTiming | null,
133 session: SessionActivityState | null,150 session: SessionActivityState | null,
134): boolean {151): boolean {
135 if (!latestTurn?.startedAt) return false;152 if (!latestTurn?.startedAt) return false;
136 if (!latestTurn.completedAt) return false;153 if (!latestTurn.completedAt) return false;
137 if (!session) return true;154 return !isSessionActivelyRunningTurn(latestTurn, session);
138 if (session.orchestrationStatus === "running") return false;
139 return true;
140}155}
141156
142export function deriveActiveWorkStartedAt(157export function deriveActiveWorkStartedAt(