diff --git a/electron/main.js b/electron/main.js index 0dea74cd..1e29d4c6 100644 --- a/electron/main.js +++ b/electron/main.js @@ -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(); diff --git a/frontend/src/app/components/Layout/AppShell.tsx b/frontend/src/app/components/Layout/AppShell.tsx index fab7d4b9..b8efeba3 100644 --- a/frontend/src/app/components/Layout/AppShell.tsx +++ b/frontend/src/app/components/Layout/AppShell.tsx @@ -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) { diff --git a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx index 85279bda..99a19c6d 100644 --- a/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/AgentCard.tsx @@ -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 = ({ 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) { diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx index 7dc37324..95430879 100644 --- a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx @@ -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 = ({ 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 = ({ 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 })); diff --git a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx index 25db7d4e..97b37145 100644 --- a/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/DashboardViewCard.tsx @@ -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 = ({ const handleRemove = (e: React.MouseEvent) => { e.stopPropagation(); + dispatch(recordClosedCard({ kind: 'view', id: output.id })); void removeViewCardCleanly(output.id, dispatch); }; diff --git a/frontend/src/app/pages/Dashboard/cards/NoteCard.tsx b/frontend/src/app/pages/Dashboard/cards/NoteCard.tsx index f5efd38a..7728eb3b 100644 --- a/frontend/src/app/pages/Dashboard/cards/NoteCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/NoteCard.tsx @@ -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 = ({ const handleRemove = (e: React.MouseEvent) => { e.stopPropagation(); + dispatch(recordClosedCard({ kind: 'note', id: noteId })); dispatch(removeNote(noteId)); }; diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardShortcuts.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardShortcuts.ts index c292a8f0..fb42d328 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardShortcuts.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardShortcuts.ts @@ -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; @@ -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(); diff --git a/frontend/src/shared/state/dashboardLayoutSlice.ts b/frontend/src/shared/state/dashboardLayoutSlice.ts index cb38c603..b928d659 100644 --- a/frontend/src/shared/state/dashboardLayoutSlice.ts +++ b/frontend/src/shared/state/dashboardLayoutSlice.ts @@ -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; viewCards: Record; @@ -118,6 +131,8 @@ export interface DashboardLayoutState { workflowsHub: WorkflowsHubPosition | null; notes: Record; closedCardPositions: Record; + /** Session-global LIFO undo stack for Ctrl/Cmd+Shift+T; survives dashboard switches (resetLayout leaves it alone). */ + recentlyClosed: ClosedCard[]; glowingBrowserCards: Record; glowingAgentCards: Record; 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) { + 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;