diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts
index 66b4b48a183dea35afc030fd0111367728144bdc..00cbe008c4a291c709f65c77f513e7d74538ff30 100644
--- a/apps/desktop/src/ipc/channels.ts
+++ b/apps/desktop/src/ipc/channels.ts
@@ -68,6 +68,7 @@ export const SET_WSL_BACKEND_ENABLED_CHANNEL = "desktop:set-wsl-backend-enabled"
export const SET_WSL_DISTRO_CHANNEL = "desktop:set-wsl-distro";
export const SET_WSL_ONLY_CHANNEL = "desktop:set-wsl-only";
export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled";
+export const PREVIEW_SET_FORWARDED_SHORTCUTS_CHANNEL = "desktop:preview-set-forwarded-shortcuts";
export const PREVIEW_CREATE_TAB_CHANNEL = "desktop:preview-create-tab";
export const PREVIEW_CLOSE_TAB_CHANNEL = "desktop:preview-close-tab";
export const PREVIEW_REGISTER_WEBVIEW_CHANNEL = "desktop:preview-register-webview";
diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts
index 89c518580f719d65012230c641041df55bee3e47..e9b795a23261271f9b2806df126a755d33e0ff4b 100644
--- a/apps/desktop/src/ipc/methods/preview.ts
+++ b/apps/desktop/src/ipc/methods/preview.ts
@@ -20,6 +20,8 @@ import {
PreviewAnnotationSubmissionResultSchema,
DEFAULT_BROWSER_PROFILE_ID,
INCOGNITO_BROWSER_PROFILE_ID,
+ PreviewForwardedShortcut,
+ MAX_KEYBINDINGS_COUNT,
} from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Schema from "effect/Schema";
@@ -53,6 +55,16 @@ export const installPreviewEventForwarding = Effect.fn(
);
});
+export const setForwardedShortcuts = DesktopIpc.makeIpcMethod({
+ channel: IpcChannels.PREVIEW_SET_FORWARDED_SHORTCUTS_CHANNEL,
+ payload: Schema.Array(PreviewForwardedShortcut).check(Schema.isMaxLength(MAX_KEYBINDINGS_COUNT)),
+ result: Schema.Void,
+ handler: Effect.fn("desktop.ipc.preview.setForwardedShortcuts")(function* (shortcuts) {
+ const manager = yield* PreviewManager.PreviewManager;
+ yield* manager.setForwardedShortcuts(shortcuts);
+ }),
+});
+
export const createTab = DesktopIpc.makeIpcMethod({
channel: IpcChannels.PREVIEW_CREATE_TAB_CHANNEL,
payload: DesktopPreviewCreateTabInputSchema,
@@ -406,6 +418,7 @@ export const saveRecording = DesktopIpc.makeIpcMethod({
});
export const methods = [
+ setForwardedShortcuts,
createTab,
closeTab,
registerWebview,
diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts
index 5ca77997af40008c456c587e32cc05728d364f71..d6e6470483fc30bf9a5e04ae38f3d4b7cad43fa7 100644
--- a/apps/desktop/src/preload.ts
+++ b/apps/desktop/src/preload.ts
@@ -299,6 +299,8 @@ contextBridge.exposeInMainWorld("desktopBridge", {
},
},
preview: {
+ setForwardedShortcuts: (shortcuts) =>
+ ipcRenderer.invoke(IpcChannels.PREVIEW_SET_FORWARDED_SHORTCUTS_CHANNEL, shortcuts),
createTab: (tabId, defaults) =>
ipcRenderer.invoke(IpcChannels.PREVIEW_CREATE_TAB_CHANNEL, {
tabId,
diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts
index 7d227a9e8e42dd97fb3a123fd3472da9b81c18dd..9b8bf7c50de614ae1d31c6343dba9dccd6e1725c 100644
--- a/apps/desktop/src/preview/Manager.test.ts
+++ b/apps/desktop/src/preview/Manager.test.ts
@@ -6,6 +6,7 @@ import type {
DesktopPreviewRecordingInputEvent,
} from "@t3tools/contracts";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
+import { parseKeybindingShortcut } from "@t3tools/shared/keybindings";
import * as Cause from "effect/Cause";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
@@ -269,32 +270,34 @@ const layerFileSystem = FileSystem.layerNoop({
}),
});
-const layer = PreviewManager.layer.pipe(
- Layer.provideMerge(
- Layer.succeed(DesktopRendererHistory.DesktopRendererHistory, {
- register: () => Effect.void,
- recordMetrics: () => Effect.void,
- shutdown: Effect.void,
- }),
- ),
- Layer.provideMerge(DesktopBrowserHost.layer),
- Layer.provideMerge(layerBrowserSession),
- Layer.provideMerge(layerEnvironment),
- Layer.provideMerge(layerFileSystem),
- Layer.provideMerge(Path.layer),
- Layer.provideMerge(NodeCrypto.layer),
- Layer.provideMerge(Layer.succeed(HostProcessPlatform, "darwin")),
-);
+const managerLayer = (platform: NodeJS.Platform = "darwin") =>
+ PreviewManager.layer.pipe(
+ Layer.provideMerge(
+ Layer.succeed(DesktopRendererHistory.DesktopRendererHistory, {
+ register: () => Effect.void,
+ recordMetrics: () => Effect.void,
+ shutdown: Effect.void,
+ }),
+ ),
+ Layer.provideMerge(DesktopBrowserHost.layer),
+ Layer.provideMerge(layerBrowserSession),
+ Layer.provideMerge(layerEnvironment),
+ Layer.provideMerge(layerFileSystem),
+ Layer.provideMerge(Path.layer),
+ Layer.provideMerge(NodeCrypto.layer),
+ Layer.provideMerge(Layer.succeed(HostProcessPlatform, platform)),
+ );
const withManager = (
use: (
manager: PreviewManager.PreviewManager["Service"],
) => Effect.Effect,
+ platform: NodeJS.Platform = "darwin",
) =>
Effect.gen(function* () {
const manager = yield* PreviewManager.PreviewManager;
return yield* use(manager);
- }).pipe(Effect.provide(layer), Effect.scoped);
+ }).pipe(Effect.provide(managerLayer(platform)), Effect.scoped);
interface TestCapturedPreviewImage {
readonly toJPEG: () => Buffer;
@@ -574,7 +577,7 @@ describe("PreviewManager", () => {
Effect.gen(function* () {
const preview = makeFaviconWebContents();
const sendInputEvent = vi.fn();
- const hostWebContents = { sendInputEvent };
+ const hostWebContents = { sendInputEvent, isDestroyed: () => false };
Object.assign(preview.webContents, { hostWebContents });
fromId.mockReturnValue(preview.webContents);
getFocusedWebContents.mockReturnValue(preview.webContents as never);
@@ -628,6 +631,108 @@ describe("PreviewManager", () => {
),
);
+ effectIt.effect.each([
+ ["mod+shift+t", "view.reopenClosed"],
+ ["ctrl+alt+u", "sidebar.toggle"],
+ ] as const)(
+ "forwards only the configured command from the focused guest: %s",
+ ([chord, command]) =>
+ withManager((manager) =>
+ Effect.gen(function* () {
+ const preview = makeFaviconWebContents();
+ const send = vi.fn();
+ Object.assign(preview.webContents, {
+ hostWebContents: { isDestroyed: () => false, send },
+ });
+ fromId.mockReturnValue(preview.webContents);
+ getFocusedWebContents.mockReturnValue(preview.webContents as never);
+ yield* manager.createTab("tab_reopen");
+ yield* manager.registerWebview("tab_reopen", 42);
+ const shortcut = parseKeybindingShortcut(chord)!;
+ yield* manager.setForwardedShortcuts([{ command, shortcut }]);
+ const beforeInput = preview.listeners.get("before-input-event")!;
+ const input = {
+ type: "keyDown",
+ key: shortcut.key.toUpperCase(),
+ meta: shortcut.metaKey || shortcut.modKey,
+ control: shortcut.ctrlKey,
+ shift: shortcut.shiftKey,
+ alt: shortcut.altKey,
+ };
+ const preventDefault = vi.fn();
+ beforeInput({ preventDefault } as never, input as never);
+ expect(preventDefault).toHaveBeenCalledOnce();
+ expect(send).toHaveBeenCalledExactlyOnceWith("desktop:menu-action", command);
+ for (const overrides of [
+ { isAutoRepeat: true },
+ { type: "keyUp" },
+ { shift: !input.shift },
+ { key: "x" },
+ ]) {
+ preventDefault.mockClear();
+ beforeInput({ preventDefault } as never, { ...input, ...overrides } as never);
+ expect(preventDefault).toHaveBeenCalledTimes("isAutoRepeat" in overrides ? 1 : 0);
+ expect(send).toHaveBeenCalledOnce();
+ }
+ getFocusedWebContents.mockReturnValue(null);
+ preventDefault.mockClear();
+ beforeInput({ preventDefault } as never, input as never);
+ expect(preventDefault).not.toHaveBeenCalled();
+ expect(send).toHaveBeenCalledOnce();
+ getFocusedWebContents.mockReturnValue(preview.webContents as never);
+ yield* manager.setForwardedShortcuts([]);
+ beforeInput({ preventDefault } as never, input as never);
+ expect(preventDefault).not.toHaveBeenCalled();
+ expect(send).toHaveBeenCalledOnce();
+ }),
+ ),
+ );
+
+ effectIt.effect.each([
+ ["linux", "ctrl+alt+[", "[", "BracketLeft", false],
+ ["win32", "ctrl+alt+8", "[", "Digit8", false],
+ ["win32", "ctrl+alt+k", "K", "KeyK", false],
+ ["linux", "ctrl+alt+`", "Dead", "Backquote", false],
+ ["darwin", "ctrl+alt+[", "[", "BracketLeft", true],
+ ["linux", "ctrl+alt+f7", "F7", "F7", true],
+ ] as const)(
+ "handles ambiguous preview chords on %s: %s",
+ ([platform, chord, key, code, expected]) =>
+ withManager(
+ (manager) =>
+ Effect.gen(function* () {
+ const preview = makeFaviconWebContents();
+ const send = vi.fn();
+ Object.assign(preview.webContents, {
+ hostWebContents: { isDestroyed: () => false, send },
+ });
+ fromId.mockReturnValue(preview.webContents);
+ getFocusedWebContents.mockReturnValue(preview.webContents as never);
+ yield* manager.createTab("tab_alt_graph");
+ yield* manager.registerWebview("tab_alt_graph", 42);
+ yield* manager.setForwardedShortcuts([
+ { command: "view.reopenClosed", shortcut: parseKeybindingShortcut(chord)! },
+ ]);
+ const preventDefault = vi.fn();
+ preview.listeners.get("before-input-event")!(
+ { preventDefault } as never,
+ {
+ type: "keyDown",
+ key,
+ code,
+ meta: false,
+ control: true,
+ shift: false,
+ alt: true,
+ } as never,
+ );
+ expect(preventDefault).toHaveBeenCalledTimes(expected ? 1 : 0);
+ expect(send).toHaveBeenCalledTimes(expected ? 1 : 0);
+ }),
+ platform,
+ ),
+ );
+
effectIt.effect("preserves focused browser editing in tabs and sign-in popups", () =>
withManager((manager) =>
Effect.gen(function* () {
@@ -2716,7 +2821,7 @@ describe("PreviewManager", () => {
expect(yield* nextType).toBe("attached");
yield* manager.closeTab("tab_1");
expect(yield* nextType).toBe("detached");
- }).pipe(Effect.provide(layer), Effect.scoped),
+ }).pipe(Effect.provide(managerLayer()), Effect.scoped),
);
effectIt.effect("stops capture retries when the tab swaps during the retry delay", () =>
diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts
index 9c797d2a4daa2a7d4bfb4d0f002aa0f105cd26a8..74b391a025b06b2012c5234632e2ab9eedd122ae 100644
--- a/apps/desktop/src/preview/Manager.ts
+++ b/apps/desktop/src/preview/Manager.ts
@@ -22,8 +22,10 @@ import type {
DesktopPreviewRecordingInputEvent,
DesktopPreviewScreenshotArtifact,
DesktopPreviewTabDefaults,
+ PreviewForwardedShortcut,
} from "@t3tools/contracts";
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
+import { matchesKeybindingShortcut } from "@t3tools/shared/keybindings";
import { normalizePreviewUrl } from "@t3tools/shared/preview";
import {
BrowserWindow,
@@ -58,7 +60,7 @@ import * as SynchronizedRef from "effect/SynchronizedRef";
import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts";
-import { PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL } from "../ipc/channels.ts";
+import { MENU_ACTION_CHANNEL, PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL } from "../ipc/channels.ts";
import * as DesktopBrowserHost from "./DesktopBrowserHost.ts";
import * as BrowserSession from "./BrowserSession.ts";
import {
@@ -555,6 +557,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
const runFork = Effect.runForkWith(context);
const resolvedArtifactDirectory = path.resolve(artifactDirectory);
+ let forwardedShortcuts: ReadonlyArray = [];
const annotationThemeRef = yield* Ref.make(DEFAULT_ANNOTATION_THEME);
const mainWindowRef = yield* Ref.make>(Option.none());
const tabsRef = yield* SynchronizedRef.make>(new Map());
@@ -1520,6 +1523,38 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
};
const beforeInput = (event: Electron.Event, input: Electron.Input): void => {
syncMenuShortcuts(wc, input);
+ const host = wc.hostWebContents;
+ const forwarded =
+ input.type === "keyDown" &&
+ !input.isComposing &&
+ host &&
+ !host.isDestroyed() &&
+ webContents.getFocusedWebContents() === wc &&
+ !(
+ hostPlatform !== "darwin" &&
+ input.control &&
+ input.alt &&
+ /^(?:[^a-zA-Z0-9]|Dead)$/u.test(input.key)
+ ) &&
+ forwardedShortcuts.find(({ shortcut }) =>
+ matchesKeybindingShortcut(
+ {
+ key: input.key,
+ code: input.code,
+ metaKey: input.meta,
+ ctrlKey: input.control,
+ shiftKey: input.shift,
+ altKey: input.alt,
+ },
+ shortcut,
+ hostPlatform === "darwin" ? "MacIntel" : hostPlatform,
+ ),
+ );
+ if (forwarded && host) {
+ event.preventDefault();
+ if (!input.isAutoRepeat) host.send(MENU_ACTION_CHANNEL, forwarded.command);
+ return;
+ }
if (isPreviewRefreshShortcut(input)) {
event.preventDefault();
runFork(
@@ -3324,6 +3359,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
setAudioMuted,
setColorScheme,
setMainWindow,
+ setForwardedShortcuts: (shortcuts: ReadonlyArray) =>
+ Effect.sync(() => {
+ forwardedShortcuts = shortcuts;
+ }),
startRecording,
closePictureInPicture,
stopRecording,
@@ -3484,6 +3523,9 @@ export class PreviewManager extends Context.Service<
PreviewManager,
{
readonly setMainWindow: (window: BrowserWindow) => Effect.Effect;
+ readonly setForwardedShortcuts: (
+ shortcuts: ReadonlyArray,
+ ) => Effect.Effect;
readonly getBrowserSession: (
scope?: string,
persistent?: boolean,
@@ -3595,6 +3637,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {
return PreviewManager.of({
setMainWindow: operations.setMainWindow,
+ setForwardedShortcuts: operations.setForwardedShortcuts,
getBrowserSession: Effect.fn("PreviewManager.getBrowserSession")(
function* (scope, persistent, namespace) {
const session = yield* browserSession
diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts
index b0385cb029b57a632c6047e75cadf45fb260ac5b..df5175ef65a30fcac449b6b3f279edb1c098420c 100644
--- a/apps/server/src/keybindings.test.ts
+++ b/apps/server/src/keybindings.test.ts
@@ -257,7 +257,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
Effect.gen(function* () {
const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig;
yield* writeKeybindingsConfig(keybindingsConfigPath, [
- { key: "mod+shift+t", command: "terminal.toggle" },
+ { key: "mod+shift+y", command: "terminal.toggle" },
{ key: "mod+shift+r", command: "script.run-tests.run" },
]);
@@ -271,7 +271,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => {
const persistedToggle = byCommand.get("terminal.toggle");
assert.isNotNull(persistedToggle);
- assert.equal(persistedToggle?.key, "mod+shift+t");
+ assert.equal(persistedToggle?.key, "mod+shift+y");
assert.isFalse(
persisted.some((entry) => entry.command === "terminal.toggle" && entry.key === "mod+j"),
);
diff --git a/apps/web/src/closedViewStore.test.ts b/apps/web/src/closedViewStore.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..6bb4631bf5725ccf2d1993f6958652be234ab636
--- /dev/null
+++ b/apps/web/src/closedViewStore.test.ts
@@ -0,0 +1,220 @@
+import { scopeThreadRef } from "@t3tools/client-runtime/environment";
+import {
+ type EnvironmentId,
+ INCOGNITO_BROWSER_PROFILE_ID,
+ type PreviewSessionSnapshot,
+ ThreadId,
+} from "@t3tools/contracts";
+import { beforeEach, describe, expect, it } from "vite-plus/test";
+
+import { type ClosedViewEntry, useClosedViewStore } from "./closedViewStore";
+
+const refA = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-A"));
+const refB = scopeThreadRef("env-2" as EnvironmentId, ThreadId.make("thread-A"));
+const diff = (threadRef: typeof refA) =>
+ ({ kind: "panel-tab", threadRef, surface: { kind: "diff", id: "diff" } }) as const;
+
+beforeEach(() => {
+ useClosedViewStore.setState({ entries: [] });
+});
+
+const snapshot: PreviewSessionSnapshot = {
+ threadId: refA.threadId,
+ tabId: "private-tab",
+ profileId: INCOGNITO_BROWSER_PROFILE_ID,
+ navStatus: { _tag: "Success", url: "https://private.example", title: "Private page" },
+ canGoBack: false,
+ canGoForward: false,
+ updatedAt: "2026-10-06T17:00:00.000Z",
+};
+
+describe("closedViewStore", () => {
+ it.each([null, {}, { entries: null }, { entries: {} }, { entries: "invalid" }])(
+ "rehydrates malformed older history %j as empty history",
+ async (state) => {
+ useClosedViewStore.getState().remember(diff(refA));
+ const { storage, name } = useClosedViewStore.persist.getOptions();
+ await storage!.setItem(name!, {
+ state: state as { entries: ClosedViewEntry[] },
+ version: 0,
+ });
+ await useClosedViewStore.persist.rehydrate();
+ expect(useClosedViewStore.persist.hasHydrated()).toBe(true);
+ expect(useClosedViewStore.getState().entries).toEqual([]);
+ expect(await storage!.getItem(name!)).toEqual({ state: { entries: [] }, version: 2 });
+ },
+ );
+
+ it.each([
+ { id: "bad", kind: "browser", threadRef: refA, snapshot: {} },
+ {
+ id: "bad",
+ kind: "browser",
+ threadRef: refA,
+ snapshot: { ...snapshot, profileId: "default", navStatus: null },
+ },
+ { id: "bad", kind: "panel-tab", surface: { kind: "diff", id: "diff" } },
+ { id: "bad", kind: "panel-tab", threadRef: refA },
+ { id: "bad", kind: "panel-tab", threadRef: {}, surface: { kind: "diff", id: "diff" } },
+ { id: "bad", kind: "panel-tab", threadRef: refA, surface: {} },
+ { id: "bad", kind: "panel-tab", threadRef: refA, surface: { kind: "file", id: "file:x" } },
+ {
+ id: "bad",
+ kind: "panel-tab",
+ threadRef: refA,
+ surface: { kind: "device", id: "device:x", target: {} },
+ },
+ {
+ id: "bad",
+ kind: "panel-tab",
+ threadRef: refA,
+ surface: { kind: "pull-request", id: "pull-request:x", repository: "owner/repo", number: 1 },
+ },
+ ])("discards incomplete saved views %j while retaining valid history", async (entry) => {
+ const id = useClosedViewStore.getState().remember(diff(refA));
+ const { storage, name } = useClosedViewStore.persist.getOptions();
+ await storage!.setItem(name!, {
+ state: { entries: [entry as ClosedViewEntry, ...useClosedViewStore.getState().entries] },
+ version: 1,
+ });
+ await useClosedViewStore.persist.rehydrate();
+ expect(useClosedViewStore.persist.hasHydrated()).toBe(true);
+ expect(useClosedViewStore.getState().entries).toEqual([{ ...diff(refA), id }]);
+ expect(() => useClosedViewStore.getState().remember(diff(refB))).not.toThrow();
+ });
+
+ it("preserves every supported saved panel type and its restore data", async () => {
+ const surfaces = [
+ { kind: "diff", id: "diff" },
+ { kind: "files", id: "files" },
+ { kind: "pull-requests", id: "pull-requests" },
+ { kind: "preview", id: "browser:new", resourceId: null },
+ { kind: "preview", id: "browser:saved", resourceId: "saved" },
+ { kind: "device", id: "device" },
+ {
+ kind: "device",
+ id: "device:phone",
+ title: "My phone",
+ target: { hostId: "mac", deviceId: "phone", platform: "ios", name: "iPhone" },
+ },
+ { kind: "file", id: "file:x", relativePath: "x", revealLine: 12, revealRequestId: 1 },
+ {
+ kind: "file",
+ id: "attachment:a",
+ relativePath: "report.pdf",
+ revealLine: null,
+ revealRequestId: 0,
+ attachment: {
+ type: "file",
+ id: "a",
+ name: "report.pdf",
+ mimeType: "application/pdf",
+ sizeBytes: 1,
+ },
+ },
+ {
+ kind: "pull-request",
+ id: "pull-request:x",
+ projectId: "project",
+ repository: "owner/repo",
+ number: 1,
+ environmentId: "env-1",
+ host: "github.com",
+ url: "https://github.com/owner/repo/pull/1",
+ },
+ ];
+ const entries = surfaces.map((surface, index) => ({
+ id: String(index),
+ kind: "panel-tab",
+ threadRef: refA,
+ surface,
+ })) as ClosedViewEntry[];
+ const { storage, name } = useClosedViewStore.persist.getOptions();
+ await storage!.setItem(name!, { state: { entries }, version: 0 });
+ await useClosedViewStore.persist.rehydrate();
+ expect(useClosedViewStore.persist.hasHydrated()).toBe(true);
+ expect(useClosedViewStore.getState().entries).toEqual(entries);
+ });
+
+ it("keeps private tabs available in memory without saving their metadata", async () => {
+ const store = useClosedViewStore.getState();
+ const publicId = store.remember(diff(refA));
+ const privateId = store.remember({ kind: "browser", threadRef: refA, snapshot });
+ expect(useClosedViewStore.getState().entries.map((entry) => entry.id)).toEqual([
+ privateId,
+ publicId,
+ ]);
+ const { storage, name } = useClosedViewStore.persist.getOptions();
+ const saved = JSON.stringify(await storage!.getItem(name!));
+ expect(saved).not.toContain("private.example");
+ expect(saved).not.toContain("Private page");
+ await useClosedViewStore.persist.rehydrate();
+ expect(useClosedViewStore.getState().entries.map((entry) => entry.id)).toEqual([publicId]);
+ });
+
+ it("removes private metadata and malformed entries from history saved by earlier versions", async () => {
+ const store = useClosedViewStore.getState();
+ const publicId = store.remember(diff(refA));
+ const browserId = store.remember({
+ kind: "browser",
+ threadRef: refA,
+ snapshot: {
+ ...snapshot,
+ tabId: "public-tab",
+ profileId: "default",
+ navStatus: { _tag: "Success", url: "https://public.example", title: "Public page" },
+ },
+ });
+ store.remember({ kind: "browser", threadRef: refA, snapshot });
+ const { storage, name } = useClosedViewStore.persist.getOptions();
+ await storage!.setItem(name!, {
+ state: {
+ entries: [
+ null,
+ {},
+ { kind: "browser" },
+ { kind: "browser", snapshot: null },
+ ...useClosedViewStore.getState().entries,
+ ] as ClosedViewEntry[],
+ },
+ version: 0,
+ });
+ await useClosedViewStore.persist.rehydrate();
+ expect(useClosedViewStore.persist.hasHydrated()).toBe(true);
+ expect(useClosedViewStore.getState().entries.map((entry) => entry.id)).toEqual([
+ browserId,
+ publicId,
+ ]);
+ const saved = JSON.stringify(await storage!.getItem(name!));
+ expect(saved).not.toContain("private.example");
+ expect(saved).not.toContain("Private page");
+ });
+
+ it("keeps newest first, moves a re-closed tab to the front, and caps at 20", () => {
+ const store = useClosedViewStore.getState();
+ store.remember(diff(refA));
+ store.remember(diff(refB));
+ store.remember(diff(refA));
+ expect(useClosedViewStore.getState().entries).toMatchObject([
+ { threadRef: refA },
+ { threadRef: refB },
+ ]);
+
+ for (let index = 0; index < 24; index++) {
+ store.remember({
+ kind: "panel-tab",
+ threadRef: refA,
+ surface: {
+ kind: "file",
+ id: `file:${index}`,
+ relativePath: `${index}.ts`,
+ revealLine: null,
+ revealRequestId: 0,
+ },
+ });
+ }
+ const entries = useClosedViewStore.getState().entries;
+ expect(entries).toHaveLength(20);
+ expect(entries[0]).toMatchObject({ surface: { id: "file:23" } });
+ });
+});
diff --git a/apps/web/src/closedViewStore.ts b/apps/web/src/closedViewStore.ts
new file mode 100644
index 0000000000000000000000000000000000000000..76fee83a83d088590e94ba17cf3b270dd0d9e497
--- /dev/null
+++ b/apps/web/src/closedViewStore.ts
@@ -0,0 +1,143 @@
+import { scopedThreadKey } from "@t3tools/client-runtime/environment";
+import {
+ ChatFileAttachment,
+ INCOGNITO_BROWSER_PROFILE_ID,
+ PreviewSessionSnapshot,
+ ScopedThreadRef,
+} from "@t3tools/contracts";
+import * as Schema from "effect/Schema";
+import { create } from "zustand";
+import { createJSONStorage, persist } from "zustand/middleware";
+
+import { resolveStorage } from "./lib/storage";
+import { randomUUID } from "./lib/utils";
+import { type RightPanelSurface } from "./rightPanelStore";
+
+export type ClosedView =
+ | {
+ kind: "panel-tab";
+ threadRef: ScopedThreadRef;
+ surface: Exclude;
+ }
+ | { kind: "browser"; threadRef: ScopedThreadRef; snapshot: PreviewSessionSnapshot };
+
+export type ClosedViewEntry = ClosedView & { id: string };
+
+interface ClosedViewStoreState {
+ entries: ClosedViewEntry[];
+ remember: (view: ClosedView) => string;
+ defer: (id: string) => void;
+ remove: (id: string) => void;
+}
+
+const sameTarget = (entry: ClosedViewEntry, view: ClosedView): boolean => {
+ if (
+ entry.kind !== view.kind ||
+ scopedThreadKey(entry.threadRef) !== scopedThreadKey(view.threadRef)
+ ) {
+ return false;
+ }
+ switch (entry.kind) {
+ case "panel-tab":
+ return view.kind === "panel-tab" && entry.surface.id === view.surface.id;
+ case "browser":
+ return view.kind === "browser" && entry.snapshot.tabId === view.snapshot.tabId;
+ }
+};
+
+const isPersistentView = (entry: ClosedViewEntry) =>
+ entry.kind !== "browser" || entry.snapshot.profileId !== INCOGNITO_BROWSER_PROFILE_ID;
+
+const isThreadRef = Schema.is(ScopedThreadRef);
+const isSnapshot = Schema.is(PreviewSessionSnapshot);
+const isAttachment = Schema.is(ChatFileAttachment);
+
+const isClosedViewEntry = (entry: unknown): entry is ClosedViewEntry => {
+ const view = entry as ClosedViewEntry | null;
+ if (!view || typeof view.id !== "string" || !isThreadRef(view.threadRef)) return false;
+ if (view.kind === "browser") return isSnapshot(view.snapshot);
+ if (view.kind !== "panel-tab") return false;
+ const surface = view.surface;
+ if (!surface || typeof surface.id !== "string") return false;
+ switch (surface.kind) {
+ case "diff":
+ case "files":
+ case "pull-requests":
+ return surface.id === surface.kind;
+ case "preview":
+ return surface.resourceId === null || typeof surface.resourceId === "string";
+ case "file":
+ return (
+ typeof surface.relativePath === "string" &&
+ (surface.revealLine === null || Number.isSafeInteger(surface.revealLine)) &&
+ Number.isSafeInteger(surface.revealRequestId) &&
+ (surface.attachment === undefined || isAttachment(surface.attachment))
+ );
+ case "device":
+ return (
+ (surface.title === undefined || typeof surface.title === "string") &&
+ (surface.target === undefined ||
+ (surface.target !== null &&
+ typeof surface.target.hostId === "string" &&
+ typeof surface.target.deviceId === "string" &&
+ typeof surface.target.name === "string" &&
+ (surface.target.platform === "ios" || surface.target.platform === "android")))
+ );
+ case "pull-request":
+ return (
+ typeof surface.projectId === "string" &&
+ typeof surface.repository === "string" &&
+ Number.isSafeInteger(surface.number) &&
+ surface.number > 0 &&
+ (surface.environmentId === undefined || typeof surface.environmentId === "string") &&
+ (surface.host === undefined || typeof surface.host === "string") &&
+ (surface.url === undefined || typeof surface.url === "string")
+ );
+ default:
+ return false;
+ }
+};
+
+export const useClosedViewStore = create()(
+ persist(
+ (set) => ({
+ entries: [],
+ remember: (view) => {
+ const id = randomUUID();
+ set((state) => ({
+ entries: [
+ { ...view, id },
+ ...state.entries.filter((entry) => !sameTarget(entry, view)),
+ ].slice(0, 20),
+ }));
+ return id;
+ },
+ defer: (id) =>
+ set((state) => {
+ const entry = state.entries.find((entry) => entry.id === id);
+ return entry
+ ? { entries: [...state.entries.filter((entry) => entry.id !== id), entry] }
+ : state;
+ }),
+ remove: (id) =>
+ set((state) => ({ entries: state.entries.filter((entry) => entry.id !== id) })),
+ }),
+ {
+ name: "t3code:closed-views:v2",
+ storage: createJSONStorage(() =>
+ resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined),
+ ),
+ version: 2,
+ migrate: (persisted) => {
+ const entries = (persisted as Partial> | null)
+ ?.entries;
+ return {
+ entries: Array.isArray(entries)
+ ? entries.filter(isClosedViewEntry).filter(isPersistentView)
+ : [],
+ };
+ },
+ partialize: ({ entries }) => ({ entries: entries.filter(isPersistentView) }),
+ },
+ ),
+);
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index 9f6256ff7d763fb2dee49477498ab30dbfc5a905..068b69ce0632890cb1d01e0e44b657fc9a22a3da 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -5947,9 +5947,16 @@ export default function ChatView(props: ChatViewProps) {
const finishRightPanelSurfaceClose = useCallback(
(surfaces: readonly RightPanelSurface[]) => {
if (!activeThreadRef) return;
- cleanupRightPanelSurfaces(surfaces);
const store = useRightPanelStore.getState();
- for (const surface of surfaces) {
+ const activeId = selectThreadRightPanelState(
+ store.byThreadKey,
+ activeThreadRef,
+ ).activeSurfaceId;
+ const ordered = surfaces.toSorted(
+ (left, right) => Number(left.id === activeId) - Number(right.id === activeId),
+ );
+ for (const surface of ordered) {
+ cleanupRightPanelSurfaces([surface]);
store.closeSurface(activeThreadRef, surface.id);
}
syncActivePreviewSurface();
diff --git a/apps/web/src/components/ReopenClosedViewShortcut.test.tsx b/apps/web/src/components/ReopenClosedViewShortcut.test.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..e43fd4069b691ba2056ff6cab3c53704fbd1fcd2
--- /dev/null
+++ b/apps/web/src/components/ReopenClosedViewShortcut.test.tsx
@@ -0,0 +1,325 @@
+import type { ResolvedKeybindingsConfig, ScopedThreadRef } from "@t3tools/contracts";
+import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
+import * as Cause from "effect/Cause";
+import { AsyncResult } from "effect/reactivity";
+import { act } from "react";
+import { create, type ReactTestRenderer } from "react-test-renderer";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
+
+const state = vi.hoisted(() => ({
+ keybindings: [] as ResolvedKeybindingsConfig,
+ params: {} as Record,
+ paletteOpen: false,
+ workspaceAvailable: true,
+ navigate: vi.fn(),
+ openPreview: vi.fn(),
+ setShortcuts: vi.fn(async () => undefined),
+ toast: vi.fn(),
+}));
+
+vi.mock("./preview/openPreviewSession", () => ({ openPreviewSession: state.openPreview }));
+vi.mock("@effect/atom-react", () => ({ useAtomValue: () => state.keybindings }));
+vi.mock("@tanstack/react-router", () => ({
+ useNavigate: () => state.navigate,
+ useParams: () => state.params,
+}));
+vi.mock("../state/server", () => ({ primaryServerKeybindingsAtom: {} }));
+vi.mock("../state/preview", () => ({ previewEnvironment: { open: state.openPreview } }));
+vi.mock("../state/use-atom-command", () => ({ useAtomCommand: (command: unknown) => command }));
+vi.mock("../state/shell", () => ({ environmentShell: { stateValueAtom: () => ({}) } }));
+vi.mock("../connection/catalog", () => ({ environmentCatalog: { catalogValueAtom: "catalog" } }));
+vi.mock("../rpc/atomRegistry", () => ({
+ appAtomRegistry: {
+ get: (atom: unknown) =>
+ atom === "catalog"
+ ? { isReady: true, entries: new Map([["remote", {}]]) }
+ : { status: "live" },
+ },
+}));
+vi.mock("../state/entities", () => ({
+ readThreadShell: () => ({ projectId: "project-1", worktreePath: null }),
+ readProject: () => (state.workspaceAvailable ? { workspaceRoot: "/work/project" } : null),
+}));
+vi.mock("../composerDraftStore", () => {
+ const store = {
+ getDraftSession: () => null,
+ getDraftThreadByRef: () => null,
+ getDraftIdByRef: () => null,
+ };
+ return {
+ useComposerDraftStore: Object.assign(
+ (select: (value: typeof store) => unknown) => select(store),
+ { getState: () => store },
+ ),
+ };
+});
+vi.mock("../commandPaletteBus", () => ({ isCommandPaletteOpen: () => state.paletteOpen }));
+vi.mock("../lib/editableFocus", () => ({ isEditableFocused: () => false }));
+vi.mock("../lib/previewFocus", () => ({ isPreviewFocused: () => false }));
+vi.mock("../lib/terminalFocus", () => ({ isTerminalFocused: () => false }));
+vi.mock("../modelPickerVisibility", () => ({ isModelPickerOpen: () => false }));
+vi.mock("./ui/toast", () => ({ toastManager: { add: state.toast } }));
+
+import { useClosedViewStore } from "../closedViewStore";
+import { selectThreadRightPanelState, useRightPanelStore } from "../rightPanelStore";
+import { useTerminalUiStateStore } from "../terminalUiStateStore";
+import { ReopenClosedViewShortcut } from "./ReopenClosedViewShortcut";
+
+const ref = { environmentId: "remote", threadId: "thread-1" } as ScopedThreadRef;
+const snapshot = {
+ threadId: ref.threadId,
+ tabId: "closed-tab",
+ navStatus: { _tag: "Success", url: "https://example.com", title: "Example" },
+ canGoBack: false,
+ canGoForward: false,
+ updatedAt: "2026-09-22T12:00:00.000Z",
+} as const;
+class TestElement extends EventTarget {
+ closest(selector: string) {
+ return selector === "[data-keybinding-capture]" ? this : null;
+ }
+}
+let renderer: ReactTestRenderer | undefined;
+let menuAction: ((action: string) => void) | undefined;
+
+async function render() {
+ await act(() => {
+ renderer = create();
+ });
+}
+
+function press(overrides: Record = {}) {
+ const event = Object.assign(new Event("keydown", { cancelable: true }), {
+ key: "T",
+ ctrlKey: true,
+ metaKey: false,
+ shiftKey: true,
+ altKey: false,
+ repeat: false,
+ ...overrides,
+ });
+ window.dispatchEvent(event);
+ return event;
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ Object.assign(state, {
+ keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
+ params: {},
+ paletteOpen: false,
+ workspaceAvailable: true,
+ });
+ state.navigate.mockResolvedValue(undefined);
+ state.openPreview.mockReset();
+ useClosedViewStore.setState({ entries: [] });
+ useRightPanelStore.setState({ byThreadKey: {}, userActionRevisionByThreadKey: {} });
+ useTerminalUiStateStore.setState({
+ terminalUiStateByThreadKey: {},
+ suppressedTerminalIdsByThreadKey: {},
+ });
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ vi.stubGlobal("HTMLElement", TestElement);
+ vi.stubGlobal("navigator", { platform: "Linux" });
+ vi.stubGlobal(
+ "window",
+ Object.assign(new EventTarget(), {
+ desktopBridge: {
+ preview: { setForwardedShortcuts: state.setShortcuts },
+ onMenuAction: (listener: typeof menuAction) => {
+ menuAction = listener;
+ return () => {
+ menuAction = undefined;
+ };
+ },
+ },
+ }),
+ );
+});
+
+afterEach(async () => {
+ await act(() => renderer?.unmount());
+ renderer = undefined;
+ vi.unstubAllGlobals();
+});
+
+describe("root reopen shortcut", () => {
+ it("restores a tab from settings to its owning remote thread", async () => {
+ useClosedViewStore
+ .getState()
+ .remember({ kind: "panel-tab", threadRef: ref, surface: { kind: "diff", id: "diff" } });
+ await render();
+ await act(() => {
+ expect(press().defaultPrevented).toBe(true);
+ });
+ expect(
+ selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, ref).activeSurfaceId,
+ ).toBe("diff");
+ expect(state.navigate).toHaveBeenCalledWith({ to: "/$environmentId/$threadId", params: ref });
+ expect(useClosedViewStore.getState().entries).toEqual([]);
+ });
+
+ it("leaves native shortcuts alone without history or while the palette is open", async () => {
+ await render();
+ expect(press().defaultPrevented).toBe(false);
+ await act(() => {
+ useClosedViewStore
+ .getState()
+ .remember({ kind: "panel-tab", threadRef: ref, surface: { kind: "diff", id: "diff" } });
+ });
+ state.paletteOpen = true;
+ expect(press().defaultPrevented).toBe(false);
+ expect(state.navigate).not.toHaveBeenCalled();
+ expect(useClosedViewStore.getState().entries).toHaveLength(1);
+ });
+
+ it("lets the keybinding recorder capture the chord", async () => {
+ useClosedViewStore
+ .getState()
+ .remember({ kind: "panel-tab", threadRef: ref, surface: { kind: "diff", id: "diff" } });
+ await render();
+ const recorder = new TestElement();
+ const event = Object.assign(new Event("keydown", { bubbles: true, cancelable: true }), {
+ key: "T",
+ ctrlKey: true,
+ metaKey: false,
+ shiftKey: true,
+ altKey: false,
+ repeat: false,
+ });
+ Object.defineProperty(event, "target", { value: recorder });
+ window.dispatchEvent(event);
+ expect(event.defaultPrevented).toBe(false);
+ expect(useClosedViewStore.getState().entries).toHaveLength(1);
+ });
+
+ it("serializes deliberate presses while a browser restore waits for RPC", async () => {
+ useClosedViewStore
+ .getState()
+ .remember({ kind: "panel-tab", threadRef: ref, surface: { kind: "files", id: "files" } });
+ useClosedViewStore.getState().remember({ kind: "browser", threadRef: ref, snapshot });
+ let finish!: (value: ReturnType) => void;
+ state.openPreview.mockReturnValue(
+ new Promise((resolve) => {
+ finish = resolve;
+ }),
+ );
+ await render();
+ await act(() => {
+ press();
+ press();
+ });
+ expect(state.openPreview).toHaveBeenCalledTimes(1);
+ expect(state.navigate).not.toHaveBeenCalled();
+ await act(() => {
+ finish(AsyncResult.success({ tabId: "new-tab" }));
+ });
+ expect(state.navigate).toHaveBeenCalledTimes(2);
+ expect(useClosedViewStore.getState().entries).toEqual([]);
+ });
+
+ it("does not create a second browser when navigation fails after restoring it", async () => {
+ const store = useClosedViewStore.getState();
+ const older = store.remember({
+ kind: "panel-tab",
+ threadRef: ref,
+ surface: { kind: "diff", id: "diff" },
+ });
+ store.remember({ kind: "browser", threadRef: ref, snapshot });
+ state.openPreview.mockResolvedValue(AsyncResult.success({ tabId: "new-tab" }));
+ state.navigate.mockRejectedValueOnce(new Error("navigation failed"));
+ await render();
+ await act(() => {
+ press();
+ });
+ expect(
+ selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, ref).activeSurfaceId,
+ ).toBe("browser:new-tab");
+ expect(state.toast).toHaveBeenCalledWith(
+ expect.objectContaining({ title: "Could not reopen view" }),
+ );
+ expect(useClosedViewStore.getState().entries.map((entry) => entry.id)).toEqual([older]);
+ await act(() => {
+ menuAction?.("view.reopenClosed");
+ });
+ expect(state.openPreview).toHaveBeenCalledOnce();
+ expect(
+ selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, ref).activeSurfaceId,
+ ).toBe("diff");
+ expect(useClosedViewStore.getState().entries).toEqual([]);
+ });
+
+ it.each(["files", "file", "browser"] as const)(
+ "keeps a failed %s restore retryable without blocking older history",
+ async (kind) => {
+ const store = useClosedViewStore.getState();
+ const older = store.remember({
+ kind: "panel-tab",
+ threadRef: ref,
+ surface: { kind: "diff", id: "diff" },
+ });
+ const failed = store.remember(
+ kind === "browser"
+ ? { kind: "browser", threadRef: ref, snapshot }
+ : {
+ kind: "panel-tab",
+ threadRef: ref,
+ surface:
+ kind === "files"
+ ? { kind: "files", id: "files" }
+ : {
+ kind: "file",
+ id: "file:src/app.ts",
+ relativePath: "src/app.ts",
+ revealLine: null,
+ revealRequestId: 0,
+ },
+ },
+ );
+ state.workspaceAvailable = false;
+ state.openPreview.mockResolvedValue(AsyncResult.failure(Cause.fail(new Error("offline"))));
+ await render();
+ await act(() => {
+ press();
+ });
+ expect(useClosedViewStore.getState().entries.map((entry) => entry.id)).toEqual([
+ older,
+ failed,
+ ]);
+ expect(state.navigate).not.toHaveBeenCalled();
+ await act(() => {
+ menuAction?.("view.reopenClosed");
+ });
+ expect(
+ selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, ref).activeSurfaceId,
+ ).toBe("diff");
+ expect(useClosedViewStore.getState().entries.map((entry) => entry.id)).toEqual([failed]);
+ state.workspaceAvailable = true;
+ state.openPreview.mockResolvedValue(AsyncResult.success({ ...snapshot, tabId: "new-tab" }));
+ await act(() => {
+ press();
+ });
+ expect(
+ selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, ref).activeSurfaceId,
+ ).toBe(
+ kind === "browser" ? "browser:new-tab" : kind === "file" ? "file:src/app.ts" : "files",
+ );
+ expect(useClosedViewStore.getState().entries).toEqual([]);
+ },
+ );
+
+ it("forwards the chord to a focused desktop browser only while history exists", async () => {
+ useClosedViewStore
+ .getState()
+ .remember({ kind: "panel-tab", threadRef: ref, surface: { kind: "diff", id: "diff" } });
+ await render();
+ expect(state.setShortcuts).toHaveBeenLastCalledWith([
+ expect.objectContaining({ command: "view.reopenClosed" }),
+ ]);
+ await act(() => {
+ menuAction?.("view.reopenClosed");
+ });
+ expect(useClosedViewStore.getState().entries).toEqual([]);
+ expect(state.setShortcuts).toHaveBeenLastCalledWith([]);
+ });
+});
diff --git a/apps/web/src/components/ReopenClosedViewShortcut.tsx b/apps/web/src/components/ReopenClosedViewShortcut.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..d8e3b65a5c9eb2a886ddfffb01191418c22015d6
--- /dev/null
+++ b/apps/web/src/components/ReopenClosedViewShortcut.tsx
@@ -0,0 +1,202 @@
+import { useAtomValue } from "@effect/atom-react";
+import {
+ scopeProjectRef,
+ scopedThreadKey,
+ scopeThreadRef,
+} from "@t3tools/client-runtime/environment";
+import type { ScopedThreadRef } from "@t3tools/contracts";
+import { useNavigate, useParams } from "@tanstack/react-router";
+import { useEffect, useEffectEvent, useRef } from "react";
+
+import { isCommandPaletteOpen } from "../commandPaletteBus";
+import { useClosedViewStore } from "../closedViewStore";
+import { useComposerDraftStore } from "../composerDraftStore";
+import { environmentCatalog } from "../connection/catalog";
+import { effectiveShortcutsForCommand, resolveShortcutCommand } from "../keybindings";
+import { isEditableFocused } from "../lib/editableFocus";
+import { isPreviewFocused } from "../lib/previewFocus";
+import { isTerminalFocused } from "../lib/terminalFocus";
+import { isModelPickerOpen } from "../modelPickerVisibility";
+import {
+ planNextReopen,
+ pullRequestsSearchForRestore,
+ reopenClosedView,
+} from "../reopenClosedView";
+import {
+ PULL_REQUESTS_PANEL_REF,
+ selectActiveRightPanel,
+ selectSelectedRightPanelSurface,
+ selectThreadRightPanelState,
+ useRightPanelStore,
+} from "../rightPanelStore";
+import { appAtomRegistry } from "../rpc/atomRegistry";
+import { readProject, readThreadShell } from "../state/entities";
+import { previewEnvironment } from "../state/preview";
+import { primaryServerKeybindingsAtom } from "../state/server";
+import { environmentShell } from "../state/shell";
+import { useAtomCommand } from "../state/use-atom-command";
+import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore";
+import {
+ buildDraftThreadRouteParams,
+ buildThreadRouteParams,
+ resolveThreadRouteTarget,
+} from "../threadRoutes";
+import { toastManager } from "./ui/toast";
+
+const isGlobalPullRequests = (ref: ScopedThreadRef) =>
+ scopedThreadKey(ref) === scopedThreadKey(PULL_REQUESTS_PANEL_REF);
+
+export function ReopenClosedViewShortcut() {
+ const navigate = useNavigate();
+ const params = useParams({ strict: false });
+ const target = resolveThreadRouteTarget(params);
+ const draft = useComposerDraftStore((state) =>
+ target?.kind === "draft" ? state.getDraftSession(target.draftId) : null,
+ );
+ const threadRef =
+ target?.kind === "server"
+ ? target.threadRef
+ : draft
+ ? (draft.promotedTo ?? scopeThreadRef(draft.environmentId, draft.threadId))
+ : null;
+ const terminalOpen = useTerminalUiStateStore(
+ (state) =>
+ selectThreadTerminalUiState(state.terminalUiStateByThreadKey, threadRef).terminalOpen,
+ );
+ const previewOpen = useRightPanelStore(
+ (state) => selectActiveRightPanel(state.byThreadKey, threadRef) === "preview",
+ );
+ const keybindings = useAtomValue(primaryServerKeybindingsAtom);
+ const hasHistory = useClosedViewStore((state) => state.entries.length > 0);
+ const openPreview = useAtomCommand(previewEnvironment.open);
+ const pending = useRef(Promise.resolve());
+
+ const reopenNext = useEffectEvent(async () => {
+ const catalog = appAtomRegistry.get(environmentCatalog.catalogValueAtom);
+ const drafts = useComposerDraftStore.getState();
+ const panelsByThread = useRightPanelStore.getState().byThreadKey;
+ const { drop, restore } = planNextReopen(useClosedViewStore.getState().entries, (entry) => {
+ const ref = entry.threadRef;
+ const panel = selectThreadRightPanelState(panelsByThread, ref);
+ if (isGlobalPullRequests(ref)) {
+ return {
+ environmentKnown: true,
+ catalogReady: true,
+ ownerExists: true,
+ shellLive: true,
+ panel,
+ };
+ }
+ return {
+ environmentKnown: catalog.entries.has(ref.environmentId),
+ catalogReady: catalog.isReady,
+ ownerExists: readThreadShell(ref) !== null || drafts.getDraftThreadByRef(ref) !== null,
+ shellLive:
+ appAtomRegistry.get(environmentShell.stateValueAtom(ref.environmentId)).status === "live",
+ panel,
+ };
+ });
+ for (const entry of drop) useClosedViewStore.getState().remove(entry.id);
+ if (!restore) return;
+
+ const ref = restore.threadRef;
+ const globalPullRequests = isGlobalPullRequests(ref);
+ const thread = globalPullRequests ? null : readThreadShell(ref);
+ const owner = thread ?? drafts.getDraftThreadByRef(ref);
+ const project = owner ? readProject(scopeProjectRef(ref.environmentId, owner.projectId)) : null;
+ if (!(await reopenClosedView(restore, { openPreview, workspaceAvailable: project !== null }))) {
+ useClosedViewStore.getState().defer(restore.id);
+ return;
+ }
+ useClosedViewStore.getState().remove(restore.id);
+ if (globalPullRequests) {
+ const selected = selectSelectedRightPanelSurface(
+ useRightPanelStore.getState().byThreadKey,
+ ref,
+ );
+ await navigate({
+ to: "/pull-requests",
+ search: (previous) => pullRequestsSearchForRestore(previous, selected),
+ });
+ } else {
+ const draftId = thread === null ? drafts.getDraftIdByRef(ref) : null;
+ if (draftId !== null)
+ await navigate({ to: "/draft/$draftId", params: buildDraftThreadRouteParams(draftId) });
+ else await navigate({ to: "/$environmentId/$threadId", params: buildThreadRouteParams(ref) });
+ }
+ });
+
+ const enqueueReopen = useEffectEvent(() => {
+ pending.current = pending.current
+ .then(() => reopenNext())
+ .catch((error: unknown) => {
+ toastManager.add({
+ type: "error",
+ title: "Could not reopen view",
+ description: error instanceof Error ? error.message : String(error),
+ });
+ });
+ });
+
+ useEffect(() => {
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (
+ event.defaultPrevented ||
+ isCommandPaletteOpen() ||
+ useClosedViewStore.getState().entries.length === 0 ||
+ (event.target instanceof HTMLElement && event.target.closest("[data-keybinding-capture]"))
+ )
+ return;
+ const command = resolveShortcutCommand(event, keybindings, {
+ context: {
+ terminalFocus: isTerminalFocused(),
+ terminalOpen,
+ previewFocus: isPreviewFocused(),
+ previewOpen,
+ editableFocus: isEditableFocused(event.target),
+ modelPickerOpen: isModelPickerOpen(),
+ },
+ });
+ if (command !== "view.reopenClosed") return;
+ event.preventDefault();
+ event.stopPropagation();
+ if (!event.repeat) enqueueReopen();
+ };
+ window.addEventListener("keydown", onKeyDown, true);
+ const unsubscribe = window.desktopBridge?.onMenuAction((action) => {
+ if (action === "view.reopenClosed" && !isCommandPaletteOpen()) enqueueReopen();
+ });
+ return () => {
+ window.removeEventListener("keydown", onKeyDown, true);
+ unsubscribe?.();
+ };
+ }, [keybindings, previewOpen, terminalOpen]);
+
+ useEffect(() => {
+ const preview = window.desktopBridge?.preview;
+ if (!preview?.setForwardedShortcuts) return;
+ void preview
+ .setForwardedShortcuts(
+ hasHistory
+ ? effectiveShortcutsForCommand(keybindings, "view.reopenClosed", {
+ context: {
+ previewFocus: true,
+ previewOpen: true,
+ terminalFocus: false,
+ terminalOpen,
+ editableFocus: false,
+ modelPickerOpen: false,
+ isDesktop: true,
+ isWeb: false,
+ },
+ }).map((shortcut) => ({ command: "view.reopenClosed", shortcut }))
+ : [],
+ )
+ .catch(() => undefined);
+ return () => {
+ void preview.setForwardedShortcuts?.([]).catch(() => undefined);
+ };
+ }, [hasHistory, keybindings, terminalOpen]);
+
+ return null;
+}
diff --git a/apps/web/src/components/preview/closePreviewSession.test.ts b/apps/web/src/components/preview/closePreviewSession.test.ts
index 1df3e22702aa34d05949d1462ff230e3aed9d09d..75c1f19f20d50a9148b1df8619a395a2c727fbf5 100644
--- a/apps/web/src/components/preview/closePreviewSession.test.ts
+++ b/apps/web/src/components/preview/closePreviewSession.test.ts
@@ -13,6 +13,8 @@ import {
resetPreviewStateForTests,
} from "~/previewStateStore";
+import { useClosedViewStore } from "~/closedViewStore";
+
import { closePreviewSession } from "./closePreviewSession";
const threadRef = {
@@ -33,7 +35,10 @@ const snapshot: PreviewSessionSnapshot = {
updatedAt: "2026-06-18T19:00:00.000Z",
};
-beforeEach(resetPreviewStateForTests);
+beforeEach(() => {
+ resetPreviewStateForTests();
+ useClosedViewStore.setState({ entries: [] });
+});
describe("closePreviewSession", () => {
it("suppresses stale server snapshots while the close is in flight", async () => {
@@ -53,17 +58,29 @@ describe("closePreviewSession", () => {
threadRef,
});
+ expect(useClosedViewStore.getState().entries).toEqual([]);
expect(readThreadPreviewState(threadRef).sessions).toEqual({});
applyPreviewServerSnapshot(threadRef, snapshot);
expect(readThreadPreviewState(threadRef).sessions).toEqual({});
finishClose?.();
await closing;
+ expect(useClosedViewStore.getState().entries).toMatchObject([
+ { kind: "browser", threadRef, snapshot },
+ ]);
expect(closePreview).toHaveBeenCalledWith({ threadId: "thread-1", tabId: "tab-1" });
});
- it("restores the last snapshot when the server close fails", async () => {
+ it("restores the last snapshot and keeps full history when the server close fails", async () => {
applyPreviewServerSnapshot(threadRef, snapshot);
+ for (let index = 0; index < 20; index++) {
+ useClosedViewStore.getState().remember({
+ kind: "browser",
+ threadRef,
+ snapshot: { ...snapshot, tabId: `closed-${index}` },
+ });
+ }
+ const history = useClosedViewStore.getState().entries;
const result = await closePreviewSession({
closePreview: async () => AsyncResult.failure(Cause.fail(new Error("close failed"))),
@@ -73,6 +90,7 @@ describe("closePreviewSession", () => {
});
expect(result._tag).toBe("Failure");
+ expect(useClosedViewStore.getState().entries).toEqual(history);
expect(readThreadPreviewState(threadRef).snapshot).toEqual(snapshot);
expect(readThreadPreviewState(threadRef).sessions).toEqual({ [snapshot.tabId]: snapshot });
});
diff --git a/apps/web/src/components/preview/closePreviewSession.ts b/apps/web/src/components/preview/closePreviewSession.ts
index 5073029f6d3cad09e398b6e0a72741c49e9da164..959ed3ce0f08ffbd9bb46332ed3c34a658fc3986 100644
--- a/apps/web/src/components/preview/closePreviewSession.ts
+++ b/apps/web/src/components/preview/closePreviewSession.ts
@@ -6,6 +6,7 @@ import type {
ScopedThreadRef,
} from "@t3tools/contracts";
+import { useClosedViewStore } from "~/closedViewStore";
import { beginPreviewSessionClose, cancelPreviewSessionClose } from "~/previewStateStore";
interface ClosePreviewSessionInput {
@@ -32,6 +33,12 @@ export async function closePreviewSession(
});
if (result._tag === "Failure") {
cancelPreviewSessionClose(input.threadRef, input.snapshot, input.tabId);
+ } else if (input.snapshot) {
+ useClosedViewStore.getState().remember({
+ kind: "browser",
+ threadRef: input.threadRef,
+ snapshot: input.snapshot,
+ });
}
return result;
}
diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts
index 40f24c9bf3bae95330938c8e671db8883352a392..f790f3681316dac94c175ee8a69aaa9f683bc591 100644
--- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts
+++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts
@@ -263,6 +263,7 @@ describe("KeybindingsSettings.logic", () => {
it("formats static and project script command labels", () => {
expect(commandLabel("commandPalette.toggle")).toBe("Command Palette: Toggle");
expect(commandLabel("themeEditor.toggle")).toBe("Theme Editor: Toggle");
+ expect(commandLabel("view.reopenClosed")).toBe("Reopen Closed Tab");
expect(commandLabel("script.setup-db.run")).toBe("Run Script: Setup Db");
});
diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts
index 4897007d8fdacf311841b1a715ebed82c23ba983..cbdd456a0172cdb09fc8ffb49a6989153c32ffdb 100644
--- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts
+++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts
@@ -322,6 +322,7 @@ export function commandLabel(command: KeybindingCommand): string {
if (usageMetric) return `Usage: ${usageMetric.label}`;
const usagePeriod = WINDOW_OPTIONS.find((option) => option.command === command);
if (usagePeriod) return `Usage: Period: ${usagePeriod.label}`;
+ if (command === "view.reopenClosed") return "Reopen Closed Tab";
const raw = String(command);
if (raw.startsWith("script.") && raw.endsWith(".run")) {
return `Run Script: ${titleCaseCommandSegment(raw.slice("script.".length, -".run".length))}`;
diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts
index c7d69443cdffd181a2f79c7985b9776877e18546..58199ddf62892c9ec0748fbd4a668e69a473042a 100644
--- a/apps/web/src/keybindings.test.ts
+++ b/apps/web/src/keybindings.test.ts
@@ -12,6 +12,7 @@ import {
type ResolvedKeybindingsConfig,
} from "@t3tools/contracts";
import {
+ effectiveShortcutsForCommand,
formatShortcutLabel,
isDiffToggleShortcut,
isRichTextBoldShortcut,
@@ -180,6 +181,40 @@ const DEFAULT_BINDINGS = compile([
},
]);
+describe("effectiveShortcutsForCommand", () => {
+ it("passes only effective preview shortcuts to the desktop bridge", () => {
+ const reopen = modShortcut("t", { shiftKey: true });
+ const second = modShortcut("y", { shiftKey: true });
+ const keybindings = compile([
+ { shortcut: reopen, command: "view.reopenClosed" },
+ {
+ shortcut: second,
+ command: "view.reopenClosed",
+ whenAst: whenIdentifier("previewFocus"),
+ },
+ {
+ shortcut: reopen,
+ command: "preview.toggle",
+ whenAst: whenIdentifier("previewFocus"),
+ },
+ ]);
+ assert.deepEqual(
+ effectiveShortcutsForCommand(keybindings, "view.reopenClosed", {
+ platform: "MacIntel",
+ context: { previewFocus: true, previewOpen: true },
+ }),
+ [second],
+ );
+ assert.deepEqual(
+ effectiveShortcutsForCommand(keybindings, "view.reopenClosed", {
+ platform: "MacIntel",
+ context: { previewFocus: false },
+ }),
+ [reopen],
+ );
+ });
+});
+
describe("isTerminalToggleShortcut", () => {
it("matches Cmd+J on macOS", () => {
assert.isTrue(
diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts
index 24a4676050340bc15ebc57cb000dc168cf2820fb..7864eabf755b7d8cb686f7576a6768ff82e1028a 100644
--- a/apps/web/src/keybindings.ts
+++ b/apps/web/src/keybindings.ts
@@ -8,27 +8,19 @@ import {
type ModelPickerJumpKeybindingCommand,
type ThreadJumpKeybindingCommand,
} from "@t3tools/contracts";
+import {
+ isMacPlatform,
+ matchesKeybindingShortcut,
+ matchesKeybindingShortcutModifiers,
+ normalizeEventKey,
+ resolveEventKeys,
+ type ShortcutEventLike,
+ type ShortcutModifierStateLike,
+} from "@t3tools/shared/keybindings";
import { isElectron } from "./env";
-import { isMacPlatform } from "./lib/utils";
-export interface ShortcutEventLike {
- getModifierState?: (key: "AltGraph") => boolean;
- type?: string;
- code?: string;
- key: string;
- repeat?: boolean;
- metaKey: boolean;
- ctrlKey: boolean;
- shiftKey: boolean;
- altKey: boolean;
-}
-
-export interface ShortcutModifierStateLike {
- metaKey: boolean;
- ctrlKey: boolean;
- shiftKey: boolean;
- altKey: boolean;
-}
+export type { ShortcutEventLike, ShortcutModifierStateLike } from "@t3tools/shared/keybindings";
+export { shortcutKeyFromEvent } from "@t3tools/shared/keybindings";
export interface ShortcutMatchContext {
terminalFocus: boolean;
@@ -57,90 +49,6 @@ const TERMINAL_WORD_FORWARD = "\u001bf";
const TERMINAL_LINE_START = "\u0001";
const TERMINAL_LINE_END = "\u0005";
const TERMINAL_DELETE_TO_LINE_START = "\u0015";
-const EVENT_CODE_SHORTCUT_KEYS: Readonly> = {
- Backquote: "`",
- Backslash: "\\",
- BracketLeft: "[",
- BracketRight: "]",
- Comma: ",",
- Digit0: "0",
- Digit1: "1",
- Digit2: "2",
- Digit3: "3",
- Digit4: "4",
- Digit5: "5",
- Digit6: "6",
- Digit7: "7",
- Digit8: "8",
- Digit9: "9",
- Equal: "=",
- Minus: "-",
- Period: ".",
- Quote: "'",
- Semicolon: ";",
- Slash: "/",
-};
-
-function normalizeEventKey(key: string): string {
- const normalized = key.toLowerCase();
- if (normalized === "esc") return "escape";
- return normalized;
-}
-
-export function shortcutKeyFromEvent(event: Pick): string {
- const layoutKey = normalizeEventKey(event.key);
- if (/^[a-z]$/.test(layoutKey)) return layoutKey;
- const physicalKey = event.code ? EVENT_CODE_SHORTCUT_KEYS[event.code] : undefined;
- return physicalKey ?? layoutKey;
-}
-
-function resolveEventKeys(event: ShortcutEventLike): Set {
- const layoutKey = normalizeEventKey(event.key);
- const keys = new Set([layoutKey]);
- // The physical-position fallback exists for layouts that type non-Latin
- // letters (Cyrillic, Greek) and for Option-modified symbols on macOS.
- // When the layout already produces a Latin letter, match on it alone;
- // otherwise a remapped physical key triggers shortcuts for two different
- // letters at once and shadows system shortcuts on non-QWERTY layouts.
- const letterCode = event.code?.match(/^Key([A-Z])$/)?.[1];
- if (letterCode && !/^[a-z]$/.test(layoutKey)) {
- keys.add(letterCode.toLowerCase());
- }
- keys.add(shortcutKeyFromEvent(event));
- return keys;
-}
-
-function matchesShortcutModifiers(
- event: ShortcutModifierStateLike,
- shortcut: KeybindingShortcut,
- platform = navigator.platform,
-): boolean {
- const useMetaForMod = isMacPlatform(platform);
- const expectedMeta = shortcut.metaKey || (shortcut.modKey && useMetaForMod);
- const expectedCtrl = shortcut.ctrlKey || (shortcut.modKey && !useMetaForMod);
- return (
- event.metaKey === expectedMeta &&
- event.ctrlKey === expectedCtrl &&
- event.shiftKey === shortcut.shiftKey &&
- event.altKey === shortcut.altKey
- );
-}
-
-function matchesShortcut(
- event: ShortcutEventLike,
- shortcut: KeybindingShortcut,
- platform = navigator.platform,
-): boolean {
- if (
- !isMacPlatform(platform) &&
- event.getModifierState?.("AltGraph") &&
- !/^[a-z0-9]$/i.test(event.key)
- )
- return false;
- if (!matchesShortcutModifiers(event, shortcut, platform)) return false;
- return resolveEventKeys(event).has(shortcut.key);
-}
-
function resolvePlatform(options: ShortcutMatchOptions | undefined): string {
return options?.platform ?? navigator.platform;
}
@@ -197,14 +105,15 @@ export function shortcutConflictKey(
].join("|");
}
-function findEffectiveShortcutForCommand(
+export function effectiveShortcutsForCommand(
keybindings: ResolvedKeybindingsConfig,
command: KeybindingCommand,
options?: ShortcutMatchOptions,
-): KeybindingShortcut | null {
+): KeybindingShortcut[] {
const platform = resolvePlatform(options);
const context = resolveContext(options);
const claimedShortcuts = new Set();
+ const effective: KeybindingShortcut[] = [];
for (let index = keybindings.length - 1; index >= 0; index -= 1) {
const binding = keybindings[index];
@@ -218,11 +127,19 @@ function findEffectiveShortcutForCommand(
claimedShortcuts.add(conflictKey);
if (binding.command === command) {
- return binding.shortcut;
+ effective.push(binding.shortcut);
}
}
- return null;
+ return effective;
+}
+
+function findEffectiveShortcutForCommand(
+ keybindings: ResolvedKeybindingsConfig,
+ command: KeybindingCommand,
+ options?: ShortcutMatchOptions,
+): KeybindingShortcut | null {
+ return effectiveShortcutsForCommand(keybindings, command, options)[0] ?? null;
}
function matchesCommandShortcut(
@@ -246,7 +163,7 @@ export function resolveShortcutCommand(
const binding = keybindings[index];
if (!binding) continue;
if (!matchesWhenClause(binding.whenAst, context)) continue;
- if (!matchesShortcut(event, binding.shortcut, platform)) continue;
+ if (!matchesKeybindingShortcut(event, binding.shortcut, platform)) continue;
return binding.command;
}
return null;
@@ -339,7 +256,7 @@ export function shouldShowThreadJumpHintsForModifiers(
for (const command of THREAD_JUMP_KEYBINDING_COMMANDS) {
const shortcut = findEffectiveShortcutForCommand(keybindings, command, options);
if (!shortcut) continue;
- if (matchesShortcutModifiers(modifiers, shortcut, platform)) {
+ if (matchesKeybindingShortcutModifiers(modifiers, shortcut, platform)) {
return true;
}
}
diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts
index 9824c0546fb2437791a7eb6a963a19f5f3d63d2d..e25ca6489d9cc067ae292c90c016b668594f59ba 100644
--- a/apps/web/src/lib/utils.ts
+++ b/apps/web/src/lib/utils.ts
@@ -1,4 +1,5 @@
import { MessageId, ProjectId, ThreadId } from "@t3tools/contracts";
+import { isMacPlatform } from "@t3tools/shared/keybindings";
import { type CxOptions, cx } from "class-variance-authority";
import * as Hex from "effect/encoding/Hex";
import { extendTailwindMerge } from "tailwind-merge";
@@ -12,9 +13,7 @@ export function cn(...inputs: CxOptions) {
return twMerge(cx(inputs));
}
-export function isMacPlatform(platform: string): boolean {
- return /mac|iphone|ipad|ipod/i.test(platform);
-}
+export { isMacPlatform };
export function isWindowsPlatform(platform: string): boolean {
return /^win(dows)?/i.test(platform);
diff --git a/apps/web/src/reopenClosedView.test.ts b/apps/web/src/reopenClosedView.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..452c59cf889c0d94f47124de6230ac34a04e3cbb
--- /dev/null
+++ b/apps/web/src/reopenClosedView.test.ts
@@ -0,0 +1,234 @@
+import {
+ DEFAULT_CLIENT_SETTINGS,
+ type PreviewSessionSnapshot,
+ type ScopedThreadRef,
+} from "@t3tools/contracts";
+import * as Cause from "effect/Cause";
+import { AsyncResult } from "effect/reactivity";
+import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
+
+import { type ClosedViewEntry, useClosedViewStore } from "./closedViewStore";
+import { __setClientSettingsForTests } from "./hooks/useSettings";
+import { readThreadPreviewState, resetPreviewStateForTests } from "./previewStateStore";
+import {
+ planNextReopen,
+ pullRequestsSearchForRestore,
+ type ReopenOwnerState,
+ reopenClosedView,
+} from "./reopenClosedView";
+import {
+ selectThreadRightPanelState,
+ type ThreadRightPanelState,
+ useRightPanelStore,
+} from "./rightPanelStore";
+
+const threadRef = {
+ environmentId: "local" as ScopedThreadRef["environmentId"],
+ threadId: "thread-1" as ScopedThreadRef["threadId"],
+};
+const snapshot: PreviewSessionSnapshot = {
+ threadId: threadRef.threadId,
+ tabId: "old-tab",
+ navStatus: { _tag: "Success", url: "https://example.com", title: "Example" },
+ canGoBack: false,
+ canGoForward: false,
+ viewport: { _tag: "freeform", width: 1200, height: 800 },
+ profileId: "work",
+ updatedAt: "2026-09-22T12:00:00.000Z",
+};
+
+beforeEach(() => {
+ __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS);
+ resetPreviewStateForTests();
+ useClosedViewStore.setState({ entries: [] });
+ useRightPanelStore.setState({ byThreadKey: {}, userActionRevisionByThreadKey: {} });
+});
+
+describe("reopenClosedView", () => {
+ it("restores a file tab at its line without creating a browser session", async () => {
+ const openPreview = vi.fn();
+ const reopened = await reopenClosedView(
+ {
+ kind: "panel-tab",
+ threadRef,
+ surface: {
+ kind: "file",
+ id: "file:src/app.ts",
+ relativePath: "src/app.ts",
+ revealLine: 18,
+ revealRequestId: 1,
+ },
+ },
+ { openPreview, workspaceAvailable: true },
+ );
+ expect(reopened).toBe(true);
+ const state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, threadRef);
+ expect(state.isOpen).toBe(true);
+ expect(state.surfaces).toMatchObject([{ id: "file:src/app.ts", revealLine: 18 }]);
+ expect(openPreview).not.toHaveBeenCalled();
+ });
+
+ it("does not reopen workspace tabs without an available project", async () => {
+ const options = {
+ openPreview: vi.fn(),
+ workspaceAvailable: false,
+ };
+ for (const surface of [
+ { kind: "files", id: "files" },
+ {
+ kind: "file",
+ id: "file:src/app.ts",
+ relativePath: "src/app.ts",
+ revealLine: null,
+ revealRequestId: 0,
+ },
+ ] as const) {
+ expect(await reopenClosedView({ kind: "panel-tab", threadRef, surface }, options)).toBe(
+ false,
+ );
+ }
+ expect(
+ selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, threadRef).surfaces,
+ ).toEqual([]);
+ });
+
+ it("recreates a browser tab with saved URL, viewport and profile, then selects its new ID", async () => {
+ const openPreview = vi.fn(async () => AsyncResult.success({ ...snapshot, tabId: "new-tab" }));
+ const result = await reopenClosedView(
+ { kind: "browser", threadRef, snapshot },
+ { openPreview, workspaceAvailable: false },
+ );
+ expect(result).toBe(true);
+ expect(openPreview).toHaveBeenCalledWith({
+ environmentId: threadRef.environmentId,
+ input: {
+ threadId: threadRef.threadId,
+ url: "https://example.com",
+ viewport: snapshot.viewport,
+ profileId: "work",
+ },
+ });
+ expect(readThreadPreviewState(threadRef).snapshot?.tabId).toBe("new-tab");
+ expect(
+ selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, threadRef)
+ .activeSurfaceId,
+ ).toBe("browser:new-tab");
+ });
+
+ it("leaves the panel unchanged when a browser resource fails to reopen", async () => {
+ const result = await reopenClosedView(
+ { kind: "browser", threadRef, snapshot },
+ {
+ openPreview: async () => AsyncResult.failure(Cause.fail(new Error("offline"))),
+ workspaceAvailable: false,
+ },
+ );
+ expect(result).toBe(false);
+ expect(
+ selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, threadRef).surfaces,
+ ).toEqual([]);
+ });
+});
+
+describe("planNextReopen", () => {
+ const entries: ClosedViewEntry[] = [
+ { id: "a", kind: "panel-tab", threadRef, surface: { kind: "diff", id: "diff" } },
+ { id: "b", kind: "panel-tab", threadRef, surface: { kind: "files", id: "files" } },
+ ];
+ const known: ReopenOwnerState = {
+ environmentKnown: true,
+ catalogReady: true,
+ ownerExists: true,
+ shellLive: true,
+ panel: { isOpen: false, activeSurfaceId: null, surfaces: [] },
+ };
+ const openDiff: ThreadRightPanelState = {
+ isOpen: true,
+ activeSurfaceId: "diff",
+ surfaces: [{ kind: "diff", id: "diff" }],
+ };
+
+ it.each([
+ ["restores the newest entry", [known, known], [], "a"],
+ [
+ "drops a removed environment once the catalog is ready",
+ [{ ...known, environmentKnown: false }, known],
+ ["a"],
+ "b",
+ ],
+ [
+ "waits for the catalog before dropping",
+ [{ ...known, environmentKnown: false, catalogReady: false }, known],
+ [],
+ null,
+ ],
+ [
+ "skips but keeps a thread deleted from a live shell",
+ [{ ...known, ownerExists: false }, known],
+ [],
+ "b",
+ ],
+ [
+ "waits when a cached shell lacks the thread",
+ [{ ...known, ownerExists: false, shellLive: false }, known],
+ [],
+ null,
+ ],
+ ["drops a tab that is already open", [{ ...known, panel: openDiff }, known], ["a"], "b"],
+ [
+ "restores a tab whose panel is hidden",
+ [{ ...known, panel: { ...openDiff, isOpen: false } }],
+ [],
+ "a",
+ ],
+ ] as const)("%s", (_name, owners, dropped, restored) => {
+ const plan = planNextReopen(
+ entries.slice(0, owners.length),
+ (e) => owners[entries.indexOf(e)]!,
+ );
+ expect(plan.drop.map((e) => e.id)).toEqual(dropped);
+ expect(plan.restore?.id ?? null).toBe(restored);
+ });
+});
+
+describe("pullRequestsSearchForRestore", () => {
+ it("replaces the selection and keeps list filters", () => {
+ const search = pullRequestsSearchForRestore(
+ {
+ involvement: "reviewing",
+ state: "open",
+ q: "mine",
+ host: "filter.example.com",
+ selectedHost: "old",
+ number: 1,
+ },
+ {
+ kind: "pull-request",
+ id: "pull-request:example",
+ repository: "owner/repo",
+ number: 42,
+ projectId: "project-1",
+ environmentId: "remote",
+ host: "github.example.com",
+ },
+ );
+ expect(search).toEqual({
+ involvement: "reviewing",
+ state: "open",
+ q: "mine",
+ host: "filter.example.com",
+ repository: "owner/repo",
+ number: 42,
+ selectedProjectId: "project-1",
+ selectedHost: "github.example.com",
+ selectedEnvironmentId: "remote",
+ });
+ });
+
+ it("clears a stale selection and fills default filters when nothing is selected", () => {
+ expect(pullRequestsSearchForRestore({ repository: "old/repo", number: 7 }, null)).toEqual({
+ involvement: "all",
+ state: "open",
+ });
+ });
+});
diff --git a/apps/web/src/reopenClosedView.ts b/apps/web/src/reopenClosedView.ts
new file mode 100644
index 0000000000000000000000000000000000000000..551119a178a763c0f0b5c5fe8ec5e206b3cfe391
--- /dev/null
+++ b/apps/web/src/reopenClosedView.ts
@@ -0,0 +1,150 @@
+import type { EnvironmentId, ProjectId } from "@t3tools/contracts";
+
+import type { OpenPreviewMutation } from "./browser/openFileInPreview";
+import type { ClosedView, ClosedViewEntry } from "./closedViewStore";
+import type { PullRequestListPreferences } from "./components/pullRequest/pullRequestListPreferences";
+import { openPreviewSession } from "./components/preview/openPreviewSession";
+import {
+ type RightPanelSurface,
+ type ThreadRightPanelState,
+ useRightPanelStore,
+} from "./rightPanelStore";
+
+export interface ReopenOwnerState {
+ /** False when the entry's environment is not in the catalog. */
+ environmentKnown: boolean;
+ catalogReady: boolean;
+ /** Whether the owning thread or draft still exists locally. */
+ ownerExists: boolean;
+ /** A live shell that lacks the thread means it was deleted; a cached one may just be stale. */
+ shellLive: boolean;
+ panel: ThreadRightPanelState;
+}
+
+/**
+ * Picks the history entry the next reopen press restores, newest first. Entries whose
+ * environment is gone or whose tab is already open are dropped. A thread missing from a
+ * live shell is skipped but kept; anything not yet knowable stops the scan with no restore.
+ */
+export function planNextReopen(
+ entries: readonly ClosedViewEntry[],
+ ownerState: (entry: ClosedViewEntry) => ReopenOwnerState,
+): { drop: ClosedViewEntry[]; restore: ClosedViewEntry | null } {
+ const drop: ClosedViewEntry[] = [];
+ for (const entry of entries) {
+ const owner = ownerState(entry);
+ if (!owner.environmentKnown) {
+ if (!owner.catalogReady) break;
+ drop.push(entry);
+ continue;
+ }
+ if (!owner.ownerExists) {
+ if (owner.shellLive) continue;
+ break;
+ }
+ const alreadyOpen =
+ entry.kind === "panel-tab"
+ ? owner.panel.isOpen && owner.panel.surfaces.some((s) => s.id === entry.surface.id)
+ : owner.panel.surfaces.some(
+ (s) => s.kind === "preview" && s.resourceId === entry.snapshot.tabId,
+ );
+ if (alreadyOpen) {
+ drop.push(entry);
+ continue;
+ }
+ return { drop, restore: entry };
+ }
+ return { drop, restore: null };
+}
+
+type PullRequestsSearchLike = Partial & {
+ repository?: string;
+ number?: number;
+ selectedProjectId?: ProjectId;
+ selectedHost?: string;
+ selectedEnvironmentId?: EnvironmentId;
+};
+
+/** Selects the restored pull request on the Pull Requests page, keeping the list filters. */
+export function pullRequestsSearchForRestore(
+ previous: S,
+ selected: RightPanelSurface | null,
+): S & PullRequestListPreferences {
+ const {
+ repository: _repository,
+ number: _number,
+ selectedProjectId: _projectId,
+ selectedHost: _host,
+ selectedEnvironmentId: _environmentId,
+ ...filters
+ } = previous;
+ return {
+ ...filters,
+ involvement: previous.involvement ?? "all",
+ state: previous.state ?? "open",
+ ...(selected?.kind === "pull-request"
+ ? {
+ repository: selected.repository,
+ number: selected.number,
+ selectedProjectId: selected.projectId as ProjectId,
+ ...(selected.host === undefined ? {} : { selectedHost: selected.host }),
+ ...(selected.environmentId === undefined
+ ? {}
+ : { selectedEnvironmentId: selected.environmentId as EnvironmentId }),
+ }
+ : {}),
+ } as S & PullRequestListPreferences;
+}
+
+export async function reopenClosedView(
+ view: ClosedView,
+ options: {
+ openPreview: OpenPreviewMutation;
+ workspaceAvailable: boolean;
+ },
+): Promise {
+ const panels = useRightPanelStore.getState();
+ const ref = view.threadRef;
+
+ if (view.kind === "browser") {
+ const url = view.snapshot.navStatus._tag === "Idle" ? undefined : view.snapshot.navStatus.url;
+ const result = await openPreviewSession({
+ openPreview: options.openPreview,
+ threadRef: ref,
+ ...(url === undefined ? {} : { url }),
+ ...(view.snapshot.viewport === undefined ? {} : { viewport: view.snapshot.viewport }),
+ ...(view.snapshot.profileId === undefined ? {} : { profileId: view.snapshot.profileId }),
+ });
+ if (result._tag === "Failure") return false;
+ panels.openBrowser(ref, result.value.tabId);
+ return true;
+ }
+ const surface = view.surface;
+ if (
+ !options.workspaceAvailable &&
+ (surface.kind === "files" || (surface.kind === "file" && !surface.attachment))
+ )
+ return false;
+ switch (surface.kind) {
+ case "preview":
+ if (surface.resourceId !== null) return false;
+ panels.openBrowser(ref, null);
+ break;
+ case "file":
+ if (surface.attachment) panels.openAttachment(ref, surface.attachment);
+ else panels.openFile(ref, surface.relativePath, surface.revealLine ?? undefined);
+ break;
+ case "device":
+ if (surface.target) {
+ panels.openDevice(ref, surface.target);
+ if (surface.title) panels.renameDevice(ref, surface.id, surface.title);
+ } else panels.open(ref, "device");
+ break;
+ case "pull-request":
+ panels.openPullRequest(ref, surface);
+ break;
+ default:
+ panels.open(ref, surface.kind);
+ }
+ return true;
+}
diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts
index aa3dfa8e43d9f2b9cfa5bb9e51d845ea55cdf935..3babb2ed749a4ae6d0917ac2faf029da0a085a1d 100644
--- a/apps/web/src/rightPanelStore.test.ts
+++ b/apps/web/src/rightPanelStore.test.ts
@@ -2,6 +2,7 @@ import { scopeThreadRef } from "@t3tools/client-runtime/environment";
import { type EnvironmentId, ThreadId } from "@t3tools/contracts";
import { beforeEach, describe, expect, it } from "vite-plus/test";
+import { useClosedViewStore } from "./closedViewStore";
import {
migratePersistedRightPanelState,
pullRequestSurface,
@@ -19,14 +20,113 @@ const refA = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-A"))
const refB = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-B"));
beforeEach(() => {
+ useClosedViewStore.setState({ entries: [] });
useRightPanelStore.setState({
byThreadKey: {},
threadPanelVisibilityByThreadKey: {},
userActionRevisionByThreadKey: {},
+ closeRevisionByThreadKey: {},
});
});
describe("rightPanelStore", () => {
+ it("records single and bulk tab closes, newest first", () => {
+ const store = useRightPanelStore.getState();
+ const pr = pullRequestSurface({
+ projectId: "project-a",
+ repository: "pingdotgg/t3code",
+ number: 42,
+ });
+ store.openFile(refA, "src/app.ts");
+ store.open(refA, "device");
+ store.openPullRequest(refA, pr);
+ store.open(refA, "diff");
+ store.closeSurface(refA, pr.id);
+ store.closeSurfacesToRight(refA, "device");
+ store.closeOtherSurfaces(refA, "file:src/app.ts");
+ expect(
+ useClosedViewStore
+ .getState()
+ .entries.map((entry) => (entry.kind === "panel-tab" ? entry.surface.id : null)),
+ ).toEqual(["device", "diff", pr.id]);
+ });
+
+ it("reopens the active tab first after a bulk close", () => {
+ const store = useRightPanelStore.getState();
+ store.open(refA, "files");
+ store.open(refA, "diff");
+ store.open(refA, "device");
+ store.activateSurface(refA, "diff");
+ store.closeAllSurfaces(refA);
+
+ expect(
+ useClosedViewStore
+ .getState()
+ .entries.map((entry) => entry.kind === "panel-tab" && entry.surface.id),
+ ).toEqual(["diff", "device", "files"]);
+ });
+
+ it("does not save an incidental Files replacement when opening an existing file", () => {
+ const store = useRightPanelStore.getState();
+ store.openFile(refA, "src/app.ts");
+ store.open(refA, "files");
+ store.openFile(refA, "src/app.ts");
+
+ expect(useClosedViewStore.getState().entries).toEqual([]);
+ });
+
+ it("ignores session tabs without browser snapshots and records the empty browser tab", () => {
+ const store = useRightPanelStore.getState();
+ store.openBrowser(refA, "tab-1");
+ store.closeSurface(refA, "browser:tab-1");
+ expect(useClosedViewStore.getState().entries).toEqual([]);
+ store.openBrowser(refA, null);
+ store.closeSurface(refA, "browser:new");
+ expect(useClosedViewStore.getState().entries).toMatchObject([
+ { kind: "panel-tab", surface: { id: "browser:new", resourceId: null } },
+ ]);
+ });
+
+ it("keeps a dismissed device hidden after another file opens", () => {
+ const store = useRightPanelStore.getState();
+ const device = {
+ hostId: "nucbox",
+ deviceId: "emulator-5580",
+ name: "Pixel",
+ platform: "android",
+ } as const;
+ store.openFile(refA, "src/app.ts");
+ store.closeSurface(refA, "file:src/app.ts");
+ store.openDevice(refA, device);
+ store.closeSurface(refA, "device:nucbox:emulator-5580");
+
+ store.openFile(refA, "src/app.ts");
+ store.openDevice(refA, device, true);
+
+ expect(
+ selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA).surfaces,
+ ).toEqual([expect.objectContaining({ id: "file:src/app.ts" })]);
+ });
+
+ it("records only closed tabs when a panel is hidden or a terminal tab closes", () => {
+ const store = useRightPanelStore.getState();
+ store.open(refA, "diff");
+ store.openFile(refA, "src/app.ts");
+ store.closeSurface(refA, "file:src/app.ts");
+ store.close(refA);
+ expect(useClosedViewStore.getState().entries).toMatchObject([
+ { kind: "panel-tab", threadRef: refA, surface: { id: "file:src/app.ts" } },
+ ]);
+ store.toggleVisibility(refA);
+ store.toggle(refA, "diff");
+ store.openTerminal(refA, "term-1");
+ store.closeSurface(refA, "terminal:term-1");
+ expect(useClosedViewStore.getState().entries).toMatchObject([
+ { kind: "panel-tab", threadRef: refA, surface: { id: "file:src/app.ts" } },
+ ]);
+ expect(useClosedViewStore.getState().entries).toHaveLength(1);
+ });
+
it("gives each host/device its own tab and preserves renamed tabs", () => {
const store = useRightPanelStore.getState();
const android = {
diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts
index 214d4a4abba508ee3bc2fe4146080ab69a09f48b..6124297ebef2ba7047abfbbdd11a856678cdfa69 100644
--- a/apps/web/src/rightPanelStore.ts
+++ b/apps/web/src/rightPanelStore.ts
@@ -7,11 +7,16 @@
* terminal surfaces point at terminal session ids, file surfaces point at
* workspace paths, and diff/files remain singleton surfaces.
*/
-import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
+import {
+ parseScopedThreadKey,
+ scopedThreadKey,
+ scopeThreadRef,
+} from "@t3tools/client-runtime/environment";
import { EnvironmentId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
+import { useClosedViewStore } from "./closedViewStore";
import { resolveStorage } from "./lib/storage";
import type { ThreadPanelPresentation } from "./rightPanelLayout";
import type { ChatFileAttachment } from "./types";
@@ -119,6 +124,7 @@ interface RightPanelStoreState {
threadPanelVisibilityByThreadKey: Record;
/** Session-only count of user panel choices per thread. Automatic updates do not advance it. */
userActionRevisionByThreadKey: Record;
+ closeRevisionByThreadKey: Record;
getUserActionRevision: (ref: ScopedThreadRef) => number;
/**
* Open a surface on behalf of the app, not the user. Refused when the user
@@ -387,6 +393,7 @@ const userAction = (
state: RightPanelStoreState,
threadKey: string,
updater: (current: ThreadRightPanelState) => ThreadRightPanelState,
+ closesView = false,
): Partial => ({
...updateThread(state, threadKey, (current) => {
const next = updater(current);
@@ -410,8 +417,22 @@ const userAction = (
...state.userActionRevisionByThreadKey,
[threadKey]: (state.userActionRevisionByThreadKey[threadKey] ?? 0) + 1,
},
+ ...(closesView
+ ? {
+ closeRevisionByThreadKey: {
+ ...state.closeRevisionByThreadKey,
+ [threadKey]: (state.closeRevisionByThreadKey[threadKey] ?? 0) + 1,
+ },
+ }
+ : {}),
});
+const closeAction = (
+ state: RightPanelStoreState,
+ threadKey: string,
+ updater: (current: ThreadRightPanelState) => ThreadRightPanelState,
+): Partial => userAction(state, threadKey, updater, true);
+
function normalizeRevealLine(line: number | undefined): number | null {
if (line === undefined || !Number.isFinite(line)) return null;
return Math.max(1, Math.trunc(line));
@@ -571,6 +592,7 @@ export const useRightPanelStore = create()(
byThreadKey: {},
threadPanelVisibilityByThreadKey: {},
userActionRevisionByThreadKey: {},
+ closeRevisionByThreadKey: {},
getUserActionRevision: (ref) =>
get().userActionRevisionByThreadKey[scopedThreadKey(ref)] ?? 0,
openProactive: (ref, surface, expectedUserActionRevision) => {
@@ -754,7 +776,7 @@ export const useRightPanelStore = create()(
),
closeTerminal: (ref, surfaceId, terminalId) =>
set((state) =>
- userAction(state, scopedThreadKey(ref), (current) => {
+ closeAction(state, scopedThreadKey(ref), (current) => {
const surface = current.surfaces.find(
(entry) => entry.id === surfaceId && entry.kind === "terminal",
);
@@ -801,25 +823,28 @@ export const useRightPanelStore = create()(
),
closeSurface: (ref, surfaceId) =>
set((state) =>
- userAction(state, scopedThreadKey(ref), (current) => {
+ closeAction(state, scopedThreadKey(ref), (current) => {
const index = current.surfaces.findIndex((surface) => surface.id === surfaceId);
if (index < 0) return current;
const surfaces = current.surfaces.filter((surface) => surface.id !== surfaceId);
+ const next = {
+ ...current,
+ isOpen: surfaces.length > 0 && current.isOpen,
+ surfaces,
+ };
if (current.activeSurfaceId !== surfaceId) {
- return { ...current, isOpen: surfaces.length > 0 && current.isOpen, surfaces };
+ return next;
}
const fallback = surfaces[Math.min(index, surfaces.length - 1)] ?? null;
return {
- ...current,
- isOpen: surfaces.length > 0 && current.isOpen,
- surfaces,
+ ...next,
activeSurfaceId: fallback?.id ?? null,
};
}),
),
closeOtherSurfaces: (ref, surfaceId) =>
set((state) =>
- userAction(state, scopedThreadKey(ref), (current) => {
+ closeAction(state, scopedThreadKey(ref), (current) => {
const surface = current.surfaces.find((entry) => entry.id === surfaceId);
if (!surface || current.surfaces.length === 1) return current;
return {
@@ -832,7 +857,7 @@ export const useRightPanelStore = create()(
),
closeSurfacesToRight: (ref, surfaceId) =>
set((state) =>
- userAction(state, scopedThreadKey(ref), (current) => {
+ closeAction(state, scopedThreadKey(ref), (current) => {
const index = current.surfaces.findIndex((surface) => surface.id === surfaceId);
if (index < 0 || index === current.surfaces.length - 1) return current;
const surfaces = current.surfaces.slice(0, index + 1);
@@ -848,7 +873,7 @@ export const useRightPanelStore = create()(
),
closeAllSurfaces: (ref) =>
set((state) =>
- userAction(state, scopedThreadKey(ref), (current) =>
+ closeAction(state, scopedThreadKey(ref), (current) =>
current.surfaces.length === 0
? current
: { ...current, isOpen: false, surfaces: [], activeSurfaceId: null },
@@ -969,7 +994,8 @@ export const useRightPanelStore = create()(
if (
!(threadKey in state.byThreadKey) &&
!(threadKey in state.threadPanelVisibilityByThreadKey) &&
- !(threadKey in state.userActionRevisionByThreadKey)
+ !(threadKey in state.userActionRevisionByThreadKey) &&
+ !(threadKey in state.closeRevisionByThreadKey)
) {
return state;
}
@@ -978,10 +1004,13 @@ export const useRightPanelStore = create()(
state.threadPanelVisibilityByThreadKey;
const { [threadKey]: _revision, ...userActionRevisionByThreadKey } =
state.userActionRevisionByThreadKey;
+ const { [threadKey]: _closeRevision, ...closeRevisionByThreadKey } =
+ state.closeRevisionByThreadKey;
return {
byThreadKey,
threadPanelVisibilityByThreadKey,
userActionRevisionByThreadKey,
+ closeRevisionByThreadKey,
};
}),
}),
@@ -1009,6 +1038,32 @@ export const useRightPanelStore = create()(
),
);
+useRightPanelStore.subscribe((next, previous) => {
+ if (next.closeRevisionByThreadKey === previous.closeRevisionByThreadKey) return;
+ for (const [threadKey, revision] of Object.entries(next.closeRevisionByThreadKey)) {
+ if (revision === previous.closeRevisionByThreadKey[threadKey]) continue;
+ const threadRef = parseScopedThreadKey(threadKey);
+ if (!threadRef) continue;
+ const before = previous.byThreadKey[threadKey] ?? EMPTY_THREAD_STATE;
+ const after = next.byThreadKey[threadKey] ?? EMPTY_THREAD_STATE;
+ const removed = before.surfaces.filter(
+ (surface) => !after.surfaces.some((entry) => entry.id === surface.id),
+ );
+ const closedInOrder = [
+ ...removed.filter((surface) => surface.id !== before.activeSurfaceId),
+ ...removed.filter((surface) => surface.id === before.activeSurfaceId),
+ ];
+ for (const surface of closedInOrder) {
+ if (
+ surface.kind === "terminal" ||
+ (surface.kind === "preview" && surface.resourceId !== null)
+ )
+ continue;
+ useClosedViewStore.getState().remember({ kind: "panel-tab", threadRef, surface });
+ }
+ }
+});
+
export function selectThreadRightPanelState(
byThreadKey: Record,
ref: ScopedThreadRef | null | undefined,
diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx
index 68bab78c1c64ebf982c3eddcdc036a546f1fe4da..438b8c5e7a1a6f11e32c6f3d6a73c85043003652 100644
--- a/apps/web/src/routes/__root.tsx
+++ b/apps/web/src/routes/__root.tsx
@@ -31,6 +31,7 @@ import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLa
import { NightlyMobileBetaNotice } from "../components/NightlyMobileBeta";
import { LegacyThreadMigrationToast } from "../components/LegacyThreadMigrationToast";
import { ThreadNotificationCoordinator } from "../components/ThreadNotificationCoordinator";
+import { ReopenClosedViewShortcut } from "../components/ReopenClosedViewShortcut";
import { ProjectCloneToastCoordinator } from "../components/ProjectCloneToastCoordinator";
import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator";
import { ChatGptWelcomeCoordinator } from "../components/settings/ChatGptWelcomeCoordinator";
@@ -235,6 +236,7 @@ function RootRouteView() {
+
diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md
index e3773bc3adbcc2ec4235a4b531a4debb3f065ecd..488b57b997cbbb7081e3275abe1d289f326bd4aa 100644
--- a/docs/user/keybindings.md
+++ b/docs/user/keybindings.md
@@ -145,6 +145,13 @@ tab. When nothing remains to close, it closes the window. In a browser, `mod+w`
closes the browser tab; rebind `rightPanel.close` and `terminal.close` to an available
shortcut such as `alt+w`.
+`mod+shift+t` reopens the last closed tab across the app, including files,
+diffs, pull requests, browsers, and devices, in the order you closed them.
+A browser opens in a fresh session without its old page history. Incognito tabs
+can reopen until you reload or quit the app. This shortcut does not undo deleted work.
+Browsers also use it to reopen browser tabs; choose another binding in Settings
+if the browser takes it first.
+
Many defaults include `!terminalFocus` so they do not intercept terminal input.
Keep that condition when remapping them if you want the same behavior.
diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts
index ad72e3e04e30566bcb8537a0828a1e929cff14e3..081958acdebeb1c89d78c387729a50222f291deb 100644
--- a/packages/contracts/src/ipc.ts
+++ b/packages/contracts/src/ipc.ts
@@ -13,6 +13,7 @@ import { AdvertisedEndpoint } from "./remoteAccess.ts";
import { ExecutionEnvironmentDescriptor } from "./environment.ts";
import { type ClientSettings, type QuitConfirmationMode, SnapShotShortcut } from "./settings.ts";
import type { EditorId } from "./editor.ts";
+import type { PreviewForwardedShortcut } from "./keybindings.ts";
import type {
DesktopAppActivationRequest,
@@ -1226,6 +1227,7 @@ export interface DesktopBridge {
export const DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER = "__t3DesktopPreviewRecordingCapture";
export interface DesktopPreviewBridge {
+ setForwardedShortcuts?: (shortcuts: ReadonlyArray) => Promise;
createTab: (tabId: string, defaults?: DesktopPreviewTabDefaults) => Promise;
closeTab: (tabId: string) => Promise;
registerWebview: (tabId: string, webContentsId: number) => Promise;
diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts
index e42396dbc582d12c6e56692860d40bc99fc0a961..579ae8676d2e2cae74f635fd2e2ed81428a85824 100644
--- a/packages/contracts/src/keybindings.ts
+++ b/packages/contracts/src/keybindings.ts
@@ -69,6 +69,7 @@ export const STATIC_KEYBINDING_COMMANDS = [
"threadPanel.toggle",
"rightPanel.toggleMaximized",
"rightPanel.close",
+ "view.reopenClosed",
"pullRequest.copyNumber",
"diff.toggle",
"preview.toggle",
@@ -156,6 +157,12 @@ export const KeybindingShortcut = Schema.Struct({
});
export type KeybindingShortcut = typeof KeybindingShortcut.Type;
+export const PreviewForwardedShortcut = Schema.Struct({
+ command: KeybindingCommand,
+ shortcut: KeybindingShortcut,
+});
+export type PreviewForwardedShortcut = typeof PreviewForwardedShortcut.Type;
+
const KeybindingWhenNodeRef = Schema.suspend(
(): Schema.Codec => KeybindingWhenNode,
);
diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts
index 634945fe0e64aaee102f60506b76355f87c53aa9..205cbe3578d5897d4d0eb512e8431a2225238f8c 100644
--- a/packages/shared/src/keybindings.ts
+++ b/packages/shared/src/keybindings.ts
@@ -10,6 +10,108 @@ import {
THREAD_JUMP_KEYBINDING_COMMANDS,
} from "@t3tools/contracts";
+export interface ShortcutEventLike {
+ getModifierState?: (key: "AltGraph") => boolean;
+ type?: string;
+ code?: string;
+ key: string;
+ repeat?: boolean;
+ metaKey: boolean;
+ ctrlKey: boolean;
+ shiftKey: boolean;
+ altKey: boolean;
+}
+
+export interface ShortcutModifierStateLike {
+ metaKey: boolean;
+ ctrlKey: boolean;
+ shiftKey: boolean;
+ altKey: boolean;
+}
+
+const EVENT_CODE_SHORTCUT_KEYS: Readonly> = {
+ Backquote: "`",
+ Backslash: "\\",
+ BracketLeft: "[",
+ BracketRight: "]",
+ Comma: ",",
+ Digit0: "0",
+ Digit1: "1",
+ Digit2: "2",
+ Digit3: "3",
+ Digit4: "4",
+ Digit5: "5",
+ Digit6: "6",
+ Digit7: "7",
+ Digit8: "8",
+ Digit9: "9",
+ Equal: "=",
+ Minus: "-",
+ Period: ".",
+ Quote: "'",
+ Semicolon: ";",
+ Slash: "/",
+};
+
+export function isMacPlatform(platform: string): boolean {
+ return /mac|iphone|ipad|ipod/i.test(platform);
+}
+
+export function normalizeEventKey(key: string): string {
+ const normalized = key.toLowerCase();
+ if (normalized === "esc") return "escape";
+ return normalized;
+}
+
+export function shortcutKeyFromEvent(event: Pick): string {
+ const layoutKey = normalizeEventKey(event.key);
+ if (/^[a-z]$/.test(layoutKey)) return layoutKey;
+ const physicalKey = event.code ? EVENT_CODE_SHORTCUT_KEYS[event.code] : undefined;
+ return physicalKey ?? layoutKey;
+}
+
+export function resolveEventKeys(event: ShortcutEventLike): Set {
+ const layoutKey = normalizeEventKey(event.key);
+ const keys = new Set([layoutKey]);
+ const letterCode = event.code?.match(/^Key([A-Z])$/)?.[1];
+ if (letterCode && !/^[a-z]$/.test(layoutKey)) {
+ keys.add(letterCode.toLowerCase());
+ }
+ keys.add(shortcutKeyFromEvent(event));
+ return keys;
+}
+
+export function matchesKeybindingShortcutModifiers(
+ event: ShortcutModifierStateLike,
+ shortcut: KeybindingShortcut,
+ platform: string,
+): boolean {
+ const useMetaForMod = isMacPlatform(platform);
+ const expectedMeta = shortcut.metaKey || (shortcut.modKey && useMetaForMod);
+ const expectedCtrl = shortcut.ctrlKey || (shortcut.modKey && !useMetaForMod);
+ return (
+ event.metaKey === expectedMeta &&
+ event.ctrlKey === expectedCtrl &&
+ event.shiftKey === shortcut.shiftKey &&
+ event.altKey === shortcut.altKey
+ );
+}
+
+export function matchesKeybindingShortcut(
+ event: ShortcutEventLike,
+ shortcut: KeybindingShortcut,
+ platform: string,
+): boolean {
+ if (
+ !isMacPlatform(platform) &&
+ event.getModifierState?.("AltGraph") &&
+ !/^[a-z0-9]$/i.test(event.key)
+ )
+ return false;
+ if (!matchesKeybindingShortcutModifiers(event, shortcut, platform)) return false;
+ return resolveEventKeys(event).has(shortcut.key);
+}
+
type WhenToken =
| { type: "identifier"; value: string }
| { type: "not" }
@@ -29,6 +131,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [
{ key: "mod+n", command: "terminal.new", when: "terminalFocus" },
{ key: "mod+w", command: "terminal.close", when: "terminalFocus" },
{ key: "mod+w", command: "rightPanel.close", when: "!terminalFocus" },
+ { key: "mod+shift+t", command: "view.reopenClosed" },
{ key: "mod+d", command: "diff.toggle", when: "!terminalFocus" },
{ key: "mod+shift+j", command: "preview.toggle" },
{ key: "mod+r", command: "preview.refresh", when: "previewFocus" },