[eric] browser: Ctrl/Cmd+Shift+T reopens the last closed card (browser/tab/agent/note/app/workflow)

This commit is contained in:
ciregenz
2026-06-26 09:04:42 -07:00
parent 37e645c256
commit a76c44aeb7
8 changed files with 150 additions and 8 deletions
+1
View File
@@ -2003,6 +2003,7 @@ function routeBrowserShortcut(event, input, webContentsId) {
else if (mod && !input.shift && key === '-') action = 'zoom-out';
else if (mod && !input.shift && key === '0') action = 'zoom-reset';
else if (mod && !input.shift && key === 'f') action = 'find';
else if (mod && input.shift && key === 't') action = 'reopen-closed';
else if (input.control && !input.meta && key === 'tab') action = input.shift ? 'tab-prev' : 'tab-next';
if (!action) return;
event.preventDefault();
@@ -43,7 +43,7 @@ import { shallowEqual } from 'react-redux';
import { fetchDashboards, createDashboard, renameDashboard } from '@/shared/state/dashboardsSlice';
import { Typewriter } from '@/app/components/feedback/Animated';
import { setPendingFocusAgentId } from '@/shared/state/tempStateSlice';
import { addBrowserCard, addBrowserTab, cycleBrowserTab } from '@/shared/state/dashboardLayoutSlice';
import { addBrowserCard, addBrowserTab, cycleBrowserTab, reopenLastClosed } from '@/shared/state/dashboardLayoutSlice';
import { setPendingBrowserUrl } from '@/shared/state/tempStateSlice';
import { fetchOutputs } from '@/shared/state/outputsSlice';
import { setInstalling } from '@/shared/state/updateSlice';
@@ -356,6 +356,8 @@ const AppShell: React.FC = () => {
const w = window as any;
if (!w.openswarm?.onBrowserShortcut) return;
return w.openswarm.onBrowserShortcut((payload: { action: string; webContentsId: number }) => {
// Reopen-last-closed is global (no target browser), so handle it before the per-browser id guard.
if (payload.action === 'reopen-closed') { dispatch(reopenLastClosed()); return; }
const id = findBrowserByWebContentsId(payload.webContentsId) ?? getLastInteractedBrowser();
if (!id) return;
switch (payload.action) {
@@ -373,7 +375,8 @@ const AppShell: React.FC = () => {
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
const id = getLastInteractedBrowser();
if (!id) return;
// Require a LIVE webview: a stale id (its card was closed) means no browser is focused, so let the canvas shortcuts (e.g. card-search Cmd+F) handle the key instead.
if (!id || !getWebview(id)) return;
const t = e.target as HTMLElement | null;
const typing = t instanceof HTMLInputElement || t instanceof HTMLTextAreaElement || !!t?.isContentEditable;
if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && (e.key || '').toLowerCase() === 'f' && !typing) {
@@ -30,6 +30,7 @@ import {
fadeGlowingAgentCard,
clearGlowingAgentCard,
removeCard,
recordClosedCard,
} from '@/shared/state/dashboardLayoutSlice';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { QuestionForm } from '@/app/pages/AgentChat/shell/ApprovalBar';
@@ -625,6 +626,8 @@ const AgentCard: React.FC<Props> = ({
if (linkedWorkflowSidecarId) {
dispatch(setCardSidecar({ workflowId: linkedWorkflowSidecarId, sessionId: null, kind: null }));
}
// Record for Cmd+Shift+T BEFORE removeCard wipes the position, but only on a real close (the glow branch just clears a tether, it doesn't close the session).
if (!glowEntry) dispatch(recordClosedCard({ kind: 'agent', id: session.id }));
dispatch(collapseSession(session.id));
dispatch(removeCard(session.id));
if (glowEntry) {
@@ -33,6 +33,7 @@ import {
updateBrowserTabTitle,
updateBrowserTabFavicon,
reorderBrowserTab,
recordClosedCard,
type BrowserTab,
} from '@/shared/state/dashboardLayoutSlice';
import { removeBrowserCardCleanly } from '@/shared/browserTeardown';
@@ -455,6 +456,7 @@ const BrowserCard: React.FC<Props> = ({
const handleRemove = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
dispatch(recordClosedCard({ kind: 'browser', id: browserId }));
removeBrowserCardCleanly(browserId, dispatch);
}, [dispatch, browserId]);
@@ -465,8 +467,11 @@ const BrowserCard: React.FC<Props> = ({
const handleCloseTab = useCallback((tabId: string, e: React.MouseEvent) => {
e.stopPropagation();
// Closing the last tab destroys the whole card, so record it as a browser-card close (reopen brings the card back), not a tab close.
if (tabs.length <= 1) dispatch(recordClosedCard({ kind: 'browser', id: browserId }));
else dispatch(recordClosedCard({ kind: 'tab', id: tabId, browserId }));
dispatch(removeBrowserTab({ browserId, tabId }));
}, [dispatch, browserId]);
}, [dispatch, browserId, tabs.length]);
const handleSwitchTab = useCallback((tabId: string) => {
dispatch(setActiveBrowserTab({ browserId, tabId }));
@@ -10,7 +10,7 @@ import RestartAltIcon from '@mui/icons-material/RestartAlt';
import CloseIcon from '@mui/icons-material/Close';
import GridViewRoundedIcon from '@mui/icons-material/GridViewRounded';
import { Output, SERVE_BASE } from '@/shared/state/outputsSlice';
import { setViewCardPosition, setViewCardSize, setActiveViewCardId } from '@/shared/state/dashboardLayoutSlice';
import { setViewCardPosition, setViewCardSize, setActiveViewCardId, recordClosedCard } from '@/shared/state/dashboardLayoutSlice';
import { removeViewCardCleanly } from '@/shared/viewTeardown';
import { useAppDispatch, useAppSelector } from '@/shared/hooks';
import { API_BASE, getAuthToken } from '@/shared/config';
@@ -304,6 +304,7 @@ const DashboardViewCard: React.FC<Props> = ({
const handleRemove = (e: React.MouseEvent) => {
e.stopPropagation();
dispatch(recordClosedCard({ kind: 'view', id: output.id }));
void removeViewCardCleanly(output.id, dispatch);
};
@@ -9,6 +9,7 @@ import {
removeNote,
updateNoteContent,
setNoteColor,
recordClosedCard,
NoteColor,
} from '@/shared/state/dashboardLayoutSlice';
import { useAppDispatch } from '@/shared/hooks';
@@ -238,6 +239,7 @@ const NoteCard: React.FC<Props> = ({
const handleRemove = (e: React.MouseEvent) => {
e.stopPropagation();
dispatch(recordClosedCard({ kind: 'note', id: noteId }));
dispatch(removeNote(noteId));
};
@@ -2,11 +2,12 @@ import { useEffect, type Dispatch, type SetStateAction } from 'react';
import { report } from '@/shared/serviceClient';
import { useAppDispatch } from '@/shared/hooks';
import { closeSession, toggleExpandSession } from '@/shared/state/agentsSlice';
import { removeNote, removeWorkflowCard, closeWorkflowsHub } from '@/shared/state/dashboardLayoutSlice';
import { removeNote, removeWorkflowCard, closeWorkflowsHub, recordClosedCard, reopenLastClosed } from '@/shared/state/dashboardLayoutSlice';
import { closeWorkflowCard } from '@/shared/state/workflowsSlice';
import { removeBrowserCardCleanly } from '@/shared/browserTeardown';
import { removeViewCardCleanly } from '@/shared/viewTeardown';
import { getLastInteractedBrowser } from '@/shared/browserFocus';
import { getWebview } from '@/shared/browserRegistry';
import type { useDashboardSelection } from '../state/useDashboardSelection';
type Selection = ReturnType<typeof useDashboardSelection>;
@@ -77,14 +78,19 @@ export function useDashboardShortcuts({
const viewIds: string[] = [];
for (const [id, type] of selection.selectedIds) {
if (type === 'agent') {
dispatch(recordClosedCard({ kind: 'agent', id }));
dispatch(closeSession({ sessionId: id }));
} else if (type === 'view') {
dispatch(recordClosedCard({ kind: 'view', id }));
viewIds.push(id);
} else if (type === 'browser') {
dispatch(recordClosedCard({ kind: 'browser', id }));
removeBrowserCardCleanly(id, dispatch);
} else if (type === 'note') {
dispatch(recordClosedCard({ kind: 'note', id }));
dispatch(removeNote(id));
} else if (type === 'workflow') {
dispatch(recordClosedCard({ kind: 'workflow', id }));
dispatch(removeWorkflowCard(id));
dispatch(closeWorkflowCard(id));
} else if (type === 'workflows-hub') {
@@ -99,6 +105,18 @@ export function useDashboardShortcuts({
return () => window.removeEventListener('keydown', handleDelete);
}, [selection, dispatch]);
// Cmd/Ctrl+Shift+T reopens the most recently closed card (browser, agent, note, app, workflow, or browser tab), like a browser's reopen-closed-tab. The guest-focused case routes through main -> AppShell.
useEffect(() => {
const handleReopen = (e: KeyboardEvent) => {
if (!isActive) return;
if (!(e.metaKey || e.ctrlKey) || !e.shiftKey || e.altKey || e.key.toLowerCase() !== 't') return;
e.preventDefault();
dispatch(reopenLastClosed());
};
window.addEventListener('keydown', handleReopen);
return () => window.removeEventListener('keydown', handleReopen);
}, [isActive, dispatch]);
// Cmd/Ctrl+A selects every card so it can be deleted in one go. Skipped inside text fields so Cmd+A there still selects text, not cards.
useEffect(() => {
const handleSelectAll = (e: KeyboardEvent) => {
@@ -118,8 +136,9 @@ export function useDashboardShortcuts({
const handleSearch = (e: KeyboardEvent) => {
if (!isActive) return; // Don't fire shortcuts when dashboard is hidden
if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== 'f') return;
// When you're in a browser card, Cmd+F is find-in-page (handled in AppShell), not card search.
if (getLastInteractedBrowser()) return;
// When you're in a LIVE browser card, Cmd+F is find-in-page (handled in AppShell), not card search. A stale id (its card was closed) must NOT suppress the palette, so require the webview to still exist.
const fb = getLastInteractedBrowser();
if (fb && getWebview(fb)) return;
const tag = (e.target as HTMLElement)?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement)?.isContentEditable) return;
e.preventDefault();
@@ -1,5 +1,5 @@
import { createSlice, createAsyncThunk, PayloadAction, createAction } from '@reduxjs/toolkit';
import { launchAndSendFirstMessage } from './agentsSlice';
import { launchAndSendFirstMessage, resumeSession } from './agentsSlice';
import { API_BASE } from '@/shared/config';
import { getLastDashboardId } from '@/shared/lastDashboardId';
@@ -110,6 +110,19 @@ export interface NotePosition {
export const DEFAULT_NOTE_W = 240;
export const DEFAULT_NOTE_H = 200;
// One entry in the Ctrl/Cmd+Shift+T "reopen last closed" stack: a full snapshot for browser/view/workflow/note/tab, just the session id for an agent (its session is brought back via resumeSession).
export type ClosedCard =
| { uid: string; kind: 'browser'; closedAt: number; card: BrowserCardPosition }
| { uid: string; kind: 'view'; closedAt: number; card: ViewCardPosition }
| { uid: string; kind: 'workflow'; closedAt: number; card: WorkflowCardPosition }
| { uid: string; kind: 'note'; closedAt: number; note: NotePosition }
| { uid: string; kind: 'tab'; closedAt: number; browserId: string; index: number; tab: BrowserTab }
| { uid: string; kind: 'agent'; closedAt: number; sessionId: string; position: CardPosition | null };
export type ClosedCardKind = ClosedCard['kind'];
const RECENTLY_CLOSED_CAP = 25;
export interface DashboardLayoutState {
cards: Record<string, CardPosition>;
viewCards: Record<string, ViewCardPosition>;
@@ -118,6 +131,8 @@ export interface DashboardLayoutState {
workflowsHub: WorkflowsHubPosition | null;
notes: Record<string, NotePosition>;
closedCardPositions: Record<string, CardPosition>;
/** Session-global LIFO undo stack for Ctrl/Cmd+Shift+T; survives dashboard switches (resetLayout leaves it alone). */
recentlyClosed: ClosedCard[];
glowingBrowserCards: Record<string, { sourceId: string; fading: boolean; label?: string }>;
glowingAgentCards: Record<string, { sourceId: string; fading: boolean; sourceYRatio?: number; label?: string }>;
persistedExpandedSessionIds: string[];
@@ -164,6 +179,7 @@ const initialState: DashboardLayoutState = {
workflowsHub: null,
notes: {},
closedCardPositions: {},
recentlyClosed: [],
glowingBrowserCards: {},
glowingAgentCards: {},
persistedExpandedSessionIds: [],
@@ -1264,6 +1280,75 @@ const dashboardLayoutSlice = createSlice({
state.pendingFocusNoteId = null;
},
// Snapshot a card onto the reopen stack RIGHT BEFORE it's closed (the data must still be in state). Dispatch only from genuine user closes, not programmatic teardown.
recordClosedCard(
state,
action: PayloadAction<{ kind: ClosedCardKind; id: string; browserId?: string }>
) {
const { kind, id, browserId } = action.payload;
const closedAt = Date.now();
const uid = `${kind}-${id}-${closedAt}`;
let entry: ClosedCard | null = null;
if (kind === 'browser' && state.browserCards[id]) {
entry = { uid, kind, closedAt, card: { ...state.browserCards[id], tabs: state.browserCards[id].tabs.map((t) => ({ ...t })) } };
} else if (kind === 'view' && state.viewCards[id]) {
entry = { uid, kind, closedAt, card: { ...state.viewCards[id] } };
} else if (kind === 'workflow' && state.workflowCards[id]) {
entry = { uid, kind, closedAt, card: { ...state.workflowCards[id] } };
} else if (kind === 'note' && state.notes[id]) {
entry = { uid, kind, closedAt, note: { ...state.notes[id] } };
} else if (kind === 'agent') {
entry = { uid, kind, closedAt, sessionId: id, position: state.cards[id] ? { ...state.cards[id] } : null };
} else if (kind === 'tab' && browserId && state.browserCards[browserId]) {
const card = state.browserCards[browserId];
const index = card.tabs.findIndex((t) => t.id === id);
// Last tab closing tears the whole card down; that's recorded as a 'browser' close instead, so skip.
if (index >= 0 && card.tabs.length > 1) entry = { uid, kind, closedAt, browserId, index, tab: { ...card.tabs[index] } };
}
if (!entry) return;
state.recentlyClosed.push(entry);
if (state.recentlyClosed.length > RECENTLY_CLOSED_CAP) state.recentlyClosed.shift();
},
// Re-insert a non-agent closed card (agents come back via resumeSession in the reopenLastClosed thunk). Lands on the current dashboard.
restoreClosedCard(
state,
action: PayloadAction<{ entry: ClosedCard; dashboardId?: string }>
) {
const { entry, dashboardId } = action.payload;
const zOrder = state.nextZOrder++;
if (entry.kind === 'browser') {
state.browserCards[entry.card.browser_id] = { ...entry.card, zOrder, dashboard_id: dashboardId ?? entry.card.dashboard_id };
} else if (entry.kind === 'view') {
state.viewCards[entry.card.output_id] = { ...entry.card, zOrder };
} else if (entry.kind === 'workflow') {
state.workflowCards[entry.card.workflow_id] = { ...entry.card, zOrder };
} else if (entry.kind === 'note') {
state.notes[entry.note.note_id] = { ...entry.note, zOrder };
} else if (entry.kind === 'tab') {
const card = state.browserCards[entry.browserId];
if (card) {
// Fresh id: reusing the old one makes BrowserCard think the tab is already initialized, so its webview never reloads the URL and sits at about:blank.
const tab = { ...entry.tab, id: generateTabId() };
card.tabs.splice(Math.min(entry.index, card.tabs.length), 0, tab);
card.activeTabId = tab.id;
card.url = tab.url;
}
}
},
popClosedCard(state, action: PayloadAction<string>) {
state.recentlyClosed = state.recentlyClosed.filter((e) => e.uid !== action.payload);
},
// Pre-seed a resumed agent's old position so reconcileSessions drops its card back where it was, not in a fresh grid cell.
seedClosedAgentPosition(
state,
action: PayloadAction<{ sessionId: string; position: CardPosition }>
) {
state.closedCardPositions[action.payload.sessionId] = action.payload.position;
},
replaceDraftId(
state,
action: PayloadAction<{ oldId: string; newId: string }>
@@ -1509,7 +1594,30 @@ export const {
setNoteColor,
removeNote,
clearPendingFocusNoteId,
recordClosedCard,
restoreClosedCard,
popClosedCard,
seedClosedAgentPosition,
resetLayout,
} = dashboardLayoutSlice.actions;
// Ctrl/Cmd+Shift+T: bring back the most recently closed card on the current dashboard. Agents resume from history (async); everything else is a synchronous re-insert. Best-effort: the entry is consumed even if an agent resume fails, so a dead session can't wedge the stack.
export const reopenLastClosed = createAsyncThunk(
'dashboardLayout/reopenLastClosed',
async (_: void, { getState, dispatch }) => {
const state = getState() as { dashboardLayout: DashboardLayoutState };
const stack = state.dashboardLayout.recentlyClosed;
if (stack.length === 0) return;
const entry = stack[stack.length - 1];
const dashboardId = getLastDashboardId() ?? undefined;
if (entry.kind === 'agent') {
if (entry.position) dispatch(seedClosedAgentPosition({ sessionId: entry.sessionId, position: entry.position }));
await dispatch(resumeSession({ sessionId: entry.sessionId }));
} else {
dispatch(restoreClosedCard({ entry, dashboardId }));
}
dispatch(popClosedCard(entry.uid));
}
);
export default dashboardLayoutSlice.reducer;