| author | |
| committer | |
| log | e8f5b4ad5b28b63563fe3812998077ffb343aec8 |
| tree | 25c901227bcb9f5bcc03db1a9f967c131cade1ed |
| parent | 752f96e9ae72be9d56686902454eb7a3c7d8686a |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>3 files changed, 16 insertions(+), 118 deletions(-)
apps/web/src/components/ChatView.tsx+10-15| ... | ... | @@ -52,7 +52,6 @@ import { |
| 52 | 52 | deriveWorkLogEntries, |
| 53 | 53 | hasActionableProposedPlan, |
| 54 | 54 | hasToolActivityForTurn, |
| 55 | isSessionActivelyRunningTurn, | |
| 56 | 55 | isLatestTurnSettled, |
| 57 | 56 | formatElapsed, |
| 58 | 57 | } from "../session-logic"; |
| ... | ... | @@ -1121,11 +1120,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1121 | 1120 | activePendingUserInput: activePendingUserInput?.requestId ?? null, |
| 1122 | 1121 | threadError: activeThread?.error, |
| 1123 | 1122 | }); |
| 1124 | const isTurnRunning = isSessionActivelyRunningTurn( | |
| 1125 | activeLatestTurn, | |
| 1126 | activeThread?.session ?? null, | |
| 1127 | ); | |
| 1128 | const isWorking = isTurnRunning || isSendBusy || isConnecting || isRevertingCheckpoint; | |
| 1123 | const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; | |
| 1129 | 1124 | const nowIso = new Date(nowTick).toISOString(); |
| 1130 | 1125 | const activeWorkStartedAt = deriveActiveWorkStartedAt( |
| 1131 | 1126 | activeLatestTurn, |
| ... | ... | @@ -1142,7 +1137,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1142 | 1137 | if (activePendingProgress) { |
| 1143 | 1138 | return `pending:${activePendingProgress.questionIndex}:${activePendingProgress.isLastQuestion}:${activePendingIsResponding}`; |
| 1144 | 1139 | } |
| 1145 | if (isTurnRunning) { | |
| 1140 | if (phase === "running") { | |
| 1146 | 1141 | return "running"; |
| 1147 | 1142 | } |
| 1148 | 1143 | if (showPlanFollowUpPrompt) { |
| ... | ... | @@ -1156,7 +1151,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1156 | 1151 | isConnecting, |
| 1157 | 1152 | isPreparingWorktree, |
| 1158 | 1153 | isSendBusy, |
| 1159 | isTurnRunning, | |
| 1154 | phase, | |
| 1160 | 1155 | prompt, |
| 1161 | 1156 | showPlanFollowUpPrompt, |
| 1162 | 1157 | ]); |
| ... | ... | @@ -2316,10 +2311,10 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2316 | 2311 | scheduleStickToBottom(); |
| 2317 | 2312 | }, [messageCount, scheduleStickToBottom]); |
| 2318 | 2313 | useEffect(() => { |
| 2319 | if (!isTurnRunning) return; | |
| 2314 | if (phase !== "running") return; | |
| 2320 | 2315 | if (!shouldAutoScrollRef.current) return; |
| 2321 | 2316 | scheduleStickToBottom(); |
| 2322 | }, [isTurnRunning, scheduleStickToBottom, timelineEntries]); | |
| 2317 | }, [phase, scheduleStickToBottom, timelineEntries]); | |
| 2323 | 2318 | |
| 2324 | 2319 | useEffect(() => { |
| 2325 | 2320 | setExpandedWorkGroups({}); |
| ... | ... | @@ -2538,14 +2533,14 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2538 | 2533 | : "local"; |
| 2539 | 2534 | |
| 2540 | 2535 | useEffect(() => { |
| 2541 | if (!isTurnRunning) return; | |
| 2536 | if (phase !== "running") return; | |
| 2542 | 2537 | const timer = window.setInterval(() => { |
| 2543 | 2538 | setNowTick(Date.now()); |
| 2544 | 2539 | }, 1000); |
| 2545 | 2540 | return () => { |
| 2546 | 2541 | window.clearInterval(timer); |
| 2547 | 2542 | }; |
| 2548 | }, [isTurnRunning]); | |
| 2543 | }, [phase]); | |
| 2549 | 2544 | |
| 2550 | 2545 | useEffect(() => { |
| 2551 | 2546 | if (!activeThreadId) return; |
| ... | ... | @@ -2765,7 +2760,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2765 | 2760 | const api = readNativeApi(); |
| 2766 | 2761 | if (!api || !activeThread || isRevertingCheckpoint) return; |
| 2767 | 2762 | |
| 2768 | if (isTurnRunning || isSendBusy || isConnecting) { | |
| 2763 | if (phase === "running" || isSendBusy || isConnecting) { | |
| 2769 | 2764 | setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints."); |
| 2770 | 2765 | return; |
| 2771 | 2766 | } |
| ... | ... | @@ -2798,7 +2793,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2798 | 2793 | } |
| 2799 | 2794 | setIsRevertingCheckpoint(false); |
| 2800 | 2795 | }, |
| 2801 | [activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, isTurnRunning, setThreadError], | |
| 2796 | [activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError], | |
| 2802 | 2797 | ); |
| 2803 | 2798 | |
| 2804 | 2799 | const onSend = async (e?: { preventDefault: () => void }) => { |
| ... | ... | @@ -4387,7 +4382,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 4387 | 4382 | } |
| 4388 | 4383 | : null |
| 4389 | 4384 | } |
| 4390 | isRunning={isTurnRunning} | |
| 4385 | isRunning={phase === "running"} | |
| 4391 | 4386 | showPlanFollowUpPrompt={ |
| 4392 | 4387 | pendingUserInputs.length === 0 && showPlanFollowUpPrompt |
| 4393 | 4388 | } |
apps/web/src/session-logic.test.ts+3-85| ... | ... | @@ -20,7 +20,6 @@ import { |
| 20 | 20 | findSidebarProposedPlan, |
| 21 | 21 | hasActionableProposedPlan, |
| 22 | 22 | hasToolActivityForTurn, |
| 23 | isSessionActivelyRunningTurn, | |
| 24 | 23 | isLatestTurnSettled, |
| 25 | 24 | } from "./session-logic"; |
| 26 | 25 | |
| ... | ... | @@ -1073,30 +1072,15 @@ describe("isLatestTurnSettled", () => { |
| 1073 | 1072 | } as const; |
| 1074 | 1073 | |
| 1075 | 1074 | 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", () => { | |
| 1091 | 1075 | expect( |
| 1092 | 1076 | isLatestTurnSettled(latestTurn, { |
| 1093 | 1077 | orchestrationStatus: "running", |
| 1094 | 1078 | activeTurnId: TurnId.makeUnsafe("turn-1"), |
| 1095 | 1079 | }), |
| 1096 | ).toBe(true); | |
| 1080 | ).toBe(false); | |
| 1097 | 1081 | }); |
| 1098 | 1082 | |
| 1099 | it("returns false while a different turn is still running", () => { | |
| 1083 | it("returns false while any turn is running to avoid stale latest-turn banners", () => { | |
| 1100 | 1084 | expect( |
| 1101 | 1085 | isLatestTurnSettled(latestTurn, { |
| 1102 | 1086 | orchestrationStatus: "running", |
| ... | ... | @@ -1128,56 +1112,6 @@ describe("isLatestTurnSettled", () => { |
| 1128 | 1112 | }); |
| 1129 | 1113 | }); |
| 1130 | 1114 | |
| 1131 | describe("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 | ||
| 1181 | 1115 | describe("deriveActiveWorkStartedAt", () => { |
| 1182 | 1116 | const latestTurn = { |
| 1183 | 1117 | turnId: TurnId.makeUnsafe("turn-1"), |
| ... | ... | @@ -1186,22 +1120,6 @@ describe("deriveActiveWorkStartedAt", () => { |
| 1186 | 1120 | } as const; |
| 1187 | 1121 | |
| 1188 | 1122 | it("prefers the in-flight turn start when the latest turn is not settled", () => { |
| 1189 | expect( | |
| 1190 | deriveActiveWorkStartedAt( | |
| 1191 | { | |
| 1192 | ...latestTurn, | |
| 1193 | completedAt: null, | |
| 1194 | }, | |
| 1195 | { | |
| 1196 | orchestrationStatus: "running", | |
| 1197 | activeTurnId: TurnId.makeUnsafe("turn-1"), | |
| 1198 | }, | |
| 1199 | "2026-02-27T21:11:00.000Z", | |
| 1200 | ), | |
| 1201 | ).toBe("2026-02-27T21:10:00.000Z"); | |
| 1202 | }); | |
| 1203 | ||
| 1204 | it("falls back to sendStartedAt when only the session status is stale-running", () => { | |
| 1205 | 1123 | expect( |
| 1206 | 1124 | deriveActiveWorkStartedAt( |
| 1207 | 1125 | latestTurn, |
| ... | ... | @@ -1211,7 +1129,7 @@ describe("deriveActiveWorkStartedAt", () => { |
| 1211 | 1129 | }, |
| 1212 | 1130 | "2026-02-27T21:11:00.000Z", |
| 1213 | 1131 | ), |
| 1214 | ).toBe("2026-02-27T21:11:00.000Z"); | |
| 1132 | ).toBe("2026-02-27T21:10:00.000Z"); | |
| 1215 | 1133 | }); |
| 1216 | 1134 | |
| 1217 | 1135 | it("falls back to sendStartedAt once the latest turn is settled", () => { |
apps/web/src/session-logic.ts+3-18| ... | ... | @@ -128,30 +128,15 @@ export function formatElapsed(startIso: string, endIso: string | undefined): str |
| 128 | 128 | type LatestTurnTiming = Pick<OrchestrationLatestTurn, "turnId" | "startedAt" | "completedAt">; |
| 129 | 129 | type SessionActivityState = Pick<ThreadSession, "orchestrationStatus" | "activeTurnId">; |
| 130 | 130 | |
| 131 | export 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 | ||
| 148 | 131 | export function isLatestTurnSettled( |
| 149 | 132 | latestTurn: LatestTurnTiming | null, |
| 150 | 133 | session: SessionActivityState | null, |
| 151 | 134 | ): boolean { |
| 152 | 135 | if (!latestTurn?.startedAt) return false; |
| 153 | 136 | if (!latestTurn.completedAt) return false; |
| 154 | return !isSessionActivelyRunningTurn(latestTurn, session); | |
| 137 | if (!session) return true; | |
| 138 | if (session.orchestrationStatus === "running") return false; | |
| 139 | return true; | |
| 155 | 140 | } |
| 156 | 141 | |
| 157 | 142 | export function deriveActiveWorkStartedAt( |