Files
Netcatty/application/state/useSessionState.ts
T
fcfba34fc1 perf(state): stop handler churn on session updates and repeated JSON work (#4121)
* perf(state): stop handler churn on session updates and repeated JSON work

- Shell handlers: AppSideEffects registers ~58 glue callbacks for the Host
  islands. Several close over sessions/workspaces/hosts (split/copy/duplicate,
  close tabs, delete host, keyboard-interactive, connect...), so every session
  status change and host update (connect bursts, last-connected times)
  re-notified the bridge and rebuilt all four shell bags, re-rendering AppView
  and TopTabs. They are now registered behind per-key stable wrappers that
  call the latest implementation (stabilizeAppHandlers); local callbacks and
  their effects are unchanged. All registered entries are actions, setters or
  refs, never read during render.
- Session actions: moveFocusInWorkspace, copySession and copyWorkspace read
  workspaces / orphan sessions / log views through refs at call time (the same
  snapshot the closure captured), so they keep a stable identity. Getters read
  during render (isBroadcastEnabled, getOrderedWorkTabs, ...) are untouched.
- AI history: the live store was parsed five times when the AI state root
  mounted; parseSessionsStore parses once per call (two parses total).
- AI history writes: the synced branch serializes once and reuses the string
  for the disk mirror, and rewriting the same immutable sessions array (save
  path, then cloud-sync collection) is skipped while storage still holds that
  exact write in the same mode.
- Command history: saving after each Enter parsed the whole store (up to 10k
  entries) only to read its clear generation, and every other window parsed it
  again on the storage event. The generation is now serialized first and read
  from the raw string (full parse only to adopt a peer clear), and the
  debounced write runs when the renderer is idle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: retrigger CI after cancelled lint-and-test run

Previous lint-and-test was cancelled mid-run.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: binaricat <binaricat@users.noreply.github.com>
2026-11-04 01:11:31 -08:00

1557 lines
60 KiB
TypeScript

import { MouseEvent,useCallback,useEffect,useMemo,useRef,useState } from 'react';
import { flushSync } from 'react-dom';
import { ConnectionLog,Host,SerialConfig,Snippet,TerminalSession,Workspace,WorkspaceNode,WorkspaceViewMode } from '../../domain/models';
import { addLogView, getLogViewTabId, removeLogView, type LogView } from './logViewState';
import {
createHostTerminalSession,
createLocalTerminalSession,
createSerialTerminalSession,
createWorkspaceHostTerminalSession,
type LocalTerminalOptions,
} from './sessionFactories';
import { isScriptSnippet } from '../../domain/snippetScript.ts';
import {
appendPaneToWorkspaceRoot,
cloneWorkspaceTree,
collectSessionIds,
createWorkspaceFromSessions as createWorkspaceEntity,
createWorkspaceFromSessionIds,
FocusDirection,
getNextFocusSessionId,
insertPaneIntoWorkspace,
pruneWorkspaceNode,
reorderWorkspaceFocusSessionOrder,
setWorkspaceFocusedSessionId,
SplitDirection,
SplitHint,
updateWorkspaceSplitSizes,
invertSplitPosition,
} from '../../domain/workspace';
import { clearSessionFontSizeOverride as clearSessionFontSizeOverrideFields } from '../../domain/terminalAppearance';
import {
composeSendTargetAfterToolbarToggle,
flipComposeSendTarget,
} from '../../domain/terminalBroadcast';
import { buildOrderedWorkTabIds, reorderWorkTabIds } from '../app/workTabSurface';
import { activeTabStore } from './activeTabStore';
import {
publishSessionCodingCliProvider,
publishSessionDynamicTitle,
sessionPresentationStore,
} from './sessionPresentationStore';
import {
closeSessionsState,
detachSessionFromWorkspaceState,
replaceDissolvedWorkspaceTabOrder,
} from './sessionWorkspaceDetach';
import {
createCopiedTerminalSessionClone,
createSplitTerminalSessionClone,
} from './terminalConnectionReuse';
import { STORAGE_KEY_RESTORE_PREVIOUS_SESSION } from '../../infrastructure/config/storageKeys';
import {
LOCAL_STORAGE_ADAPTER_CHANGED_EVENT,
localStorageAdapter,
} from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { sessionRestoreStorage } from './sessionRestoreStorage';
import {
buildAndWriteSessionRestorePayload,
createInitialRestoredSessionState,
patchSessionRestoreActiveTabId,
shouldPersistSessionRestoreState,
updateRestoredSessionStatusState,
} from './sessionRestoreState';
import type { SessionRestorePayload } from '../../domain/sessionRestore';
import { resolveRestorePreviousSessionSetting } from './sessionRestoreSettings';
import type { CodingCliProviderId } from '../../domain/codingCliProviders';
import { normalizeCodingCliDynamicTitleForStorage } from '../../domain/codingCliTitleParse';
import {
DEFAULT_WORKSPACE_TITLE,
buildMergedWorkspaceTitle,
buildMergedWorkspaceTitleFromSessions,
getSessionConnectionLabel,
} from '../../domain/sessionTabTitle';
import { cleanupClosedTerminalSessions } from './aiStateSnapshots';
import { syncSessionHostLabels } from '../../domain/syncSessionHostLabels';
import { useVaultSnapshotField } from './vaultSnapshotStore';
export function addWorkspaceIfMissing(
workspaces: Workspace[],
workspace: Workspace,
): Workspace[] {
return workspaces.some(ws => ws.id === workspace.id) ? workspaces : [...workspaces, workspace];
}
export function addTerminalSessionIfMissing(
sessions: TerminalSession[],
session: TerminalSession,
): TerminalSession[] {
return sessions.some(candidate => candidate.id === session.id) ? sessions : [...sessions, session];
}
export function insertWorkspacePaneIfMissing(
workspaces: Workspace[],
workspaceId: string,
sessionId: string,
hint: SplitHint,
): Workspace[] {
return workspaces.map(ws => {
if (ws.id !== workspaceId) return ws;
if (collectSessionIds(ws.root).includes(sessionId)) return ws;
return { ...ws, root: insertPaneIntoWorkspace(ws.root, sessionId, hint) };
});
}
export function appendWorkspaceRootPaneIfMissing(
workspaces: Workspace[],
workspaceId: string,
sessionId: string,
direction: SplitDirection = 'vertical',
): Workspace[] {
return workspaces.map(ws => {
if (ws.id !== workspaceId) return ws;
if (collectSessionIds(ws.root).includes(sessionId)) {
return ws.focusedSessionId === sessionId ? ws : { ...ws, focusedSessionId: sessionId };
}
return {
...ws,
root: appendPaneToWorkspaceRoot(ws.root, sessionId, direction),
focusedSessionId: sessionId,
};
});
}
export function insertCopiedTabOrderIdOnce(
prevTabOrder: string[],
sourceTabId: string,
copiedTabId: string,
allTabIds: string[],
): string[] {
if (prevTabOrder.includes(copiedTabId)) return prevTabOrder;
const directIdx = prevTabOrder.indexOf(sourceTabId);
if (directIdx !== -1) {
const next = [...prevTabOrder];
next.splice(directIdx + 1, 0, copiedTabId);
return next;
}
// allTabIds should only ever list pre-existing tabs; defensively strip the
// copied id in case a caller's list includes it, so it isn't reconciled in
// twice (once via newIds, once via the explicit splice below).
const existingTabIds = allTabIds.filter(id => id !== copiedTabId);
const allTabIdSet = new Set(existingTabIds);
const orderedIds = prevTabOrder.filter(id => allTabIdSet.has(id));
const orderedIdSet = new Set(orderedIds);
const newIds = existingTabIds.filter(id => !orderedIdSet.has(id));
const currentOrder = [...orderedIds, ...newIds];
const sourceIdx = currentOrder.indexOf(sourceTabId);
if (sourceIdx === -1) return [...prevTabOrder, copiedTabId];
const next = [...currentOrder];
next.splice(sourceIdx + 1, 0, copiedTabId);
return next;
}
export function buildCopiedWorkspace(
sourceWorkspace: Workspace,
prevSessions: TerminalSession[],
params: {
newWorkspaceId: string;
sessionIdMap: ReadonlyMap<string, string>;
localShellType?: TerminalSession['shellType'];
perPaneCwd?: Record<string, string | undefined>;
nodeIdMap?: ReadonlyMap<string, string>;
},
): { newSessions: TerminalSession[]; newWorkspace: Workspace } | null {
const sourceIds = collectSessionIds(sourceWorkspace.root);
const liveIds = sourceIds.filter(id => prevSessions.some(s => s.id === id));
if (liveIds.length === 0) return null;
// Prune panes whose source session is gone so the clone never references a
// missing session.
let prunedRoot = sourceWorkspace.root;
for (const deadId of sourceIds.filter(id => !liveIds.includes(id))) {
const next = pruneWorkspaceNode(prunedRoot, deadId);
if (next) prunedRoot = next;
}
// Precondition: sessionIdMap covers every id in collectSessionIds(root)
// (the sole caller, copyWorkspace, builds it that way), so every liveId has
// a mapping and the `as string` casts here and below are sound.
const liveIdMap = new Map<string, string>(
liveIds.map(id => [id, params.sessionIdMap.get(id) as string]),
);
const newSessions = liveIds.map(srcId => {
const src = prevSessions.find(s => s.id === srcId) as TerminalSession;
return createCopiedTerminalSessionClone(src, {
id: liveIdMap.get(srcId) as string,
workspaceId: params.newWorkspaceId,
localShellType: params.localShellType,
inheritedCwd: params.perPaneCwd?.[srcId],
});
});
const remap = (id?: string): string | undefined =>
id != null ? liveIdMap.get(id) : undefined;
const newWorkspace: Workspace = {
...sourceWorkspace,
id: params.newWorkspaceId,
root: cloneWorkspaceTree(prunedRoot, liveIdMap, params.nodeIdMap),
focusedSessionId: remap(sourceWorkspace.focusedSessionId)
?? liveIdMap.get(liveIds[0]),
focusSessionOrder: sourceWorkspace.focusSessionOrder
? sourceWorkspace.focusSessionOrder
.map(id => liveIdMap.get(id))
.filter((id): id is string => Boolean(id))
: undefined,
};
return { newSessions, newWorkspace };
}
export const useSessionState = ({
persistSessionRestore = true,
}: {
persistSessionRestore?: boolean;
} = {}) => {
const initialRestoreState = useMemo(() => createInitialRestoredSessionState({
restoreEnabled: persistSessionRestore && resolveRestorePreviousSessionSetting(
localStorageAdapter.readBoolean(STORAGE_KEY_RESTORE_PREVIOUS_SESSION),
),
payload: persistSessionRestore ? sessionRestoreStorage.read() : null,
}), [persistSessionRestore]);
const [sessions, setSessions] = useState<TerminalSession[]>(initialRestoreState.sessions);
const [workspaces, setWorkspaces] = useState<Workspace[]>(initialRestoreState.workspaces);
// Latest workspaces snapshot for synchronous existence checks outside
// setWorkspaces updaters — React doesn't guarantee updaters run
// synchronously, so relying on a flag flipped inside them to decide
// whether to also call setSessions is racy and can leave orphan panes.
const workspacesRef = useRef(workspaces);
workspacesRef.current = workspaces;
// activeTabId is now managed by external store - components subscribe directly
const setActiveTabId = activeTabStore.setActiveTabId;
const [draggingSessionId, setDraggingSessionId] = useState<string | null>(null);
const [sessionRenameTarget, setSessionRenameTarget] = useState<TerminalSession | null>(null);
const [sessionRenameValue, setSessionRenameValue] = useState('');
const [workspaceRenameTarget, setWorkspaceRenameTarget] = useState<Workspace | null>(null);
const [workspaceRenameValue, setWorkspaceRenameValue] = useState('');
// Tab order: stores ordered list of tab IDs (orphan session IDs and workspace IDs)
const [tabOrder, setTabOrder] = useState<string[]>(initialRestoreState.tabOrder);
// Broadcast mode: stores workspace IDs that have broadcast enabled
const [broadcastWorkspaceIds, setBroadcastWorkspaceIds] = useState<Set<string>>(new Set());
// Compose-bar send target. Toolbar toggles reset it; the compose bar can
// then flip it without changing keystroke fan-out.
const [composeSendAllWorkspaceIds, setComposeSendAllWorkspaceIds] = useState<Set<string>>(new Set());
// Global broadcast mode: enables broadcast for all orphan sessions (not in workspaces)
const [globalBroadcastEnabled, setGlobalBroadcastEnabled] = useState<boolean>(false);
const [globalComposeSendToAll, setGlobalComposeSendToAll] = useState<boolean>(false);
// Log views: stores open log replay tabs
const [logViews, setLogViews] = useState<LogView[]>([]);
const logViewsRef = useRef(logViews);
logViewsRef.current = logViews;
const [restorePreviousSessionRevision, setRestorePreviousSessionRevision] = useState(0);
const sessionsRef = useRef(sessions);
const tabOrderRef = useRef(tabOrder);
const scheduleSessionRestorePersistRef = useRef<() => void>(() => {});
const sessionRestoreCwdByIdRef = useRef(
new Map(
initialRestoreState.sessions
.filter((session) => Boolean(session.lastCwd))
.map((session) => [session.id, session.lastCwd as string]),
),
);
const hasSeenRestorableSessionRestoreStateRef = useRef(
persistSessionRestore && shouldPersistSessionRestoreState(
initialRestoreState.sessions,
initialRestoreState.workspaces,
initialRestoreState.tabOrder,
),
);
sessionsRef.current = sessions;
tabOrderRef.current = tabOrder;
if (persistSessionRestore && shouldPersistSessionRestoreState(sessions, workspaces, tabOrder)) {
hasSeenRestorableSessionRestoreStateRef.current = true;
}
useEffect(() => {
if (initialRestoreState.activeTabId !== 'vault') {
activeTabStore.setActiveTabId(initialRestoreState.activeTabId);
}
}, [initialRestoreState.activeTabId]);
// Keep open sessions' connection label in sync with the vault. Sessions
// snapshot `hostLabel` at connect time, so renaming a host would otherwise
// leave its already-open tabs (and merged-workspace titles derived from
// those labels) showing the old name until the session is closed. The
// generated-title sync effect below re-runs on this session change and
// refreshes synthesized workspace titles too.
const vaultHosts = useVaultSnapshotField('hosts');
useEffect(() => {
if (vaultHosts.length === 0) return;
// The helper returns the same array reference when nothing changed, so
// React bails out of the state update.
setSessions(prev => syncSessionHostLabels(prev, vaultHosts));
}, [vaultHosts]);
// Keep generated merged-workspace titles (e.g. "01/02" after dragging tab
// "02" onto "01") synchronized with the workspace's current membership and
// each pane's connection label. `autoTitle: false` makes
// resolveWorkspaceTabLabel treat the composed title as a user-chosen name,
// so without this pass a pane rename or a pane being added/removed would
// leave the label stale. Runs off session changes and only touches
// workspaces still flagged `generatedTitle` (a user rename clears it).
useEffect(() => {
setWorkspaces(prev => {
let changed = false;
const next = prev.map(ws => {
if (!ws.generatedTitle) return ws;
// Order members by the workspace's pane tree (base/join or split
// layout), not by global session creation order — merging a newer tab
// onto an older one must keep the merged title as created (e.g.
// "02/01"), while `sessions.filter` would rewrite it to "01/02".
const rootIds = collectSessionIds(ws.root);
const members = [
...rootIds
.map(id => sessions.find(s => s.id === id))
.filter((s): s is TerminalSession => Boolean(s)),
// Safety net for transient membership: any session assigned to this
// workspace whose pane has not landed in `root` yet keeps its
// previous (creation-order) position after the pane-tree members.
...sessions.filter(s => s.workspaceId === ws.id && !rootIds.includes(s.id)),
];
const recomputed = buildMergedWorkspaceTitleFromSessions(members) ?? DEFAULT_WORKSPACE_TITLE;
if (recomputed === ws.title) return ws;
changed = true;
return { ...ws, title: recomputed };
});
return changed ? next : prev;
});
}, [sessions]);
useEffect(() => {
const handleRestorePreviousSessionChanged = (key?: string) => {
if (key !== STORAGE_KEY_RESTORE_PREVIOUS_SESSION) return;
setRestorePreviousSessionRevision((revision) => revision + 1);
};
const handleLocalStorageAdapterChanged = (event: Event) => {
const detail = (event as CustomEvent<{ key?: string }>).detail;
handleRestorePreviousSessionChanged(detail?.key);
};
window.addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handleLocalStorageAdapterChanged);
const unsubscribeSettingsSync = netcattyBridge.get()?.onSettingsChanged?.((payload) => {
handleRestorePreviousSessionChanged(payload?.key);
});
return () => {
window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, handleLocalStorageAdapterChanged);
unsubscribeSettingsSync?.();
};
}, []);
useEffect(() => {
if (!persistSessionRestore) return;
const restoreEnabled = resolveRestorePreviousSessionSetting(
localStorageAdapter.readBoolean(STORAGE_KEY_RESTORE_PREVIOUS_SESSION),
);
if (!restoreEnabled) {
scheduleSessionRestorePersistRef.current = () => {};
sessionRestoreStorage.clear();
hasSeenRestorableSessionRestoreStateRef.current = false;
return;
}
let timeout: number | undefined;
let activeTabPatchTimeout: number | undefined;
// Last full restore payload written this session. Tab switches patch only
// activeTabId against this cache instead of re-serializing every session.
let lastFullPayload: SessionRestorePayload | null = null;
const persistNow = () => {
const sessionsForRestore = sessionsRef.current.map((session) => {
const cwd = sessionRestoreCwdByIdRef.current.get(session.id);
if (cwd) {
return session.lastCwd === cwd ? session : { ...session, lastCwd: cwd };
}
if (session.lastCwd === undefined) return session;
const { lastCwd: _lastCwd, ...rest } = session;
return rest;
});
const hasRestorableState = shouldPersistSessionRestoreState(
sessionsForRestore,
workspacesRef.current,
tabOrderRef.current,
);
const clearOnEmpty = hasSeenRestorableSessionRestoreStateRef.current && !hasRestorableState;
const wrote = buildAndWriteSessionRestorePayload({
restoreEnabled: resolveRestorePreviousSessionSetting(
localStorageAdapter.readBoolean(STORAGE_KEY_RESTORE_PREVIOUS_SESSION),
),
clearOnEmpty,
sessions: sessionsForRestore,
workspaces: workspacesRef.current,
tabOrder: tabOrderRef.current,
activeTabId: activeTabStore.getActiveTabId(),
storage: {
write: (payload) => {
lastFullPayload = payload;
return sessionRestoreStorage.write(payload);
},
clear: () => {
lastFullPayload = null;
sessionRestoreStorage.clear();
},
},
});
if (!wrote && !hasRestorableState) {
lastFullPayload = null;
}
};
const schedulePersist = () => {
if (timeout !== undefined) {
window.clearTimeout(timeout);
}
timeout = window.setTimeout(() => {
timeout = undefined;
persistNow();
}, 250);
};
const scheduleActiveTabPatch = () => {
// Coalesce rapid tab clicks. Only patch when we hold a trusted full payload
// written by persistNow in this effect. Never storage.read() as a base —
// disk can still have pre-connect sessions while live already added a host
// and activated its tab (effect recreated, lastFullPayload reset to null).
if (activeTabPatchTimeout !== undefined) {
window.clearTimeout(activeTabPatchTimeout);
}
activeTabPatchTimeout = window.setTimeout(() => {
activeTabPatchTimeout = undefined;
const activeTabId = activeTabStore.getActiveTabId();
if (!lastFullPayload) {
// Cache empty after effect recreate or before first full write — rebuild
// from live sessions/workspaces (debounced with other structural persists).
schedulePersist();
return;
}
const result = patchSessionRestoreActiveTabId({
activeTabId,
cachedPayload: lastFullPayload,
storage: {
write: (payload) => {
lastFullPayload = payload;
return sessionRestoreStorage.write(payload);
},
},
});
if (result.status === "missing") {
schedulePersist();
}
}, 100);
};
schedulePersist();
scheduleSessionRestorePersistRef.current = schedulePersist;
const unsubscribeActiveTab = activeTabStore.subscribeSync(scheduleActiveTabPatch);
const handlePageHide = () => {
if (timeout !== undefined) {
window.clearTimeout(timeout);
timeout = undefined;
}
if (activeTabPatchTimeout !== undefined) {
window.clearTimeout(activeTabPatchTimeout);
activeTabPatchTimeout = undefined;
}
persistNow();
};
window.addEventListener("pagehide", handlePageHide);
window.addEventListener("beforeunload", handlePageHide);
return () => {
scheduleSessionRestorePersistRef.current = () => {};
if (timeout !== undefined) {
window.clearTimeout(timeout);
}
if (activeTabPatchTimeout !== undefined) {
window.clearTimeout(activeTabPatchTimeout);
}
unsubscribeActiveTab();
window.removeEventListener("pagehide", handlePageHide);
window.removeEventListener("beforeunload", handlePageHide);
};
}, [sessions, workspaces, tabOrder, restorePreviousSessionRevision, persistSessionRestore]);
const updateSessionRestoreCwd = useCallback((sessionId: string, cwd: string | null) => {
const nextCwd = cwd && cwd.trim().length > 0 ? cwd : null;
const currentCwd = sessionRestoreCwdByIdRef.current.get(sessionId) ?? null;
if (currentCwd === nextCwd) return;
if (nextCwd) {
sessionRestoreCwdByIdRef.current.set(sessionId, nextCwd);
// Once a live cwd is tracked, the one-shot inherited-cwd seed set by a
// clone/split has served its purpose — drop it so a later remount +
// fresh reconnect doesn't re-inject the stale `cd`. The functional
// update returns `prev` unchanged (no re-render) unless the field is set.
setSessions((prev) => {
const target = prev.find((candidate) => candidate.id === sessionId);
if (!target || target.pendingInitialCwd === undefined) return prev;
return prev.map((candidate) => {
if (candidate.id !== sessionId) return candidate;
const { pendingInitialCwd: _spent, ...rest } = candidate;
return rest;
});
});
} else {
sessionRestoreCwdByIdRef.current.delete(sessionId);
}
scheduleSessionRestorePersistRef.current();
}, []);
// The live tracked cwd (OSC 7) is kept in a ref, not on the session object,
// so expose a reader for callers (clone/split cwd inheritance) that need the
// current directory of a running session without forcing a re-render.
const getSessionRestoreCwd = useCallback(
(sessionId: string): string | undefined => sessionRestoreCwdByIdRef.current.get(sessionId) ?? undefined,
[],
);
const updateSessionDynamicTitle = useCallback((sessionId: string, title: string | null) => {
const normalizedTitle = title ? normalizeCodingCliDynamicTitleForStorage(title) : '';
const nextTitle = normalizedTitle.length > 0 ? normalizedTitle : null;
// Presentation-only: write the external store so TopTabs / focus sidebar /
// pane chrome refresh via applySessionPresentation without setSessions
// thrashing appTerminalDomain / TerminalLayer parents.
publishSessionDynamicTitle(sessionId, nextTitle);
}, []);
const updateSessionCodingCliProvider = useCallback((
sessionId: string,
providerId: CodingCliProviderId | null,
) => {
// Presentation-only: store-driven icon chrome. Consumers must overlay via
// applySessionPresentation / usePresentedSession (TopTabs, focus sidebar, panes).
publishSessionCodingCliProvider(sessionId, providerId);
}, []);
const createLocalTerminal = useCallback((options?: LocalTerminalOptions) => {
const sessionId = crypto.randomUUID();
setSessions(prev => [...prev, createLocalTerminalSession(sessionId, options)]);
setActiveTabId(sessionId);
return sessionId;
}, [setActiveTabId]);
const createSerialSession = useCallback((config: SerialConfig, options?: { charset?: string }) => {
const sessionId = crypto.randomUUID();
setSessions(prev => [...prev, createSerialTerminalSession(sessionId, config, options)]);
setActiveTabId(sessionId);
return sessionId;
}, [setActiveTabId]);
const connectToHost = useCallback((host: Host, options?: { hidden?: boolean }) => {
const hidden = options?.hidden === true;
const newSession = createHostTerminalSession(crypto.randomUUID(), host);
const sessionToAdd = hidden ? { ...newSession, hiddenFromTabs: true } : newSession;
setSessions(prev => [...prev, sessionToAdd]);
if (!hidden) setActiveTabId(newSession.id);
return newSession.id;
}, [setActiveTabId]);
const updateSessionStatus = useCallback((sessionId: string, status: TerminalSession['status']) => {
setSessions(prev => updateRestoredSessionStatusState(prev, sessionId, status));
}, []);
const updateSessionFontSize = useCallback((sessionId: string, fontSize: number) => {
setSessions(prev => prev.map(s => (
s.id === sessionId ? { ...s, fontSize, fontSizeOverride: true } : s
)));
}, []);
const clearSessionFontSizeOverride = useCallback((sessionId: string) => {
setSessions(prev => prev.map(s => (
s.id === sessionId ? clearSessionFontSizeOverrideFields(s) : s
)));
}, []);
const closeWorkspace = useCallback((workspaceId: string) => {
const closedIds = sessionsRef.current
.filter(session => session.workspaceId === workspaceId)
.map(session => session.id);
cleanupClosedTerminalSessions(closedIds);
for (const sessionId of closedIds) {
sessionPresentationStore.clearSession(sessionId);
}
setWorkspaces(prevWorkspaces => {
const remainingWorkspaces = prevWorkspaces.filter(w => w.id !== workspaceId);
setSessions(prevSessions => prevSessions.filter(s => s.workspaceId !== workspaceId));
const currentActiveTabId = activeTabStore.getActiveTabId();
if (currentActiveTabId === workspaceId) {
if (remainingWorkspaces.length > 0) {
setActiveTabId(remainingWorkspaces[remainingWorkspaces.length - 1].id);
} else {
setActiveTabId('vault');
}
}
return remainingWorkspaces;
});
}, [setActiveTabId]);
const closeSessions = useCallback((sessionIds: string[]) => {
cleanupClosedTerminalSessions(sessionIds);
for (const sessionId of sessionIds) {
sessionPresentationStore.clearSession(sessionId);
}
const result = closeSessionsState({
sessions: sessionsRef.current,
workspaces: workspacesRef.current,
sessionIds,
currentActiveTabId: activeTabStore.getActiveTabId(),
preferredTabId: activeTabStore.getPreviousActiveTabId(),
tabOrder: tabOrderRef.current,
});
setWorkspaces(result.workspaces);
setSessions(result.sessions);
setTabOrder(result.tabOrder);
if (result.activeTabId) {
// Closing must not rewrite previous-tab history to the closed tab.
activeTabStore.setActiveTabId(result.activeTabId, { recordPrevious: false });
}
}, []);
const closeSession = useCallback((sessionId: string, e?: MouseEvent) => {
e?.stopPropagation();
closeSessions([sessionId]);
}, [closeSessions]);
const startSessionRename = useCallback((sessionId: string) => {
setSessions(prevSessions => {
const target = prevSessions.find(s => s.id === sessionId);
if (target) {
setSessionRenameTarget(target);
setSessionRenameValue(target.customName || target.hostLabel);
}
return prevSessions;
});
}, []);
const renameSessionInline = useCallback((sessionId: string, name: string) => {
const trimmed = name.trim();
if (!trimmed) return;
setSessions(prev => prev.map(s => (
s.id === sessionId ? { ...s, customName: trimmed, hostLabel: trimmed } : s
)));
}, []);
const submitSessionRename = useCallback((sessionId?: string, name?: string) => {
if (sessionId !== undefined && name !== undefined) {
const trimmed = name.trim();
if (!trimmed) return;
setSessions(prev => prev.map(s => (
s.id === sessionId ? { ...s, customName: trimmed, hostLabel: trimmed } : s
)));
return;
}
setSessionRenameValue(prevValue => {
const trimmed = prevValue.trim();
if (!trimmed) return prevValue;
setSessionRenameTarget(prevTarget => {
if (!prevTarget) return prevTarget;
setSessions(prev => prev.map(s => (
s.id === prevTarget.id ? { ...s, customName: trimmed, hostLabel: trimmed } : s
)));
return null;
});
return '';
});
}, []);
const resetSessionRename = useCallback(() => {
setSessionRenameTarget(null);
setSessionRenameValue('');
}, []);
const startWorkspaceRename = useCallback((workspaceId: string) => {
setWorkspaces(prevWorkspaces => {
const target = prevWorkspaces.find(w => w.id === workspaceId);
if (target) {
setWorkspaceRenameTarget(target);
setWorkspaceRenameValue(target.title);
}
return prevWorkspaces;
});
}, []);
const submitWorkspaceRename = useCallback((workspaceId?: string, name?: string) => {
// A user rename takes ownership of the title: clear `generatedTitle` so a
// previously synthesized merged title ("01/02") stops being auto-updated.
const applyRename = (w: Workspace, trimmed: string): Workspace => (
{ ...w, title: trimmed, autoTitle: false, generatedTitle: false }
);
if (workspaceId !== undefined && name !== undefined) {
const trimmed = name.trim();
if (!trimmed) return;
setWorkspaces(prev => prev.map(w => (
w.id === workspaceId ? applyRename(w, trimmed) : w
)));
return;
}
setWorkspaceRenameValue(prevValue => {
const trimmed = prevValue.trim();
if (!trimmed) return prevValue;
setWorkspaceRenameTarget(prevTarget => {
if (!prevTarget) return prevTarget;
setWorkspaces(prev => prev.map(w => w.id === prevTarget.id ? applyRename(w, trimmed) : w));
return null;
});
return '';
});
}, []);
const resetWorkspaceRename = useCallback(() => {
setWorkspaceRenameTarget(null);
setWorkspaceRenameValue('');
}, []);
const createWorkspaceWithHosts = useCallback((name: string, hosts: Host[]) => {
if (hosts.length === 0) return;
const sessionSeeds = hosts.map(host => ({
id: crypto.randomUUID(),
host,
}));
const sessionIds = sessionSeeds.map(s => s.id);
// Create workspace
const workspace = createWorkspaceFromSessionIds(sessionIds, {
title: name,
autoTitle: false,
viewMode: 'split',
});
// Assign workspaceId to sessions
const sessionsWithWorkspace = sessionSeeds.map(({ id, host }) => (
createWorkspaceHostTerminalSession(id, host, workspace.id)
));
setSessions(prev => [...prev, ...sessionsWithWorkspace]);
setWorkspaces(prev => [...prev, workspace]);
setActiveTabId(workspace.id);
}, [setActiveTabId]);
// Like createWorkspaceWithHosts but supports mixed targets — each
// entry is either an SSH host or a local terminal. Used by the
// "New Workspace" flow in QuickSwitcher.
type WorkspaceTarget =
| { kind: 'local'; shellType?: TerminalSession['shellType']; shell?: string; shellArgs?: string[]; shellName?: string; shellIcon?: string }
| { kind: 'host'; host: Host };
const createWorkspaceFromTargets = useCallback((targets: WorkspaceTarget[], name?: string): string | null => {
if (targets.length === 0) return null;
// A caller-provided name is an explicit title; without one the workspace is
// auto-titled and the tab derives a host-based label.
const explicitName = name?.trim();
const workspaceTitle = explicitName || DEFAULT_WORKSPACE_TITLE;
const newSessions: TerminalSession[] = targets.map((target) => {
if (target.kind === 'local') {
const sessionId = crypto.randomUUID();
return createLocalTerminalSession(sessionId, {
shellType: target.shellType,
shell: target.shell,
shellArgs: target.shellArgs,
shellName: target.shellName,
shellIcon: target.shellIcon,
});
}
const host = target.host;
return createHostTerminalSession(crypto.randomUUID(), host);
});
const sessionIds = newSessions.map((s) => s.id);
// Default to focus-mode (sidebar layout) regardless of target
// count — matches the intent behind the QuickSwitcher "New
// Workspace" flow, which the user expects to land in focus view.
const workspace = createWorkspaceFromSessionIds(sessionIds, {
title: workspaceTitle,
autoTitle: explicitName ? false : undefined,
viewMode: 'focus',
});
const sessionsWithWorkspace = newSessions.map((s) => ({ ...s, workspaceId: workspace.id }));
setSessions((prev) => [...prev, ...sessionsWithWorkspace]);
setWorkspaces((prev) => [...prev, workspace]);
setActiveTabId(workspace.id);
return workspace.id;
}, [setActiveTabId]);
const addSessionToWorkspace = useCallback((
workspaceId: string,
sessionId: string,
hint: SplitHint
) => {
if (!hint) return;
setSessions(prevSessions => {
const session = prevSessions.find(s => s.id === sessionId);
if (!session || session.workspaceId) return prevSessions;
setWorkspaces(prevWorkspaces => {
const targetWorkspace = prevWorkspaces.find(w => w.id === workspaceId);
if (!targetWorkspace) return prevWorkspaces;
return insertWorkspacePaneIfMissing(prevWorkspaces, workspaceId, sessionId, hint);
});
setActiveTabId(workspaceId);
return prevSessions.map(s => s.id === sessionId ? { ...s, workspaceId } : s);
});
}, [setActiveTabId]);
// Merge two tabs into one workspace. When one of them already belongs to a
// workspace, that workspace (and its id) is reused instead of dissolving
// and recreating: the id is what workspace-scoped AI chats bind to, so
// regenerating it used to strand every conversation anchored to it
// (issue #4015 R3). Only two genuinely standalone tabs create a new id.
const createWorkspaceFromSessions = useCallback((
baseSessionId: string,
joiningSessionId: string,
hint: SplitHint
) => {
if (!hint || baseSessionId === joiningSessionId) return;
const baseSession = sessionsRef.current.find((s) => s.id === baseSessionId);
const joiningSession = sessionsRef.current.find((s) => s.id === joiningSessionId);
const anchorWorkspaceId = baseSession?.workspaceId ?? joiningSession?.workspaceId;
if (anchorWorkspaceId) {
const anchorSessionId = baseSession?.workspaceId ? baseSessionId : joiningSessionId;
const orphanSessionId = anchorSessionId === baseSessionId ? joiningSessionId : baseSessionId;
const orphanSession = sessionsRef.current.find((s) => s.id === orphanSessionId);
if (!orphanSession || orphanSession.workspaceId) return;
// Split next to the anchor's existing pane: the raw drop hint targets
// the orphan's pane, which is not part of this workspace tree yet.
// When the dragged (joining) pane already owns the workspace, the orphan
// is what gets inserted — invert the drop position so the previewed
// side still matches (hint describes where the dragged pane should sit).
const joinOwnsWorkspace = Boolean(joiningSession?.workspaceId) && !baseSession?.workspaceId;
const insertHint = {
...hint,
targetSessionId: anchorSessionId,
position: joinOwnsWorkspace ? invertSplitPosition(hint.position) : hint.position,
};
addSessionToWorkspace(
anchorWorkspaceId,
orphanSessionId,
insertHint,
);
// The orphan's standalone tab slot is replaced by the workspace tab.
setTabOrder((prevTabOrder) => prevTabOrder.filter((id) => id !== orphanSessionId));
return;
}
// Name the merged workspace after both tabs' connection labels (e.g.
// "01/02") so the window title and the rename dialog show the merged
// identity instead of the generic "Workspace". Falls back to the default
// auto title when neither tab has a label. The explicit `autoTitle: false`
// makes tab labels use this composed name too; `generatedTitle: true`
// marks it as synthesized so it keeps tracking membership/renames (see the
// generated-title sync effect below) instead of going stale.
// Compose the title from the created pane-tree order (not base/join
// order): for left/top merges `createWorkspaceEntity` places the joining
// pane before the base pane in `root`, and the generated-title sync
// effect above recomputes from `root` order — building the initial title
// in tree order keeps it stable instead of renaming itself right after
// creation.
const created = createWorkspaceEntity(baseSessionId, joiningSessionId, hint);
const treeLabels = collectSessionIds(created.root).map((id) =>
getSessionConnectionLabel(
sessionsRef.current.find(s => s.id === id) ?? { customName: '', hostLabel: '' },
),
);
const mergedTitle = buildMergedWorkspaceTitle(treeLabels[0] ?? '', treeLabels[1] ?? '');
const newWorkspace = mergedTitle
? { ...created, title: mergedTitle, autoTitle: false, generatedTitle: true }
: created;
setSessions((prevSessions) => {
const base = prevSessions.find((s) => s.id === baseSessionId);
const joining = prevSessions.find((s) => s.id === joiningSessionId);
if (!base || !joining || base.workspaceId || joining.workspaceId) return prevSessions;
setWorkspaces((prev) => addWorkspaceIfMissing(prev, newWorkspace));
// Collapse the two session tab slots into the workspace tab so later
// detach/close can replace `ws-*` with session ids again.
setTabOrder((prevTabOrder) => {
const withoutSessions = prevTabOrder.filter(
(id) => id !== baseSessionId && id !== joiningSessionId,
);
if (withoutSessions.includes(newWorkspace.id)) return withoutSessions;
const indexes = [baseSessionId, joiningSessionId]
.map((id) => prevTabOrder.indexOf(id))
.filter((index) => index >= 0);
const insertAt = indexes.length > 0 ? Math.min(...indexes) : withoutSessions.length;
const next = [...withoutSessions];
next.splice(Math.min(insertAt, next.length), 0, newWorkspace.id);
return next;
});
setActiveTabId(newWorkspace.id);
return prevSessions.map((s) => {
if (s.id === baseSessionId || s.id === joiningSessionId) {
return { ...s, workspaceId: newWorkspace.id };
}
return s;
});
});
}, [addSessionToWorkspace, setActiveTabId]);
// Add a host into an existing workspace by creating a new session for
// that host and appending it as the last pane at the workspace root.
// Sibling sizes are rebalanced equally by appendPaneToWorkspaceRoot.
// Unlike addSessionToWorkspace (which takes a pre-created orphan
// session and a SplitHint), this is atomic — the new session is born
// already bound to the target workspace and focused.
const appendHostToWorkspace = useCallback((
workspaceId: string,
host: Host,
direction: SplitDirection = 'vertical',
): string | null => {
// Cheap early-exit using the ref when the workspace is clearly
// absent. The authoritative check lives inside the setWorkspaces
// updater below so we also cover the concurrent-close race.
if (!workspacesRef.current.some(w => w.id === workspaceId)) return null;
const newSessionId = crypto.randomUUID();
const newSession = createWorkspaceHostTerminalSession(newSessionId, host, workspaceId);
// Nest setSessions + setActiveTabId inside the setWorkspaces updater
// so we only commit the session when the workspace update actually
// matched — otherwise a concurrent closeWorkspace between the ref
// check and the updater firing would leave an orphan session with a
// workspaceId pointing at nothing, and active tab would jump to a
// closed id. The inner setSessions is idempotent (id dedupe) so
// StrictMode's dev-time double-invoke does not duplicate the row.
setWorkspaces(prev => {
const target = prev.find(w => w.id === workspaceId);
if (!target) return prev;
setSessions(s => addTerminalSessionIfMissing(s, newSession));
setActiveTabId(workspaceId);
return appendWorkspaceRootPaneIfMissing(prev, workspaceId, newSessionId, direction);
});
return newSessionId;
}, [setActiveTabId]);
// Agent callers bind scope immediately, so they need committed insertion,
// not just the ID allocated by the ordinary batched UI append operation.
const appendHostToWorkspaceConfirmed = useCallback((
workspaceId: string,
host: Host,
direction: SplitDirection = 'vertical',
): string | null => {
let sessionId: string | null = null;
flushSync(() => {
sessionId = appendHostToWorkspace(workspaceId, host, direction);
});
if (!sessionId) return null;
const committedWorkspace = workspacesRef.current.find(w => w.id === workspaceId);
const inserted = committedWorkspace
&& collectSessionIds(committedWorkspace.root).includes(sessionId)
&& sessionsRef.current.some(s => s.id === sessionId && s.workspaceId === workspaceId);
return inserted ? sessionId : null;
}, [appendHostToWorkspace]);
// Atomic "append a local terminal pane" — mirror of appendHostToWorkspace
// but constructs a local-protocol session instead of an SSH one.
const appendLocalTerminalToWorkspace = useCallback((
workspaceId: string,
options?: {
shellType?: TerminalSession['shellType'];
shell?: string;
shellArgs?: string[];
shellName?: string;
shellIcon?: string;
},
direction: SplitDirection = 'vertical',
): string | null => {
// Same pattern as appendHostToWorkspace — ref guard + authoritative
// inside-updater match to cover concurrent closeWorkspace.
if (!workspacesRef.current.some(w => w.id === workspaceId)) return null;
const newSessionId = crypto.randomUUID();
const newSession: TerminalSession = {
...createLocalTerminalSession(newSessionId, {
shellType: options?.shellType,
shell: options?.shell,
shellArgs: options?.shellArgs,
shellName: options?.shellName,
shellIcon: options?.shellIcon,
}),
workspaceId,
};
setWorkspaces(prev => {
const target = prev.find(w => w.id === workspaceId);
if (!target) return prev;
setSessions(s => addTerminalSessionIfMissing(s, newSession));
setActiveTabId(workspaceId);
return appendWorkspaceRootPaneIfMissing(prev, workspaceId, newSessionId, direction);
});
return newSessionId;
}, [setActiveTabId]);
const updateSplitSizes = useCallback((workspaceId: string, splitId: string, sizes: number[]) => {
setWorkspaces(prev => prev.map(ws => {
if (ws.id !== workspaceId) return ws;
return { ...ws, root: updateWorkspaceSplitSizes(ws.root, splitId, sizes) };
}));
}, []);
// Split a session to create a workspace with the same host connection
// direction: 'horizontal' = split top/bottom, 'vertical' = split left/right
const splitSession = useCallback((
sessionId: string,
direction: SplitDirection,
options?: {
localShellType?: TerminalSession['shellType'];
inheritedCwd?: string;
},
) => {
const newSessionId = crypto.randomUUID();
const standaloneHint: SplitHint = {
direction,
position: direction === 'horizontal' ? 'bottom' : 'right',
};
const standaloneWorkspace = createWorkspaceEntity(sessionId, newSessionId, standaloneHint);
setSessions(prevSessions => {
const session = prevSessions.find(s => s.id === sessionId);
if (!session) return prevSessions;
// If session is already in a workspace, split within that workspace
if (session.workspaceId) {
// Create a new session with the same host
const newSession = createSplitTerminalSessionClone(session, {
id: newSessionId,
localShellType: options?.localShellType,
inheritedCwd: options?.inheritedCwd,
workspaceId: session.workspaceId,
});
// Add pane to existing workspace
const hint: SplitHint = {
direction,
position: direction === 'horizontal' ? 'bottom' : 'right',
targetSessionId: sessionId,
};
setWorkspaces(prevWorkspaces => {
return insertWorkspacePaneIfMissing(prevWorkspaces, session.workspaceId, newSession.id, hint);
});
return addTerminalSessionIfMissing(prevSessions, newSession);
}
// Session is standalone - create a new workspace
const newSession = createSplitTerminalSessionClone(session, {
id: newSessionId,
localShellType: options?.localShellType,
inheritedCwd: options?.inheritedCwd,
});
setWorkspaces(prev => addWorkspaceIfMissing(prev, standaloneWorkspace));
setActiveTabId(standaloneWorkspace.id);
return prevSessions.map(s => {
if (s.id === sessionId) {
return { ...s, workspaceId: standaloneWorkspace.id };
}
return s;
}).concat({ ...newSession, workspaceId: standaloneWorkspace.id });
});
}, [setActiveTabId]);
// Toggle workspace view mode between split and focus
const toggleWorkspaceViewMode = useCallback((workspaceId: string) => {
setWorkspaces(prev => prev.map(ws => {
if (ws.id !== workspaceId) return ws;
const currentMode = ws.viewMode || 'split';
const newMode: WorkspaceViewMode = currentMode === 'split' ? 'focus' : 'split';
// If switching to focus mode and no focused session, pick the first one
let focusedSessionId = ws.focusedSessionId;
if (newMode === 'focus' && !focusedSessionId) {
const sessionIds = collectSessionIds(ws.root);
focusedSessionId = sessionIds[0];
}
return { ...ws, viewMode: newMode, focusedSessionId };
}));
}, []);
// Set the focused session in a workspace (for focus mode)
const setWorkspaceFocusedSession = useCallback((workspaceId: string, sessionId: string) => {
// Pane click and focus both land here; an unchanged focus must keep the
// same array or every click re-renders the whole shell (and re-saves the
// session restore snapshot).
setWorkspaces(prev => setWorkspaceFocusedSessionId(prev, workspaceId, sessionId));
}, []);
const reorderWorkspaceSessions = useCallback((
workspaceId: string,
draggedSessionId: string,
targetSessionId: string,
position: 'before' | 'after' = 'before',
) => {
setWorkspaces(prev => prev.map(ws => {
if (ws.id !== workspaceId) return ws;
return {
...ws,
focusSessionOrder: reorderWorkspaceFocusSessionOrder(
ws.root,
ws.focusSessionOrder,
draggedSessionId,
targetSessionId,
position,
),
};
}));
}, []);
// Move focus between panes in a workspace
const moveFocusInWorkspace = useCallback((workspaceId: string, direction: FocusDirection): boolean => {
const workspace = workspacesRef.current.find(w => w.id === workspaceId);
if (!workspace) {
return false;
}
// Get current focused session, or first session if none focused
const sessionIds = collectSessionIds(workspace.root);
const currentFocused = workspace.focusedSessionId || sessionIds[0];
if (!currentFocused) {
return false;
}
// Find the next session in the given direction
const nextSessionId = getNextFocusSessionId(workspace.root, currentFocused, direction);
if (!nextSessionId) {
return false;
}
// Update focused session
setWorkspaces(prev => setWorkspaceFocusedSessionId(prev, workspaceId, nextSessionId));
return true;
}, []);
// Run a snippet on multiple target hosts - creates a focus mode workspace
const runSnippet = useCallback((snippet: Snippet, targetHosts: Host[], commandOverride?: string) => {
if (targetHosts.length === 0) return;
const resolvedCommand = commandOverride ?? snippet.command;
// Create sessions for each target host. createHostTerminalSession copies
// protocol, shellType, and local-shell metadata, so local vault hosts keep
// their transport info (session consumers treat a missing protocol as SSH).
const newSessions: TerminalSession[] = targetHosts.map(host =>
createHostTerminalSession(crypto.randomUUID(), host),
);
const sessionIds = newSessions.map(s => s.id);
// Create a focus mode workspace
const workspace = createWorkspaceFromSessionIds(sessionIds, {
title: snippet.label,
autoTitle: false,
viewMode: 'focus',
snippetId: snippet.id,
});
// Update sessions with workspaceId
const sessionsWithWorkspace = newSessions.map(s => ({
...s,
workspaceId: workspace.id,
...(isScriptSnippet(snippet)
? { pendingScriptId: snippet.id, pendingScript: snippet }
: {
startupCommand: resolvedCommand,
noAutoRun: snippet.noAutoRun,
multiLineRunMode: snippet.multiLineRunMode ?? 'paste',
}),
}));
setSessions(prev => [...prev, ...sessionsWithWorkspace]);
setWorkspaces(prev => [...prev, workspace]);
setActiveTabId(workspace.id);
}, [setActiveTabId]);
const orphanSessions = useMemo(() => sessions.filter(s => !s.workspaceId && !s.hiddenFromTabs), [sessions]);
// Read by action callbacks at call time so they keep a stable identity:
// orphanSessions changes with every session status update.
const orphanSessionsRef = useRef(orphanSessions);
orphanSessionsRef.current = orphanSessions;
const canUseGlobalBroadcast = orphanSessions.length >= 2;
// Losing the group must disarm broadcast before another tab can join it.
// Compose-bar send target follows the toolbar switch back to active-only.
if (!canUseGlobalBroadcast && (globalBroadcastEnabled || globalComposeSendToAll)) {
if (globalBroadcastEnabled) setGlobalBroadcastEnabled(false);
if (globalComposeSendToAll) setGlobalComposeSendToAll(false);
}
const openLogView = useCallback((log: ConnectionLog) => {
const tabId = getLogViewTabId(log);
setLogViews(prev => addLogView(prev, log));
setActiveTabId(tabId);
}, [setActiveTabId]);
const closeLogView = useCallback((logViewId: string) => {
setLogViews(prev => {
const updated = removeLogView(prev, logViewId);
if (activeTabStore.getActiveTabId() === logViewId) {
setActiveTabId(updated.length > 0 ? updated[updated.length - 1].id : 'vault');
}
return updated;
});
}, [setActiveTabId]);
// Copy a session - creates a new session with the same host connection
const copySession = useCallback((sessionId: string, options?: {
localShellType?: TerminalSession['shellType'];
inheritedCwd?: string;
/** When false, open a brand-new connection instead of reusing the source's SSH channel. */
reuseConnection?: boolean;
}) => {
// Pre-allocate the new id outside the updater so StrictMode's
// double-invocation of the functional updater doesn't mint two ids.
// Same snapshot the old closure captured (values at click time), read from
// refs so this action keeps a stable identity across session updates.
const tabIdsAtCall = [
...orphanSessionsRef.current.map(s => s.id),
...workspacesRef.current.map(w => w.id),
...logViewsRef.current.map(lv => lv.id),
];
const newSessionId = crypto.randomUUID();
setSessions(prevSessions => {
const session = prevSessions.find(s => s.id === sessionId);
// Source may have been closed between the user's action and this
// update running; in that case skip entirely — do NOT switch the
// active tab or insert into tabOrder, which would leave dangling ids.
if (!session) return prevSessions;
const newSession = createCopiedTerminalSessionClone(session, {
id: newSessionId,
localShellType: options?.localShellType,
inheritedCwd: options?.inheritedCwd,
reuseConnection: options?.reuseConnection,
});
// Schedule the activeTab + tabOrder updates only when creation
// actually happens. These nested setStates are idempotent, so
// StrictMode's double-invocation is harmless.
setActiveTabId(newSessionId);
setTabOrder(prevTabOrder => {
const allTabIds = tabIdsAtCall;
return insertCopiedTabOrderIdOnce(prevTabOrder, sessionId, newSessionId, allTabIds);
});
return [...prevSessions, newSession];
});
}, [setActiveTabId]);
// Copy a whole workspace (split/focus tab): clone every pane's session and
// reproduce the layout tree in a new tab.
const copyWorkspace = useCallback((workspaceId: string, options?: {
localShellType?: TerminalSession['shellType'];
perPaneCwd?: Record<string, string | undefined>;
}) => {
// Same snapshot the old closure captured (values at click time), read from
// refs so this action keeps a stable identity across session updates.
const tabIdsAtCall = [
...orphanSessionsRef.current.map(s => s.id),
...workspacesRef.current.map(w => w.id),
...logViewsRef.current.map(lv => lv.id),
];
const sourceWorkspace = workspacesRef.current.find(w => w.id === workspaceId);
if (!sourceWorkspace) return;
// Pre-mint the new workspace id and the old->new session-id map OUTSIDE the
// updater so StrictMode's double-invocation does not mint two sets of ids.
const newWorkspaceId = `ws-${crypto.randomUUID()}`;
const sessionIdMap = new Map<string, string>(
collectSessionIds(sourceWorkspace.root).map(id => [id, crypto.randomUUID()]),
);
const nodeIdMap = new Map<string, string>();
const preMintNodeIds = (node: WorkspaceNode) => {
nodeIdMap.set(node.id, crypto.randomUUID());
if (node.type === 'split') node.children.forEach(preMintNodeIds);
};
preMintNodeIds(sourceWorkspace.root);
const newSessionIds = new Set(sessionIdMap.values());
setSessions(prevSessions => {
// The cwd capture above is async. Avoid resurrecting a workspace that was
// closed (or structurally changed) while those probes were pending.
if (workspacesRef.current.find(w => w.id === workspaceId) !== sourceWorkspace) {
return prevSessions;
}
if (prevSessions.some(session => newSessionIds.has(session.id))) return prevSessions;
const built = buildCopiedWorkspace(sourceWorkspace, prevSessions, {
newWorkspaceId,
sessionIdMap,
localShellType: options?.localShellType,
perPaneCwd: options?.perPaneCwd,
nodeIdMap,
});
// Every source pane was closed between click and update — skip cleanly.
if (!built) return prevSessions;
// Nested idempotent setStates (mirrors copySession's pattern).
setWorkspaces(prev => addWorkspaceIfMissing(prev, built.newWorkspace));
setActiveTabId(newWorkspaceId);
setTabOrder(prevTabOrder => {
const allTabIds = tabIdsAtCall;
return insertCopiedTabOrderIdOnce(prevTabOrder, workspaceId, newWorkspaceId, allTabIds);
});
return [...prevSessions, ...built.newSessions];
});
}, [setActiveTabId]);
const createSessionFromCloneSource = useCallback((sourceSession: TerminalSession, options?: {
localShellType?: TerminalSession['shellType'];
}) => {
const newSessionId = crypto.randomUUID();
const newSession = createCopiedTerminalSessionClone(sourceSession, {
id: newSessionId,
localShellType: options?.localShellType,
});
delete newSession.workspaceId;
setSessions(prevSessions => {
if (prevSessions.some(session => session.id === newSessionId)) return prevSessions;
return [...prevSessions, newSession];
});
setTabOrder(prevTabOrder => [...prevTabOrder, newSessionId]);
setActiveTabId(newSessionId);
return newSessionId;
}, [setActiveTabId]);
const setWorkspaceComposeSendTarget = useCallback((workspaceId: string, target: 'all' | 'active') => {
setComposeSendAllWorkspaceIds((prev) => {
const hasAll = prev.has(workspaceId);
if ((target === 'all') === hasAll) return prev;
const next = new Set(prev);
if (target === 'all') next.add(workspaceId);
else next.delete(workspaceId);
return next;
});
}, []);
// Toggle broadcast mode for a workspace. Compose-bar send target follows.
const toggleBroadcast = useCallback((workspaceId: string) => {
const enabling = !broadcastWorkspaceIds.has(workspaceId);
setBroadcastWorkspaceIds(prev => {
const next = new Set(prev);
if (prev.has(workspaceId)) next.delete(workspaceId);
else next.add(workspaceId);
return next;
});
setWorkspaceComposeSendTarget(workspaceId, composeSendTargetAfterToolbarToggle(enabling));
}, [broadcastWorkspaceIds, setWorkspaceComposeSendTarget]);
// Check if a workspace has broadcast enabled
const isBroadcastEnabled = useCallback((workspaceId: string) => {
return broadcastWorkspaceIds.has(workspaceId);
}, [broadcastWorkspaceIds]);
const toggleComposeSendTarget = useCallback((workspaceId: string) => {
setWorkspaceComposeSendTarget(
workspaceId,
flipComposeSendTarget(composeSendAllWorkspaceIds.has(workspaceId) ? 'all' : 'active'),
);
}, [composeSendAllWorkspaceIds, setWorkspaceComposeSendTarget]);
const isComposeSendToAll = useCallback((workspaceId: string) => {
return composeSendAllWorkspaceIds.has(workspaceId);
}, [composeSendAllWorkspaceIds]);
// Toggle global broadcast mode for orphan sessions
const toggleGlobalBroadcast = useCallback(() => {
setGlobalBroadcastEnabled(prev => {
const next = !prev;
setGlobalComposeSendToAll(composeSendTargetAfterToolbarToggle(next) === 'all');
return next;
});
}, []);
const toggleGlobalComposeSendTarget = useCallback(() => {
setGlobalComposeSendToAll((prev) => flipComposeSendTarget(prev ? 'all' : 'active') === 'all');
}, []);
// Global broadcast enabled state - direct boolean value
const isGlobalBroadcastEnabled = globalBroadcastEnabled;
const isGlobalComposeSendToAll = globalComposeSendToAll;
const baseWorkTabIds = useMemo(() => [
...orphanSessions.map(s => s.id),
...workspaces.map(w => w.id),
...logViews.map(lv => lv.id),
], [orphanSessions, workspaces, logViews]);
const getOrderedWorkTabs = useCallback((additionalTabIds: readonly string[] = []) => {
const allTabIds = [...baseWorkTabIds, ...additionalTabIds];
return buildOrderedWorkTabIds(tabOrder, allTabIds);
}, [baseWorkTabIds, tabOrder]);
// Get ordered tabs: combines orphan sessions, workspaces, and log views in the custom order
const orderedTabs = useMemo(
() => getOrderedWorkTabs(),
[getOrderedWorkTabs],
);
const removeSessionFromWorkspace = useCallback((
sessionId: string,
tabInsertionTarget?: {
tabId: string;
position: 'before' | 'after';
additionalTabIds?: readonly string[];
},
) => {
// Detach from latest refs so continuous-render / memoized panes cannot
// act on a stale workspace snapshot and drop the terminal instead of
// restoring it as a standalone tab.
const result = detachSessionFromWorkspaceState({
sessions: sessionsRef.current,
workspaces: workspacesRef.current,
sessionId,
});
if (!result.changed) return;
setWorkspaces(result.workspaces);
setSessions(result.sessions);
setTabOrder((prevTabOrder) => {
const replacedOrder = replaceDissolvedWorkspaceTabOrder(
prevTabOrder,
result.dissolvedWorkspaceId,
result.replacementTabIds,
);
if (!tabInsertionTarget) return replacedOrder;
const allTabIds = [
...result.sessions.filter((s) => !s.workspaceId).map((s) => s.id),
...result.workspaces.map((w) => w.id),
...logViews.map((lv) => lv.id),
...(tabInsertionTarget.additionalTabIds ?? []),
];
return reorderWorkTabIds(
replacedOrder,
allTabIds,
sessionId,
tabInsertionTarget.tabId,
tabInsertionTarget.position,
);
});
if (result.activeTabId) setActiveTabId(result.activeTabId);
}, [logViews, setActiveTabId]);
const reorderTabs = useCallback((
draggedId: string,
targetId: string,
position: 'before' | 'after' = 'before',
additionalTabIds: readonly string[] = [],
) => {
if (draggedId === targetId) return;
setTabOrder(prevTabOrder => reorderWorkTabIds(
prevTabOrder,
[...baseWorkTabIds, ...additionalTabIds],
draggedId,
targetId,
position,
));
}, [baseWorkTabIds]);
return {
sessions,
workspaces,
// activeTabId removed - components should subscribe via useActiveTabId() from activeTabStore
setActiveTabId,
draggingSessionId,
setDraggingSessionId,
sessionRenameTarget,
sessionRenameValue,
setSessionRenameValue,
startSessionRename,
renameSessionInline,
submitSessionRename,
resetSessionRename,
workspaceRenameTarget,
workspaceRenameValue,
setWorkspaceRenameValue,
startWorkspaceRename,
submitWorkspaceRename,
resetWorkspaceRename,
createLocalTerminal,
createSerialSession,
connectToHost,
closeSession,
closeSessions,
closeWorkspace,
updateSessionStatus,
updateSessionFontSize,
clearSessionFontSizeOverride,
createWorkspaceWithHosts,
createWorkspaceFromTargets,
createWorkspaceFromSessions,
addSessionToWorkspace,
removeSessionFromWorkspace,
appendHostToWorkspace,
appendHostToWorkspaceConfirmed,
appendLocalTerminalToWorkspace,
updateSplitSizes,
splitSession,
toggleWorkspaceViewMode,
setWorkspaceFocusedSession,
reorderWorkspaceSessions,
moveFocusInWorkspace,
runSnippet,
orphanSessions,
canUseGlobalBroadcast,
// Broadcast mode
toggleBroadcast,
isBroadcastEnabled,
toggleComposeSendTarget,
isComposeSendToAll,
toggleGlobalBroadcast,
isGlobalBroadcastEnabled,
toggleGlobalComposeSendTarget,
isGlobalComposeSendToAll,
orderedTabs,
getOrderedWorkTabs,
reorderTabs,
// Log views
logViews,
openLogView,
closeLogView,
// Copy session
copySession,
copyWorkspace,
createSessionFromCloneSource,
updateSessionRestoreCwd,
getSessionRestoreCwd,
updateSessionDynamicTitle,
updateSessionCodingCliProvider,
};
};