| 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,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]); |
| 2318 | 2323 | ||
| 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"; |
| 2534 | 2539 | ||
| 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]); |
| 2544 | 2549 | ||
| 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; |
| 2762 | 2767 | ||
| 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 | ); |
| 2798 | 2803 | ||
| 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 | : null | 4388 | : null |
| 4384 | } | 4389 | } |
| 4385 | isRunning={phase === "running"} | 4390 | isRunning={isTurnRunning} |
| 4386 | showPlanFollowUpPrompt={ | 4391 | showPlanFollowUpPrompt={ |
| 4387 | pendingUserInputs.length === 0 && showPlanFollowUpPrompt | 4392 | 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"; |
| 25 | 26 | ||
| ... | @@ -1072,15 +1073,30 @@ describe("isLatestTurnSettled", () => { | ... | @@ -1072,15 +1073,30 @@ describe("isLatestTurnSettled", () => { |
| 1072 | } as const; | 1073 | } as const; |
| 1073 | 1074 | ||
| 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 | }); |
| 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 | 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 | }); |
| 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 | describe("deriveActiveWorkStartedAt", () => { | 1181 | describe("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 | }); |
| 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 | 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 |
| 128 | type LatestTurnTiming = Pick<OrchestrationLatestTurn, "turnId" | "startedAt" | "completedAt">; | 128 | type LatestTurnTiming = Pick<OrchestrationLatestTurn, "turnId" | "startedAt" | "completedAt">; |
| 129 | type SessionActivityState = Pick<ThreadSession, "orchestrationStatus" | "activeTurnId">; | 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 | export function isLatestTurnSettled( | 148 | export 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 | } |
| 141 | 156 | ||
| 142 | export function deriveActiveWorkStartedAt( | 157 | export function deriveActiveWorkStartedAt( |