diff --git a/src/client/src/components/ExtensionDialogCard.test.ts b/src/client/src/components/ExtensionDialogCard.test.ts
index e0af15e..87f1117 100644
--- a/src/client/src/components/ExtensionDialogCard.test.ts
+++ b/src/client/src/components/ExtensionDialogCard.test.ts
@@ -141,13 +141,25 @@ describe("extension-dialog-card countdown", () => {
vi.setSystemTime(new Date("2026-07-27T10:00:00.000Z"));
const card = await mountOpenDialog(openDialog({ timeoutAt: "2026-07-27T10:01:30.000Z" }));
const root = renderRoot(card);
- const status = requiredElement(root.querySelector("[role='status']"), "countdown status");
+ const countdown = requiredElement(root.querySelector(".countdown"), "countdown");
- expect(status.textContent).toBe("Auto-cancels in 1m 30s");
+ expect(countdown.textContent).toBe("Auto-cancels in 1m 30s");
await vi.advanceTimersByTimeAsync(30_000);
await card.updateComplete;
- expect(status.textContent).toBe("Auto-cancels in 1m 0s");
+ expect(countdown.textContent).toBe("Auto-cancels in 1m 0s");
+ });
+
+ it("is decorative: no live region announcing every second", async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-07-27T10:00:00.000Z"));
+ const card = await mountOpenDialog(openDialog({ timeoutAt: "2026-07-27T10:01:30.000Z" }));
+ const root = renderRoot(card);
+
+ // A ticking live region would queue a screen-reader announcement per
+ // second; the daemon-owned dialog.closed event is the real signal.
+ expect(requiredElement(root.querySelector(".countdown"), "countdown").getAttribute("role")).toBeNull();
+ expect(root.querySelector("[aria-live]")).toBeNull();
});
it("renders no countdown when the dialog waits forever", async () => {
@@ -155,7 +167,7 @@ describe("extension-dialog-card countdown", () => {
const card = await mountOpenDialog(openDialog());
const root = renderRoot(card);
- expect(root.querySelector("[role='status']")).toBeNull();
+ expect(root.querySelector(".countdown")).toBeNull();
});
it("stops ticking once the dialog closes", async () => {
@@ -219,6 +231,11 @@ describe("extensionDialogCountdownText", () => {
expect(extensionDialogCountdownText("2026-07-27T11:02:00.000Z", now)).toBe("Auto-cancels in 1h 2m");
});
+ it("never rounds the minutes up to 60 near an hour boundary", () => {
+ expect(extensionDialogCountdownText("2026-07-27T11:59:55.000Z", now)).toBe("Auto-cancels in 1h 59m");
+ expect(extensionDialogCountdownText("2026-07-27T12:59:40.000Z", now)).toBe("Auto-cancels in 2h 59m");
+ });
+
it("stays display-only once the deadline has passed", () => {
expect(extensionDialogCountdownText("2026-07-27T09:59:59.000Z", now)).toBe("Auto-cancel imminent");
});
diff --git a/src/client/src/components/ExtensionDialogCard.ts b/src/client/src/components/ExtensionDialogCard.ts
index 2b861c2..fb51096 100644
--- a/src/client/src/components/ExtensionDialogCard.ts
+++ b/src/client/src/components/ExtensionDialogCard.ts
@@ -58,7 +58,8 @@ export function extensionDialogCountdownText(timeoutAt: string | undefined, nowM
const seconds = Math.ceil(remainingMs / 1000);
if (seconds >= 3600) {
const hours = Math.floor(seconds / 3600);
- const minutes = Math.round((seconds % 3600) / 60);
+ // Floor, not round: rounding yields "1h 60m" in the last half-minute of an hour.
+ const minutes = Math.floor((seconds % 3600) / 60);
return `Auto-cancels in ${String(hours)}h ${String(minutes)}m`;
}
if (seconds >= 60) {
@@ -129,7 +130,10 @@ export class ExtensionDialogCard extends LitElement {
${dialog.title}
${countdown === undefined
? null
- : html``}
+ // Decorative only — no live region: a polite region would queue one
+ // announcement per second. The daemon-owned dialog.closed event is
+ // the real signal, and the settled card announces the outcome.
+ : html``}
${this.renderOpenBody(dialog)}
diff --git a/src/client/src/controllers/sessionController.startupDialogs.test.ts b/src/client/src/controllers/sessionController.startupDialogs.test.ts
index e533596..3b116bf 100644
--- a/src/client/src/controllers/sessionController.startupDialogs.test.ts
+++ b/src/client/src/controllers/sessionController.startupDialogs.test.ts
@@ -298,4 +298,68 @@ describe("SessionController session_start dialog startup reachability", () => {
resolveBackendSession(harness);
await start;
});
+
+ it("drops a mid-startup status snapshot that lands after the readiness swap", async () => {
+ const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } };
+ const resyncRequest = deferred();
+ let resyncIssued = false;
+ const harness = pendingStartController(state, {
+ status: (session) => {
+ // The first backend status call is the subscribe-time resync; hold it
+ // until after the swap. Later calls (the readiness join) answer fresh.
+ if (sessionLookupId(session) === BACKEND_SESSION_ID && !resyncIssued) {
+ resyncIssued = true;
+ return resyncRequest.promise;
+ }
+ return Promise.resolve(status(sessionLookupId(session)));
+ },
+ });
+ const { start, tempId } = beginPendingStart(harness);
+ reportBackendSessionId(harness, tempId);
+ harness.socket.emit({ type: "dialog.opened", dialog: dialog("dialog-1") });
+ expect(harness.state.current.pendingDialogs).toEqual([dialog("dialog-1")]);
+
+ resolveBackendSession(harness);
+ await start;
+ await vi.waitFor(() => { expect(harness.state.current.selectedSession?.id).toBe(BACKEND_SESSION_ID); });
+
+ // The stale snapshot — issued before the swap and claiming dialog-1 is
+ // still open — must not clobber the real session's fresher state.
+ resyncRequest.resolve(statusWithDialogs(BACKEND_SESSION_ID, [dialog("dialog-1")]));
+ await Promise.resolve();
+ await Promise.resolve();
+
+ expect(harness.state.current.pendingDialogs).toEqual([]);
+ expect(harness.state.current.sessionStatuses[BACKEND_SESSION_ID]?.pendingDialogs ?? []).toEqual([]);
+ });
+
+ it("drops the dead card, closes the socket, and ignores late frames when the create fails mid-startup", async () => {
+ const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } };
+ let answerCalled = false;
+ const harness = pendingStartController(state, {
+ answerDialog: () => {
+ answerCalled = true;
+ return Promise.resolve(closeResponse(status(BACKEND_SESSION_ID)));
+ },
+ });
+ const closeSpy = vi.spyOn(harness.socket, "close");
+ const { start, tempId } = beginPendingStart(harness);
+ reportBackendSessionId(harness, tempId);
+ harness.socket.emit({ type: "dialog.opened", dialog: dialog("dialog-1") });
+ expect(harness.state.current.pendingDialogs).toEqual([dialog("dialog-1")]);
+
+ closeSpy.mockClear();
+ harness.startRequest.reject(new Error("create exploded"));
+ await start;
+
+ expect(harness.state.current.error).toBe("Failed to start session: create exploded");
+ expect(harness.state.current.pendingDialogs).toEqual([]);
+ expect(closeSpy).toHaveBeenCalled();
+
+ // Late frames from the dead session are dropped, and no answer can leave.
+ harness.socket.emit({ type: "dialog.opened", dialog: dialog("dialog-2") });
+ expect(harness.state.current.pendingDialogs).toEqual([]);
+ await harness.controller.answerDialog("dialog-2", true);
+ expect(answerCalled).toBe(false);
+ });
});
diff --git a/src/client/src/controllers/sessionController.ts b/src/client/src/controllers/sessionController.ts
index 127c9c8..362e219 100644
--- a/src/client/src/controllers/sessionController.ts
+++ b/src/client/src/controllers/sessionController.ts
@@ -1214,9 +1214,15 @@ export class SessionController {
const pending = this.pendingSessionStarts.get(tempId);
if (pending === undefined) return;
this.pendingSessionStarts.delete(tempId);
+ const wasDiscarded = pending.discarded;
+ // The pending start is dead: stop routing its dialog frames (a card on the
+ // failed row could never be answered) and drop the early-subscribed socket
+ // so it stops reconnecting against a session that may not exist.
+ pending.discarded = true;
+ if (this.getState().selectedSession?.id === tempId) this.socket.close();
const releasedCreatedSessions = this.takeSuppressedCreatedSessionsFor(pending.cwd, pending.machineId);
const isCurrentPendingStart = this.isCurrentPendingStart(pending);
- if (pending.discarded || !isCurrentPendingStart) {
+ if (wasDiscarded || !isCurrentPendingStart) {
if (isCurrentPendingStart) this.applyReleasedCreatedSessions(releasedCreatedSessions, pending.machineId);
return;
}
@@ -1228,6 +1234,9 @@ export class SessionController {
sessions: hasPendingRow ? state.sessions : [pending.session, ...state.sessions],
sessionActivities: { ...state.sessionActivities, [tempId]: activity },
activity: state.selectedSession?.id === tempId ? activity : state.activity,
+ // Open cards on the failed row are dead: the create is gone, so no
+ // answer could ever reach the daemon. Settled outcomes stay as history.
+ ...(state.selectedSession?.id === tempId ? { pendingDialogs: [] } : {}),
error: `Failed to start session: ${message}`,
});
this.applyReleasedCreatedSessions(releasedCreatedSessions, pending.machineId);
@@ -1585,8 +1594,11 @@ export class SessionController {
if (backendSessionId === undefined) return;
void this.api.status({ id: backendSessionId, cwd: pending.cwd }, pending.machineId).then(
(status) => {
- this.applyStatus(status);
+ // Guard before applying: this unordered snapshot can land after the
+ // readiness swap made the real session selected, and a stale replace
+ // must not clobber the socket's fresher dialog state.
if (pending.discarded || this.getState().selectedSession?.id !== pending.tempId) return;
+ this.applyStatus(status);
const state = this.getState();
const knownIds = new Set([
...state.pendingDialogs.map((pendingDialog) => pendingDialog.dialogId),
diff --git a/src/server/sessions/piSessionService.extensionDialogs.test.ts b/src/server/sessions/piSessionService.extensionDialogs.test.ts
index 47e805d..d682550 100644
--- a/src/server/sessions/piSessionService.extensionDialogs.test.ts
+++ b/src/server/sessions/piSessionService.extensionDialogs.test.ts
@@ -597,4 +597,37 @@ describe("PiSessionService session_start dialog startup reachability", () => {
expect(confirmAnswers).toEqual([false]);
await service.dispose();
});
+
+ it("dispose settles a startup-parked dialog instead of blocking behind its timeout", async () => {
+ const { service, store, events, confirmAnswers } = startupDialogService();
+ // The open flow registers in pendingSessionOpens, which dispose awaits:
+ // without settling the dialog first, disposal would ride its timeout.
+ const opening = service.messages(sessionRef(ACTIVE_SESSION_ID));
+ await parkOnStartupDialog(store);
+
+ await service.dispose();
+
+ expect(confirmAnswers).toEqual([false]);
+ const closedEvents = dialogEvents(events).filter(({ event }) => event.type === "dialog.closed");
+ expect(closedEvents).toHaveLength(1);
+ expect(closedEvents[0]?.event).toMatchObject({ dialogId: "dialog-1", reason: "session-ended" });
+ // The released open completed inside dispose's awaited window; the late
+ // messages read neither hangs nor rejects the test run.
+ await Promise.allSettled([opening]);
+ });
+
+ it("closing a session whose open is parked on a session_start dialog settles the dialog first", async () => {
+ const { service, store, events, confirmAnswers } = startupDialogService();
+ const opening = service.messages(sessionRef(ACTIVE_SESSION_ID));
+ await parkOnStartupDialog(store);
+
+ await service.stop(ACTIVE_SESSION_ID);
+
+ expect(confirmAnswers).toEqual([false]);
+ const closedEvents = dialogEvents(events).filter(({ event }) => event.type === "dialog.closed");
+ expect(closedEvents).toHaveLength(1);
+ expect(closedEvents[0]?.event).toMatchObject({ dialogId: "dialog-1", reason: "session-ended" });
+ await Promise.allSettled([opening]);
+ await service.dispose();
+ });
});
diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts
index 01a3ef3..f8f7552 100644
--- a/src/server/sessions/piSessionService.ts
+++ b/src/server/sessions/piSessionService.ts
@@ -1005,6 +1005,9 @@ export class PiSessionService implements SessionRouteService {
this.clearUnreadPublicationRetry();
clearInterval(this.heartbeat);
this.clearCompactionDrainTimers();
+ // Same startup-park hazard as closeActive(): settle `session_start` dialogs
+ // of sessions still binding extensions before awaiting their pending opens.
+ for (const sessionId of this.startupSessions.keys()) this.endSessionExtensionDialogs(sessionId);
const pendingOpens = this.pendingSessionOpenPromises();
if (pendingOpens.length > 0) await Promise.allSettled(pendingOpens);
const activeSessions = Array.from(new Set(this.active.values()));
@@ -2592,6 +2595,10 @@ export class PiSessionService implements SessionRouteService {
}
private async closeActive(sessionId: string, notificationPolicy: NotificationClosePolicy = CLEAR_RUNTIME_NOTIFICATIONS): Promise {
+ // A session whose open is parked on a `session_start` dialog holds its
+ // pending open until the dialog settles; settle it first so closing cannot
+ // block behind the dialog timeout (which `0` makes infinite).
+ if (this.startupSessions.has(sessionId)) this.endSessionExtensionDialogs(sessionId);
const pendingOpens = this.pendingSessionOpenPromises(sessionId);
if (pendingOpens.length > 0) await Promise.allSettled(pendingOpens);
const active = this.active.get(sessionId);