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