Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
609 changes: 395 additions & 214 deletions apps/web/src/components/ChatView.tsx

Large diffs are not rendered by default.

40 changes: 40 additions & 0 deletions apps/web/src/lib/terminalOpenReservations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vite-plus/test";

import {
isTerminalOpenReservationActive,
releaseTerminalOpen,
reserveTerminalOpen,
reservedTerminalOpenIds,
} from "./terminalOpenReservations";

describe("terminalOpenReservations", () => {
it("releases ids after an open request settles", () => {
const reservation = reserveTerminalOpen("thread-a", "term-2");
expect(reservedTerminalOpenIds("thread-a")).toEqual(["term-2"]);

releaseTerminalOpen("thread-a", "term-2", reservation);
expect(reservedTerminalOpenIds("thread-a")).toEqual([]);
});

it("does not let a stale failure release a newer reservation", () => {
const stale = reserveTerminalOpen("thread-a", "term-2");
const current = reserveTerminalOpen("thread-a", "term-2");

releaseTerminalOpen("thread-a", "term-2", stale);
expect(isTerminalOpenReservationActive("thread-a", "term-2", current)).toBe(true);
expect(reservedTerminalOpenIds("thread-a")).toEqual(["term-2"]);

releaseTerminalOpen("thread-a", "term-2", current);
expect(reservedTerminalOpenIds("thread-a")).toEqual([]);
});

it("scopes reservations per thread and tolerates unknown releases", () => {
const reservation = reserveTerminalOpen("thread-b", "term-2");
releaseTerminalOpen("thread-a", "term-2");
releaseTerminalOpen("thread-b", "never-reserved");

expect(reservedTerminalOpenIds("thread-a")).toEqual([]);
expect(reservedTerminalOpenIds("thread-b")).toEqual(["term-2"]);
releaseTerminalOpen("thread-b", "term-2", reservation);
});
});
47 changes: 47 additions & 0 deletions apps/web/src/lib/terminalOpenReservations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* Terminal ids with an open request still in flight, keyed by thread.
*
* Reservations are deliberately scoped to the request lifetime. They protect
* optimistic failure handlers from racing a new allocation without becoming a
* history of every terminal id that has ever existed.
*/
const inFlightOpenIdsByThreadKey = new Map<string, Map<string, symbol>>();

export type TerminalOpenReservation = symbol;

export function reserveTerminalOpen(
threadKey: string,
terminalId: string,
): TerminalOpenReservation {
const ids = inFlightOpenIdsByThreadKey.get(threadKey) ?? new Map<string, symbol>();
const reservation = Symbol(terminalId);
ids.set(terminalId, reservation);
inFlightOpenIdsByThreadKey.set(threadKey, ids);
return reservation;
}

export function isTerminalOpenReservationActive(
threadKey: string,
terminalId: string,
reservation: TerminalOpenReservation,
): boolean {
return inFlightOpenIdsByThreadKey.get(threadKey)?.get(terminalId) === reservation;
}

export function releaseTerminalOpen(
threadKey: string,
terminalId: string,
reservation?: TerminalOpenReservation,
): void {
const ids = inFlightOpenIdsByThreadKey.get(threadKey);
if (!ids) return;
if (reservation !== undefined && ids.get(terminalId) !== reservation) return;
ids.delete(terminalId);
if (ids.size === 0) {
inFlightOpenIdsByThreadKey.delete(threadKey);
}
}

export function reservedTerminalOpenIds(threadKey: string): string[] {
return [...(inFlightOpenIdsByThreadKey.get(threadKey)?.keys() ?? [])];
}
121 changes: 121 additions & 0 deletions apps/web/src/lib/terminalPendingPanelCloses.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { beforeEach, describe, expect, it, vi } from "vite-plus/test";

import {
clearPendingPanelClose,
clearPendingPanelCloses,
pendingPanelCloseVersion,
recordPendingPanelClose,
resolvePendingPanelCloses,
subscribePendingPanelCloses,
} from "./terminalPendingPanelCloses";

const snapshot = {
surfaceId: "terminal:term-1" as const,
resourceId: "term-1",
surfaceIndex: 0,
terminalIds: ["term-1", "term-2", "term-3"],
splitDirection: "vertical" as const,
};

beforeEach(() => {
clearPendingPanelCloses("thread-a");
clearPendingPanelCloses("thread-b");
});

describe("terminalPendingPanelCloses", () => {
it("restores a surviving close only after the settle grace", () => {
recordPendingPanelClose("thread-a", "term-2", snapshot, 1_000);

expect(resolvePendingPanelCloses("thread-a", ["term-1", "term-2"], 2_000)).toEqual([]);
expect(resolvePendingPanelCloses("thread-a", ["term-1", "term-2"], 3_000)).toEqual([
{ terminalId: "term-2", snapshot },
]);
expect(resolvePendingPanelCloses("thread-a", ["term-1", "term-2"], 3_000)).toEqual([]);
});

it("drops a snapshot when the server confirms the close", () => {
recordPendingPanelClose("thread-a", "term-2", snapshot, 1_000);

expect(resolvePendingPanelCloses("thread-a", ["term-1"], 3_000)).toEqual([]);
expect(resolvePendingPanelCloses("thread-a", ["term-1", "term-2"], 3_000)).toEqual([]);
});

it("does not treat an unloaded empty list as authoritative", () => {
recordPendingPanelClose("thread-a", "term-2", snapshot, 1_000);

expect(resolvePendingPanelCloses("thread-a", [], 3_000)).toEqual([]);
expect(resolvePendingPanelCloses("thread-a", ["term-1", "term-2"], 3_000)).toEqual([
{ terminalId: "term-2", snapshot },
]);
});

it("treats an authoritative empty list as a confirmed close", () => {
recordPendingPanelClose("thread-a", "term-2", snapshot, 1_000);

expect(resolvePendingPanelCloses("thread-a", [], 3_000, true)).toEqual([]);
expect(resolvePendingPanelCloses("thread-a", ["term-2"], 3_000, true)).toEqual([]);
});

it("restores nested closes in reverse order and allows id reuse to cancel a stale snapshot", () => {
recordPendingPanelClose("thread-a", "term-2", snapshot, 1_000);
recordPendingPanelClose(
"thread-a",
"term-3",
{ ...snapshot, terminalIds: ["term-1", "term-3"] },
1_000,
);

expect(
resolvePendingPanelCloses("thread-a", ["term-1", "term-2", "term-3"], 3_000, true).map(
(entry) => entry.terminalId,
),
).toEqual(["term-3", "term-2"]);

recordPendingPanelClose("thread-a", "term-2", snapshot, 1_000);
clearPendingPanelClose("thread-a", "term-2");
expect(resolvePendingPanelCloses("thread-a", ["term-2"], 3_000, true)).toEqual([]);
});

it("notifies subscribers when a close becomes eligible", () => {
vi.useFakeTimers();
try {
let notifications = 0;
const unsubscribe = subscribePendingPanelCloses(() => {
notifications += 1;
});
const versionBefore = pendingPanelCloseVersion();

recordPendingPanelClose("thread-a", "term-2", snapshot);
expect(notifications).toBe(0);
vi.advanceTimersByTime(1_500);
expect(notifications).toBe(1);
expect(pendingPanelCloseVersion()).toBeGreaterThan(versionBefore);
unsubscribe();
} finally {
vi.useRealTimers();
}
});

it("schedules the earliest interrupted close when closes overlap", () => {
vi.useFakeTimers();
try {
let notifications = 0;
const unsubscribe = subscribePendingPanelCloses(() => {
notifications += 1;
});

vi.setSystemTime(1_000);
recordPendingPanelClose("thread-a", "term-2", snapshot);
vi.advanceTimersByTime(1_000);
recordPendingPanelClose("thread-a", "term-3", snapshot);

vi.advanceTimersByTime(499);
expect(notifications).toBe(0);
vi.advanceTimersByTime(1);
expect(notifications).toBe(1);
unsubscribe();
} finally {
vi.useRealTimers();
}
});
});
144 changes: 144 additions & 0 deletions apps/web/src/lib/terminalPendingPanelCloses.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import type { TerminalSurfaceSnapshot } from "../rightPanelStore";

/**
* Right-panel closes whose outcome the server has not confirmed yet.
*
* An interrupted close is not evidence that the session survived or ended.
* Holding the pre-close snapshot until fresh metadata settles the outcome
* prevents both a dead pane being resurrected and a live pane being adopted by
* the drawer in the wrong layout.
*/
interface PendingPanelClose {
readonly snapshot: TerminalSurfaceSnapshot;
readonly readyAt: number;
}

const SETTLE_GRACE_MS = 1_500;
const pendingByThreadKey = new Map<string, Map<string, PendingPanelClose>>();
const timersByThreadKey = new Map<string, ReturnType<typeof setTimeout>>();

let version = 0;
const listeners = new Set<() => void>();

function notifyPendingPanelCloses(): void {
version += 1;
listeners.forEach((listener) => listener());
}

function clearTimer(threadKey: string): void {
const timer = timersByThreadKey.get(threadKey);
if (timer === undefined) return;
clearTimeout(timer);
timersByThreadKey.delete(threadKey);
}

function earliestReadyAt(pending: Map<string, PendingPanelClose>): number {
let readyAt = Number.POSITIVE_INFINITY;
for (const entry of pending.values()) {
readyAt = Math.min(readyAt, entry.readyAt);
}
return readyAt;
}

function scheduleNotification(
threadKey: string,
pending: Map<string, PendingPanelClose>,
delayMs?: number,
): void {
clearTimer(threadKey);
const nextReadyAt = earliestReadyAt(pending);
timersByThreadKey.set(
threadKey,
setTimeout(
() => {
timersByThreadKey.delete(threadKey);
if (pendingByThreadKey.get(threadKey) === pending) {
notifyPendingPanelCloses();
if (pending.size > 0) {
const nextReadyAt = earliestReadyAt(pending);
if (nextReadyAt > Date.now()) {
scheduleNotification(threadKey, pending, nextReadyAt - Date.now());
}
}
}
},
delayMs ?? Math.max(0, nextReadyAt - Date.now()),
),
);
}

export function subscribePendingPanelCloses(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}

export function pendingPanelCloseVersion(): number {
return version;
}

export function recordPendingPanelClose(
threadKey: string,
terminalId: string,
snapshot: TerminalSurfaceSnapshot,
now: number = Date.now(),
): void {
const pending = pendingByThreadKey.get(threadKey) ?? new Map<string, PendingPanelClose>();
pending.set(terminalId, { snapshot, readyAt: now + SETTLE_GRACE_MS });
pendingByThreadKey.set(threadKey, pending);
scheduleNotification(threadKey, pending);
}

/**
* A newly opened session with the same id supersedes an old interrupted close.
* Do not let the old snapshot restore over the new session later.
*/
export function clearPendingPanelClose(threadKey: string, terminalId: string): void {
const pending = pendingByThreadKey.get(threadKey);
if (!pending?.delete(terminalId)) return;
if (pending.size === 0) {
pendingByThreadKey.delete(threadKey);
clearTimer(threadKey);
return;
}
scheduleNotification(threadKey, pending);
}

/**
* Settles eligible entries against authoritative server metadata. Surviving
* sessions are returned in reverse close order so nested split closes restore
* their original pane order. Sessions absent from metadata are confirmed
* closed and are discarded.
*/
export function resolvePendingPanelCloses(
threadKey: string,
serverTerminalIds: readonly string[],
now: number = Date.now(),
listLoaded: boolean = serverTerminalIds.length > 0,
): Array<{ terminalId: string; snapshot: TerminalSurfaceSnapshot }> {
const pending = pendingByThreadKey.get(threadKey);
if (!pending || !listLoaded) return [];

const serverIds = new Set(serverTerminalIds);
const survived: Array<{ terminalId: string; snapshot: TerminalSurfaceSnapshot }> = [];
for (const [terminalId, entry] of pending) {
if (entry.readyAt > now) continue;
pending.delete(terminalId);
if (serverIds.has(terminalId)) {
survived.push({ terminalId, snapshot: entry.snapshot });
}
}
if (pending.size === 0) {
pendingByThreadKey.delete(threadKey);
clearTimer(threadKey);
} else {
scheduleNotification(threadKey, pending);
}
return survived.toReversed();
}

export function clearPendingPanelCloses(threadKey: string): void {
pendingByThreadKey.delete(threadKey);
clearTimer(threadKey);
}
Loading
Loading