Files
Netcatty/application/state/useSessionState.ts
T
陈大猫 6b9b71ac8a feat(scripts): terminal script automation, recording, and side panel (#1763)
* feat(scripts): add terminal script automation, recording, and side panel

Introduce nct script runtime in the main process with terminal-side recording,
execution overlay, Monaco editor, connect-script queues, and scripts side panel
integration. Fix recording to wire scriptRecorderRef into xterm, use waitForPrompt
for root/user shells, and save recorded scripts through the global editor dialog.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): address Bugbot review findings for recording and side panel

Refresh script run state in side panel memo, guard toolbar recording against
multi-session conflicts, reset connect scripts on reconnect, resolve focused
session via getActiveTerminalSessionId, and avoid wiping host edit form when
snippets list updates.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): prevent pending script re-run and improve workspace run feedback

Track one-shot pendingScript execution separately from onConnect queue so
reconnects do not replay Run-now scripts. Fall back to focused session when
running on all tabs outside a workspace, and toast when every tab is still
connecting.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): consume connect-script window once and filter serial hosts

Mark the onConnect script evaluation as consumed even when the queue is
empty so later snippet/host edits do not rerun automation mid-session.
Restore serial-host filtering in SelectHostPanel confirm handler.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): grace period before giving up on connect-script queue

Only mark onConnect automation as consumed after scripts run or a short
post-connect grace window expires, so late-loading snippets still trigger
while mid-session Vault edits do not.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): gate connect-script consumption on vault hydration

Replace the grace timer with vault-init and unresolved host script binding
checks so late-loading snippets still run onConnect automation while
mid-session edits stay suppressed. Clear pending script run state on
disconnect so reconnect retries workspace pending scripts.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): honor run targets and retry failed connect automation

Route QuickScriptEditorDialog Run through onRunSnippet for multi-host
workspaces. Defer connect/pending script completion markers until each
script succeeds and retry the queue after failures.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): stabilize pending runs and global connect queue sync

Keep pending script run state across reconnects, run onConnect scripts
before one-shot workspace pending scripts, and strip per-host queue entries
when an onConnect script targets all hosts.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): defer host queue snippet sync and guard connect runs

Persist snippet target links only when the host panel is saved, block
duplicate connect-script scheduling while a batch is running, and require
runnable hosts before showing Run in the snippets editor footer.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): retry connect failures and surface output-trigger errors

Use a retry tick in connect-script effect deps, toast automation failures
from output triggers, and require runnable hosts before QuickScript Run.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): track id-less pending runs and re-arm output triggers

Remember anonymous pendingScript objects after first run and allow
onOutput triggers to fire again when new terminal output arrives.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): unblock paused connect runs and pass session meta

Resolve waitForScriptRun on pause, run workspace scripts per session
with hostname context, defer output-trigger arming until runs start, and
add noRunnableHosts i18n for QuickScript Run.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): serialize output triggers and defer empty connect finalize

Fire at most one onOutput trigger per scan while a run is active, and
only finalize an empty connect queue after vault snippets are loaded.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): guard output launch race and script run waits

Block overlapping onOutput launches, timeout stuck waitForScriptRun
promises, and disable vault Run when no connectable targets exist.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): match output triggers on new chunks only

Scan onOutput patterns against recent terminal output instead of buffer
length dedupe, and restore overflow-visible on SelectHostPanel new-host flow.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): await sequential workspace runs and output launch

Wait for each tab script to finish in sequential workspace mode, and hold
output-trigger launch guard until the session run becomes active.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): wait through pause and hold output trigger until done

Keep connect and sequential workspace queues blocked until scripts
complete or fail, and retain output-trigger guard until the launched run
finishes.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): harden output trigger wait helpers

Add explicit timeouts to output-trigger wait helpers and stop rescanning
the buffer after script completion to avoid sticky-match rerun loops.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): allow empty connect queue and match output in new chunks

Treat an empty connectScriptIds array as an explicit cleared queue instead
of re-migrating from snippet targets, and only fire onOutput triggers when
the latest terminal output contains the pattern.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): require fresh output overlap for onOutput triggers

Only fire output-matched scripts when the regex match extends into the
latest terminal chunk, preventing reruns from stale buffer text.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): promote manual scripts added to host connect queue

Diff connect queue changes from raw connectScriptIds so saving a host
updates manual scripts to onConnect when they are newly queued.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): dedupe output triggers by match position

Track the buffer end offset of each output trigger match and ignore
stale re-scans, with a longer wait for run registration before retry.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): record output trigger matches only after run starts

Propagate automation launch failures to output triggers and commit match
dedupe only once the session run is registered as active.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): address Codex review on wait cursor and sensitive codegen

Track scan offset so waitFor/waitForAny only match output after the previous wait, and emit sendLine after sensitive prompt in recorded scripts.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): harden onOutput triggers against PTY echo and local input

Filter server output before pattern matching so onOutput fires only after
Enter on real stdout, not on keystroke/paste echo, OSC/SGR sequences, or
alternate-screen apps. Add dev diagnostics and scope triggers to the active
session when no explicit host targets are configured.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(scripts): remove onOutput trigger debug logging

Drop temporary diagnostics module and console traces now that PTY echo
filtering is verified; keep the production trigger pipeline unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 23:48:12 +08:00

1232 lines
44 KiB
TypeScript

import { MouseEvent,useCallback,useEffect,useMemo,useRef,useState } from 'react';
import { ConnectionLog,Host,SerialConfig,Snippet,TerminalSession,Workspace,WorkspaceViewMode } from '../../domain/models';
import { addLogView, getLogViewTabId, removeLogView, type LogView } from './logViewState';
import { createHostTerminalSession, createLocalTerminalSession, createSerialTerminalSession, type LocalTerminalOptions } from './sessionFactories';
import { isScriptSnippet } from '../../domain/snippetScript.ts';
import {
appendPaneToWorkspaceRoot,
collectSessionIds,
createWorkspaceFromSessions as createWorkspaceEntity,
createWorkspaceFromSessionIds,
FocusDirection,
getNextFocusSessionId,
insertPaneIntoWorkspace,
pruneWorkspaceNode,
reorderWorkspaceFocusSessionOrder,
SplitDirection,
SplitHint,
updateWorkspaceSplitSizes,
} from '../../domain/workspace';
import { clearSessionFontSizeOverride as clearSessionFontSizeOverrideFields } from '../../domain/terminalAppearance';
import { buildOrderedWorkTabIds, reorderWorkTabIds } from '../app/workTabSurface';
import { activeTabStore } from './activeTabStore';
import {
closeSessionWorkspaceLayoutState,
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,
shouldPersistSessionRestoreState,
updateRestoredSessionStatusState,
} from './sessionRestoreState';
import { resolveRestorePreviousSessionSetting } from './sessionRestoreSettings';
import type { CodingCliProviderId } from '../../domain/codingCliProviders';
import { normalizeCodingCliDynamicTitleForStorage } from '../../domain/codingCliTitleParse';
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());
// Log views: stores open log replay tabs
const [logViews, setLogViews] = useState<LogView[]>([]);
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]);
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;
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;
buildAndWriteSessionRestorePayload({
restoreEnabled: resolveRestorePreviousSessionSetting(
localStorageAdapter.readBoolean(STORAGE_KEY_RESTORE_PREVIOUS_SESSION),
),
clearOnEmpty,
sessions: sessionsForRestore,
workspaces: workspacesRef.current,
tabOrder: tabOrderRef.current,
activeTabId: activeTabStore.getActiveTabId(),
storage: sessionRestoreStorage,
});
};
const schedulePersist = () => {
if (timeout !== undefined) {
window.clearTimeout(timeout);
}
timeout = window.setTimeout(() => {
timeout = undefined;
persistNow();
}, 250);
};
schedulePersist();
scheduleSessionRestorePersistRef.current = schedulePersist;
const unsubscribeActiveTab = activeTabStore.subscribeSync(schedulePersist);
const handlePageHide = () => {
if (timeout !== undefined) {
window.clearTimeout(timeout);
timeout = undefined;
}
persistNow();
};
window.addEventListener("pagehide", handlePageHide);
window.addEventListener("beforeunload", handlePageHide);
return () => {
scheduleSessionRestorePersistRef.current = () => {};
if (timeout !== undefined) {
window.clearTimeout(timeout);
}
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);
} else {
sessionRestoreCwdByIdRef.current.delete(sessionId);
}
scheduleSessionRestorePersistRef.current();
}, []);
const updateSessionDynamicTitle = useCallback((sessionId: string, title: string | null) => {
const normalizedTitle = title ? normalizeCodingCliDynamicTitleForStorage(title) : '';
const nextTitle = normalizedTitle.length > 0 ? normalizedTitle : null;
setSessions((prev) => {
const session = prev.find((candidate) => candidate.id === sessionId);
if (!session) return prev;
if ((session.dynamicTitle ?? null) === nextTitle) return prev;
return prev.map((candidate) => {
if (candidate.id !== sessionId) return candidate;
if (!nextTitle) {
const { dynamicTitle: _removed, ...rest } = candidate;
return rest;
}
return { ...candidate, dynamicTitle: nextTitle };
});
});
}, []);
const updateSessionCodingCliProvider = useCallback((
sessionId: string,
providerId: CodingCliProviderId | null,
) => {
setSessions((prev) => {
const session = prev.find((candidate) => candidate.id === sessionId);
if (!session) return prev;
if ((session.codingCliProviderId ?? null) === providerId) return prev;
return prev.map((candidate) => {
if (candidate.id !== sessionId) return candidate;
if (!providerId) {
const { codingCliProviderId: _removed, ...rest } = candidate;
return rest;
}
return { ...candidate, codingCliProviderId: 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) => {
const newSession = createHostTerminalSession(crypto.randomUUID(), host);
setSessions(prev => [...prev, newSession]);
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) => {
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 closeSession = useCallback((sessionId: string, e?: MouseEvent) => {
e?.stopPropagation();
// Pre-compute outside the setSessions updater so we don't depend on React
// having run the updater by the time we queue the microtask. React 18+ does
// not guarantee updater execution timing under concurrent scheduling.
const sessionBeingClosed = sessions.find(s => s.id === sessionId);
const workspaceIdToMaybeClose =
sessionBeingClosed?.workspaceId &&
sessions.every(s => s.id === sessionId || s.workspaceId !== sessionBeingClosed.workspaceId)
? sessionBeingClosed.workspaceId
: undefined;
setSessions(prevSessions => {
const targetSession = prevSessions.find(s => s.id === sessionId);
const wsId = targetSession?.workspaceId;
setWorkspaces(prevWorkspaces => {
const {
workspaces: nextWorkspaces,
removedWorkspaceId,
dissolvedWorkspaceId,
lastRemainingSessionId,
} = closeSessionWorkspaceLayoutState(prevWorkspaces, wsId, sessionId);
const remainingSessions = prevSessions.filter(s => s.id !== sessionId);
const fallbackWorkspace = nextWorkspaces[nextWorkspaces.length - 1];
const fallbackSolo = remainingSessions.filter(s => !s.workspaceId).slice(-1)[0];
const currentActiveTabId = activeTabStore.getActiveTabId();
const getFallback = () => {
if (lastRemainingSessionId) return lastRemainingSessionId;
if (fallbackWorkspace) return fallbackWorkspace.id;
if (fallbackSolo) return fallbackSolo.id;
return 'vault';
};
if (dissolvedWorkspaceId && lastRemainingSessionId) {
setTabOrder(prevTabOrder => replaceDissolvedWorkspaceTabOrder(
prevTabOrder,
dissolvedWorkspaceId,
[lastRemainingSessionId],
));
}
if (dissolvedWorkspaceId && currentActiveTabId === dissolvedWorkspaceId) {
setActiveTabId(getFallback());
} else if (currentActiveTabId === sessionId) {
setActiveTabId(getFallback());
} else if (removedWorkspaceId && currentActiveTabId === removedWorkspaceId) {
setActiveTabId(getFallback());
} else if (wsId && currentActiveTabId === wsId && !nextWorkspaces.find(w => w.id === wsId)) {
setActiveTabId(getFallback());
}
return nextWorkspaces;
});
// Check if we need to dissolve a workspace (convert remaining session to orphan)
if (targetSession?.workspaceId) {
const ws = workspaces.find(w => w.id === targetSession.workspaceId);
if (ws) {
const pruned = pruneWorkspaceNode(ws.root, sessionId);
if (pruned) {
const remainingSessionIds = collectSessionIds(pruned);
if (remainingSessionIds.length === 1) {
// Dissolve: remove workspaceId from the remaining session
return prevSessions
.filter(s => s.id !== sessionId)
.map(s => remainingSessionIds.includes(s.id) ? { ...s, workspaceId: undefined } : s);
}
}
}
}
return prevSessions.filter(s => s.id !== sessionId);
});
if (workspaceIdToMaybeClose) {
queueMicrotask(() => closeWorkspace(workspaceIdToMaybeClose!));
}
}, [sessions, workspaces, setActiveTabId, closeWorkspace]);
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(() => {
setWorkspaceRenameValue(prevValue => {
const name = prevValue.trim();
if (!name) return prevValue;
setWorkspaceRenameTarget(prevTarget => {
if (!prevTarget) return prevTarget;
setWorkspaces(prev => prev.map(w => w.id === prevTarget.id ? { ...w, title: name } : w));
return null;
});
return '';
});
}, []);
const resetWorkspaceRename = useCallback(() => {
setWorkspaceRenameTarget(null);
setWorkspaceRenameValue('');
}, []);
const createWorkspaceWithHosts = useCallback((name: string, hosts: Host[]) => {
if (hosts.length === 0) return;
// Create sessions for each host
const newSessions: TerminalSession[] = hosts.map(host => {
// Handle serial hosts specially
if (host.protocol === 'serial') {
const serialConfig: SerialConfig = host.serialConfig || {
path: host.hostname,
baudRate: host.port || 115200,
dataBits: 8,
stopBits: 1,
parity: 'none',
flowControl: 'none',
localEcho: false,
lineMode: false,
};
const portName = serialConfig.path.split('/').pop() || serialConfig.path;
return {
id: crypto.randomUUID(),
hostId: host.id,
hostLabel: host.label || `Serial: ${portName}`,
hostname: serialConfig.path,
username: '',
status: 'connecting',
protocol: 'serial',
serialConfig: serialConfig,
charset: host.charset,
};
}
return {
id: crypto.randomUUID(),
hostId: host.id,
hostLabel: host.label,
hostname: host.hostname,
username: host.username,
status: 'connecting',
protocol: host.protocol,
port: host.port,
moshEnabled: host.moshEnabled,
etEnabled: host.etEnabled,
charset: host.charset,
};
});
const sessionIds = newSessions.map(s => s.id);
// Create workspace
const workspace = createWorkspaceFromSessionIds(sessionIds, {
title: name,
viewMode: 'split',
});
// Assign workspaceId to sessions
const sessionsWithWorkspace = newSessions.map(s => ({
...s,
workspaceId: 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 = 'Workspace'): string | null => {
if (targets.length === 0) return null;
const newSessions: TerminalSession[] = targets.map((target) => {
if (target.kind === 'local') {
const sessionId = crypto.randomUUID();
return {
id: sessionId,
hostId: `local-${sessionId}`,
hostLabel: target.shellName || 'Local Terminal',
hostname: 'localhost',
username: 'local',
status: 'connecting',
protocol: 'local',
shellType: target.shellType,
localShell: target.shell,
localShellArgs: target.shellArgs,
localShellName: target.shellName,
localShellIcon: target.shellIcon,
};
}
const host = target.host;
if (host.protocol === 'serial') {
const serialConfig: SerialConfig = host.serialConfig || {
path: host.hostname,
baudRate: host.port || 115200,
dataBits: 8,
stopBits: 1,
parity: 'none',
flowControl: 'none',
localEcho: false,
lineMode: false,
};
const portName = serialConfig.path.split('/').pop() || serialConfig.path;
return {
id: crypto.randomUUID(),
hostId: host.id,
hostLabel: host.label || `Serial: ${portName}`,
hostname: serialConfig.path,
username: '',
status: 'connecting',
protocol: 'serial',
serialConfig,
charset: host.charset,
};
}
return {
id: crypto.randomUUID(),
hostId: host.id,
hostLabel: host.label,
hostname: host.hostname,
username: host.username,
status: 'connecting',
protocol: host.protocol,
port: host.port,
moshEnabled: host.moshEnabled,
etEnabled: host.etEnabled,
charset: host.charset,
};
});
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: name,
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 createWorkspaceFromSessions = useCallback((
baseSessionId: string,
joiningSessionId: string,
hint: SplitHint
) => {
if (!hint || baseSessionId === joiningSessionId) return;
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;
const newWorkspace = createWorkspaceEntity(baseSessionId, joiningSessionId, hint);
setWorkspaces(prev => [...prev, newWorkspace]);
setActiveTabId(newWorkspace.id);
return prevSessions.map(s => {
if (s.id === baseSessionId || s.id === joiningSessionId) {
return { ...s, workspaceId: newWorkspace.id };
}
return s;
});
});
}, [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 prevWorkspaces.map(ws => {
if (ws.id !== workspaceId) return ws;
return { ...ws, root: insertPaneIntoWorkspace(ws.root, sessionId, hint) };
});
});
setActiveTabId(workspaceId);
return prevSessions.map(s => s.id === sessionId ? { ...s, workspaceId } : s);
});
}, [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 => {
// Serial hosts use a different session constructor; they currently
// only enter workspaces via createSerialSession + drag, so reject
// them here to avoid a partially-constructed session.
if (host.protocol === 'serial') return 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: TerminalSession = {
id: newSessionId,
hostId: host.id,
hostLabel: host.label,
hostname: host.hostname,
username: host.username,
status: 'connecting',
protocol: host.protocol,
port: host.port,
moshEnabled: host.moshEnabled,
etEnabled: host.etEnabled,
charset: host.charset,
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 => s.some(x => x.id === newSessionId) ? s : [...s, newSession]);
setActiveTabId(workspaceId);
return prev.map(ws => {
if (ws.id !== workspaceId) return ws;
return {
...ws,
root: appendPaneToWorkspaceRoot(ws.root, newSessionId, direction),
focusedSessionId: newSessionId,
};
});
});
return newSessionId;
}, [setActiveTabId]);
// 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 localHostId = `local-${newSessionId}`;
const newSession: TerminalSession = {
id: newSessionId,
hostId: localHostId,
hostLabel: options?.shellName || 'Local Terminal',
hostname: 'localhost',
username: 'local',
status: 'connecting',
protocol: 'local',
shellType: options?.shellType,
localShell: options?.shell,
localShellArgs: options?.shellArgs,
localShellName: options?.shellName,
localShellIcon: options?.shellIcon,
workspaceId,
};
setWorkspaces(prev => {
const target = prev.find(w => w.id === workspaceId);
if (!target) return prev;
setSessions(s => s.some(x => x.id === newSessionId) ? s : [...s, newSession]);
setActiveTabId(workspaceId);
return prev.map(ws => {
if (ws.id !== workspaceId) return ws;
return {
...ws,
root: appendPaneToWorkspaceRoot(ws.root, newSessionId, direction),
focusedSessionId: newSessionId,
};
});
});
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'];
},
) => {
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: crypto.randomUUID(),
localShellType: options?.localShellType,
workspaceId: session.workspaceId,
});
// Add pane to existing workspace
const hint: SplitHint = {
direction,
position: direction === 'horizontal' ? 'bottom' : 'right',
targetSessionId: sessionId,
};
setWorkspaces(prevWorkspaces => {
return prevWorkspaces.map(ws => {
if (ws.id !== session.workspaceId) return ws;
return { ...ws, root: insertPaneIntoWorkspace(ws.root, newSession.id, hint) };
});
});
return [...prevSessions, newSession];
}
// Session is standalone - create a new workspace
const newSession = createSplitTerminalSessionClone(session, {
id: crypto.randomUUID(),
localShellType: options?.localShellType,
});
const hint: SplitHint = {
direction,
position: direction === 'horizontal' ? 'bottom' : 'right',
};
const newWorkspace = createWorkspaceEntity(sessionId, newSession.id, hint);
setWorkspaces(prev => [...prev, newWorkspace]);
setActiveTabId(newWorkspace.id);
return prevSessions.map(s => {
if (s.id === sessionId) {
return { ...s, workspaceId: newWorkspace.id };
}
return s;
}).concat({ ...newSession, workspaceId: newWorkspace.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) => {
setWorkspaces(prev => prev.map(ws => {
if (ws.id !== workspaceId) return ws;
return { ...ws, focusedSessionId: 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 = workspaces.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 => prev.map(ws => {
if (ws.id !== workspaceId) return ws;
return { ...ws, focusedSessionId: nextSessionId };
}));
return true;
}, [workspaces]);
// 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
const newSessions: TerminalSession[] = targetHosts.map(host => ({
id: crypto.randomUUID(),
hostId: host.id,
hostLabel: host.label,
hostname: host.hostname,
username: host.username,
status: 'connecting' as const,
charset: host.charset,
// workspaceId will be set after workspace is created
}));
const sessionIds = newSessions.map(s => s.id);
// Create a focus mode workspace
const workspace = createWorkspaceFromSessionIds(sessionIds, {
title: snippet.label,
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,
}),
}));
setSessions(prev => [...prev, ...sessionsWithWorkspace]);
setWorkspaces(prev => [...prev, workspace]);
setActiveTabId(workspace.id);
}, [setActiveTabId]);
const orphanSessions = useMemo(() => sessions.filter(s => !s.workspaceId), [sessions]);
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'];
}) => {
// Pre-allocate the new id outside the updater so StrictMode's
// double-invocation of the functional updater doesn't mint two ids.
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,
});
// 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 => {
// Fast path: source is already tracked in tabOrder — splice directly.
const directIdx = prevTabOrder.indexOf(sessionId);
if (directIdx !== -1) {
const next = [...prevTabOrder];
next.splice(directIdx + 1, 0, newSessionId);
return next;
}
// Fallback: source is only in the derived tab collections. Rebuild the
// effective order (same pattern as reorderTabs) to locate its position.
const allTabIds = [
...orphanSessions.map(s => s.id),
...workspaces.map(w => w.id),
...logViews.map(lv => lv.id),
];
const allTabIdSet = new Set(allTabIds);
const orderedIds = prevTabOrder.filter(id => allTabIdSet.has(id));
const orderedIdSet = new Set(orderedIds);
const newIds = allTabIds.filter(id => !orderedIdSet.has(id));
const currentOrder = [...orderedIds, ...newIds];
const sourceIdx = currentOrder.indexOf(sessionId);
if (sourceIdx === -1) return [...prevTabOrder, newSessionId];
const next = [...currentOrder];
next.splice(sourceIdx + 1, 0, newSessionId);
return next;
});
return [...prevSessions, newSession];
});
}, [orphanSessions, workspaces, logViews, 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]);
// Toggle broadcast mode for a workspace
const toggleBroadcast = useCallback((workspaceId: string) => {
setBroadcastWorkspaceIds(prev => {
const next = new Set(prev);
if (next.has(workspaceId)) {
next.delete(workspaceId);
} else {
next.add(workspaceId);
}
return next;
});
}, []);
// Check if a workspace has broadcast enabled
const isBroadcastEnabled = useCallback((workspaceId: string) => {
return broadcastWorkspaceIds.has(workspaceId);
}, [broadcastWorkspaceIds]);
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[];
},
) => {
setSessions(prevSessions => {
const result = detachSessionFromWorkspaceState({
sessions: prevSessions,
workspaces: workspacesRef.current,
sessionId,
});
if (!result.changed) return prevSessions;
setWorkspaces(result.workspaces);
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);
return result.sessions;
});
}, [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,
closeWorkspace,
updateSessionStatus,
updateSessionFontSize,
clearSessionFontSizeOverride,
createWorkspaceWithHosts,
createWorkspaceFromTargets,
createWorkspaceFromSessions,
addSessionToWorkspace,
removeSessionFromWorkspace,
appendHostToWorkspace,
appendLocalTerminalToWorkspace,
updateSplitSizes,
splitSession,
toggleWorkspaceViewMode,
setWorkspaceFocusedSession,
reorderWorkspaceSessions,
moveFocusInWorkspace,
runSnippet,
orphanSessions,
// Broadcast mode
toggleBroadcast,
isBroadcastEnabled,
orderedTabs,
getOrderedWorkTabs,
reorderTabs,
// Log views
logViews,
openLogView,
closeLogView,
// Copy session
copySession,
createSessionFromCloneSource,
updateSessionRestoreCwd,
updateSessionDynamicTitle,
updateSessionCodingCliProvider,
};
};