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 {
5252 deriveWorkLogEntries,
5353 hasActionableProposedPlan,
5454 hasToolActivityForTurn,
55 isSessionActivelyRunningTurn,
5556 isLatestTurnSettled,
5657 formatElapsed,
5758} from "../session-logic";
......@@ -1120,7 +1121,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
11201121 activePendingUserInput: activePendingUserInput?.requestId ?? null,
11211122 threadError: activeThread?.error,
11221123 });
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;
11241129 const nowIso = new Date(nowTick).toISOString();
11251130 const activeWorkStartedAt = deriveActiveWorkStartedAt(
11261131 activeLatestTurn,
......@@ -1137,7 +1142,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
11371142 if (activePendingProgress) {
11381143 return `pending:${activePendingProgress.questionIndex}:${activePendingProgress.isLastQuestion}:${activePendingIsResponding}`;
11391144 }
1140 if (phase === "running") {
1145 if (isTurnRunning) {
11411146 return "running";
11421147 }
11431148 if (showPlanFollowUpPrompt) {
......@@ -1151,7 +1156,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
11511156 isConnecting,
11521157 isPreparingWorktree,
11531158 isSendBusy,
1154 phase,
1159 isTurnRunning,
11551160 prompt,
11561161 showPlanFollowUpPrompt,
11571162 ]);
......@@ -2311,10 +2316,10 @@ export default function ChatView({ threadId }: ChatViewProps) {
23112316 scheduleStickToBottom();
23122317 }, [messageCount, scheduleStickToBottom]);
23132318 useEffect(() => {
2314 if (phase !== "running") return;
2319 if (!isTurnRunning) return;
23152320 if (!shouldAutoScrollRef.current) return;
23162321 scheduleStickToBottom();
2317 }, [phase, scheduleStickToBottom, timelineEntries]);
2322 }, [isTurnRunning, scheduleStickToBottom, timelineEntries]);
23182323
23192324 useEffect(() => {
23202325 setExpandedWorkGroups({});
......@@ -2533,14 +2538,14 @@ export default function ChatView({ threadId }: ChatViewProps) {
25332538 : "local";
25342539
25352540 useEffect(() => {
2536 if (phase !== "running") return;
2541 if (!isTurnRunning) return;
25372542 const timer = window.setInterval(() => {
25382543 setNowTick(Date.now());
25392544 }, 1000);
25402545 return () => {
25412546 window.clearInterval(timer);
25422547 };
2543 }, [phase]);
2548 }, [isTurnRunning]);
25442549
25452550 useEffect(() => {
25462551 if (!activeThreadId) return;
......@@ -2760,7 +2765,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
27602765 const api = readNativeApi();
27612766 if (!api || !activeThread || isRevertingCheckpoint) return;
27622767
2763 if (phase === "running" || isSendBusy || isConnecting) {
2768 if (isTurnRunning || isSendBusy || isConnecting) {
27642769 setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints.");
27652770 return;
27662771 }
......@@ -2793,7 +2798,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
27932798 }
27942799 setIsRevertingCheckpoint(false);
27952800 },
2796 [activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError],
2801 [activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, isTurnRunning, setThreadError],
27972802 );
27982803
27992804 const onSend = async (e?: { preventDefault: () => void }) => {
......@@ -4382,7 +4387,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
43824387 }
43834388 : null
43844389 }
4385 isRunning={phase === "running"}
4390 isRunning={isTurnRunning}
43864391 showPlanFollowUpPrompt={
43874392 pendingUserInputs.length === 0 && showPlanFollowUpPrompt
43884393 }
apps/web/src/session-logic.test.ts+85-3
......@@ -20,6 +20,7 @@ import {
2020 findSidebarProposedPlan,
2121 hasActionableProposedPlan,
2222 hasToolActivityForTurn,
23 isSessionActivelyRunningTurn,
2324 isLatestTurnSettled,
2425} from "./session-logic";
2526
......@@ -1072,15 +1073,30 @@ describe("isLatestTurnSettled", () => {
10721073 } as const;
10731074
10741075 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", () => {
10751091 expect(
10761092 isLatestTurnSettled(latestTurn, {
10771093 orchestrationStatus: "running",
10781094 activeTurnId: TurnId.makeUnsafe("turn-1"),
10791095 }),
1080 ).toBe(false);
1096 ).toBe(true);
10811097 });
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", () => {
10841100 expect(
10851101 isLatestTurnSettled(latestTurn, {
10861102 orchestrationStatus: "running",
......@@ -1112,6 +1128,56 @@ describe("isLatestTurnSettled", () => {
11121128 });
11131129});
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
11151181describe("deriveActiveWorkStartedAt", () => {
11161182 const latestTurn = {
11171183 turnId: TurnId.makeUnsafe("turn-1"),
......@@ -1122,7 +1188,10 @@ describe("deriveActiveWorkStartedAt", () => {
11221188 it("prefers the in-flight turn start when the latest turn is not settled", () => {
11231189 expect(
11241190 deriveActiveWorkStartedAt(
1125 latestTurn,
1191 {
1192 ...latestTurn,
1193 completedAt: null,
1194 },
11261195 {
11271196 orchestrationStatus: "running",
11281197 activeTurnId: TurnId.makeUnsafe("turn-1"),
......@@ -1132,6 +1201,19 @@ describe("deriveActiveWorkStartedAt", () => {
11321201 ).toBe("2026-02-27T21:10:00.000Z");
11331202 });
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
11351217 it("falls back to sendStartedAt once the latest turn is settled", () => {
11361218 expect(
11371219 deriveActiveWorkStartedAt(
apps/web/src/session-logic.ts+18-3
......@@ -128,15 +128,30 @@ export function formatElapsed(startIso: string, endIso: string | undefined): str
128128type LatestTurnTiming = Pick<OrchestrationLatestTurn, "turnId" | "startedAt" | "completedAt">;
129129type 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
131148export function isLatestTurnSettled(
132149 latestTurn: LatestTurnTiming | null,
133150 session: SessionActivityState | null,
134151): boolean {
135152 if (!latestTurn?.startedAt) return false;
136153 if (!latestTurn.completedAt) return false;
137 if (!session) return true;
138 if (session.orchestrationStatus === "running") return false;
139 return true;
154 return !isSessionActivelyRunningTurn(latestTurn, session);
140155}
141156
142157export function deriveActiveWorkStartedAt(