(sid: string, status: 'idle' | 'aborted')
| 2238 | }); |
| 2239 | |
| 2240 | function onSessionIdle(sid: string, status: 'idle' | 'aborted'): void { |
| 2241 | // The turn finished — this session no longer has a prompt in flight. |
| 2242 | inFlightPromptSessions.delete(sid); |
| 2243 | rawState.sendingBySession = { ...rawState.sendingBySession, [sid]: false }; |
| 2244 | // Drop any cached prompt_id so a later skill activation (which has no |
| 2245 | // prompt_id) doesn't accidentally reuse this stale id for :abort. |
| 2246 | if (rawState.promptIdBySession[sid] !== undefined) { |
| 2247 | const next = { ...rawState.promptIdBySession }; |
| 2248 | delete next[sid]; |
| 2249 | rawState.promptIdBySession = next; |
| 2250 | } |
| 2251 | |
| 2252 | // For the session on screen, refresh git status (edits the agent just made) |
| 2253 | // and runtime status (model/context usage may have changed this turn). |
| 2254 | if (sid === rawState.activeSessionId) { |
| 2255 | appearance.resetFastMoon(); |
| 2256 | void workspaceState.loadGitStatus(sid); |
| 2257 | void refreshSessionStatus(sid); |
| 2258 | } else if (status === 'idle') { |
| 2259 | // A background session finished a turn the user hasn't seen — light up its |
| 2260 | // unread dot until they open it. Aborted (cancelled/failed) turns are |
| 2261 | // excluded on purpose: there is no fresh result to read, and counting them |
| 2262 | // is what made the sidebar fill with stale unreads after a refresh. |
| 2263 | rawState.unreadBySession = { ...rawState.unreadBySession, [sid]: true }; |
| 2264 | saveUnread({ [sid]: true }); |
| 2265 | } |
| 2266 | |
| 2267 | // Browser notification when the user isn't watching this session. |
| 2268 | notification.maybeNotifyCompletion(sid, { |
| 2269 | isActiveAndVisible: |
| 2270 | sid === rawState.activeSessionId && |
| 2271 | typeof document !== 'undefined' && |
| 2272 | document.visibilityState === 'visible', |
| 2273 | sessionTitle: rawState.sessions.find((s) => s.id === sid)?.title ?? '', |
| 2274 | onClick: () => { |
| 2275 | void workspaceState.selectSession(sid); |
| 2276 | }, |
| 2277 | }); |
| 2278 | |
| 2279 | // Completion sound — only for real completions (aborted/cancelled turns stay |
| 2280 | // silent). Plays regardless of visibility so it also reaches a backgrounded tab. |
| 2281 | if (status === 'idle') { |
| 2282 | sound.maybePlayCompletionSound(); |
| 2283 | } |
| 2284 | |
| 2285 | const queue = rawState.queuedBySession[sid] ?? []; |
| 2286 | if (queue.length === 0) return; |
| 2287 | |
| 2288 | const [next, ...rest] = queue; |
| 2289 | rawState.queuedBySession = { ...rawState.queuedBySession, [sid]: rest }; |
| 2290 | // Flush the first queued message; on failure put it back at the head so a |
| 2291 | // transient error doesn't silently drop the prompt. |
| 2292 | if (next !== undefined) { |
| 2293 | void workspaceState.submitPromptInternal(sid, next.text, next.attachments).then((ok) => { |
| 2294 | if (!ok) { |
| 2295 | const current = rawState.queuedBySession[sid] ?? []; |
| 2296 | rawState.queuedBySession = { |
| 2297 | ...rawState.queuedBySession, |
no test coverage detected