mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-08 18:57:43 +02:00
[eric] browser: Ctrl/Cmd+Shift+T reopens the last closed card (browser/tab/agent/note/app/workflow)
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user