Files
Netcatty/application/state/sessionWorkspaceDetach.ts
T
axe006 a5de52247e fix(ai): 关闭最后一个可见标签时不再回退到隐藏的 MCP 会话
resolveActiveTabAfterCloseSession 的 solo-session 兜底逻辑此前只
排除了归属 workspace 的会话,没有排除 hiddenFromTabs 的隐藏会话。
用户关闭最后一个可见标签、且存在静默运行的 MCP 会话时,兜底会把
该隐藏会话选为 activeTabId,导致它意外暴露在主窗口,违背静默设置
的初衷。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 10:59:34 +08:00

304 lines
9.3 KiB
TypeScript

import type { TerminalSession, Workspace } from "../../domain/models";
import { collectSessionIds, pruneWorkspaceNode } from "../../domain/workspace";
export type DetachSessionFromWorkspaceStateResult = {
changed: boolean;
sessions: TerminalSession[];
workspaces: Workspace[];
activeTabId?: string;
dissolvedWorkspaceId?: string;
replacementTabIds?: string[];
};
export type CloseSessionWorkspaceLayoutResult = {
workspaces: Workspace[];
removedWorkspaceId?: string;
dissolvedWorkspaceId?: string;
lastRemainingSessionId?: string;
};
export type CloseSessionsStateResult = {
sessions: TerminalSession[];
workspaces: Workspace[];
tabOrder: string[];
activeTabId?: string;
};
type DetachSessionFromWorkspaceStateOptions = {
sessions: TerminalSession[];
workspaces: Workspace[];
sessionId: string;
};
export function replaceDissolvedWorkspaceTabOrder(
tabOrder: readonly string[],
workspaceId: string | undefined,
replacementTabIds: readonly string[] | undefined,
): string[] {
if (!workspaceId || !replacementTabIds?.length) return [...tabOrder];
const uniqueReplacementIds = replacementTabIds.filter((tabId, index, list) => (
tabId && list.indexOf(tabId) === index
));
if (uniqueReplacementIds.length === 0) return [...tabOrder];
if (!tabOrder.includes(workspaceId)) {
const hasAllReplacementIds = uniqueReplacementIds.every((tabId) => tabOrder.includes(tabId));
return hasAllReplacementIds ? [...tabOrder] : [
...tabOrder,
...uniqueReplacementIds.filter((tabId) => !tabOrder.includes(tabId)),
];
}
const replacementIdSet = new Set(uniqueReplacementIds);
let inserted = false;
const nextOrder: string[] = [];
for (const tabId of tabOrder) {
if (tabId === workspaceId) {
if (!inserted) {
nextOrder.push(...uniqueReplacementIds);
inserted = true;
}
continue;
}
if (!replacementIdSet.has(tabId)) {
nextOrder.push(tabId);
}
}
return nextOrder;
}
export function closeSessionWorkspaceLayoutState(
workspaces: readonly Workspace[],
workspaceId: string | undefined,
sessionId: string,
): CloseSessionWorkspaceLayoutResult {
if (!workspaceId) return { workspaces: [...workspaces] };
let removedWorkspaceId: string | undefined;
let dissolvedWorkspaceId: string | undefined;
let lastRemainingSessionId: string | undefined;
const nextWorkspaces = workspaces
.map((workspace) => {
if (workspace.id !== workspaceId) return workspace;
const prunedRoot = pruneWorkspaceNode(workspace.root, sessionId);
if (!prunedRoot) {
removedWorkspaceId = workspace.id;
return null;
}
const remainingSessionIds = collectSessionIds(prunedRoot);
if (remainingSessionIds.length === 1) {
dissolvedWorkspaceId = workspace.id;
lastRemainingSessionId = remainingSessionIds[0];
return null;
}
return { ...workspace, root: prunedRoot };
})
.filter((workspace): workspace is Workspace => Boolean(workspace));
return {
workspaces: nextWorkspaces,
removedWorkspaceId,
dissolvedWorkspaceId,
lastRemainingSessionId,
};
}
/**
* Apply a close-session action to the session list using the layout result.
* When a 2-pane workspace dissolves to one terminal, that remaining session
* must become an orphan tab — not stay bound to a deleted workspaceId.
*/
export function applyCloseSessionToSessions(
sessions: readonly TerminalSession[],
sessionId: string,
layoutResult: Pick<CloseSessionWorkspaceLayoutResult, "lastRemainingSessionId">,
): TerminalSession[] {
const remaining = sessions.filter((session) => session.id !== sessionId);
const lastRemainingSessionId = layoutResult.lastRemainingSessionId;
if (!lastRemainingSessionId) return remaining;
return remaining.map((session) => (
session.id === lastRemainingSessionId
? { ...session, workspaceId: undefined }
: session
));
}
export function resolveActiveTabAfterCloseSession({
currentActiveTabId,
closedSessionId,
workspaceId,
layoutResult,
remainingSessions,
}: {
currentActiveTabId: string | null;
closedSessionId: string;
workspaceId: string | undefined;
layoutResult: CloseSessionWorkspaceLayoutResult;
remainingSessions: readonly TerminalSession[];
}): string | null {
const fallbackWorkspace = layoutResult.workspaces[layoutResult.workspaces.length - 1];
const fallbackSolo = remainingSessions.filter((session) => !session.workspaceId && !session.hiddenFromTabs).slice(-1)[0];
const fallback = layoutResult.lastRemainingSessionId
?? fallbackWorkspace?.id
?? fallbackSolo?.id
?? "vault";
if (
currentActiveTabId === closedSessionId
|| (layoutResult.dissolvedWorkspaceId && currentActiveTabId === layoutResult.dissolvedWorkspaceId)
|| (layoutResult.removedWorkspaceId && currentActiveTabId === layoutResult.removedWorkspaceId)
|| (workspaceId && currentActiveTabId === workspaceId && !layoutResult.workspaces.some((ws) => ws.id === workspaceId))
) {
return fallback;
}
return null;
}
export function closeSessionsState({
sessions,
workspaces,
sessionIds,
currentActiveTabId,
tabOrder,
}: {
sessions: readonly TerminalSession[];
workspaces: readonly Workspace[];
sessionIds: readonly string[];
currentActiveTabId: string | null;
tabOrder: readonly string[];
}): CloseSessionsStateResult {
let nextSessions = [...sessions];
let nextWorkspaces = [...workspaces];
let nextTabOrder = [...tabOrder];
let nextActiveTabId = currentActiveTabId;
let resolvedActiveTabId: string | undefined;
for (const sessionId of new Set(sessionIds)) {
const targetSession = nextSessions.find((session) => session.id === sessionId);
if (!targetSession) continue;
const workspaceId = targetSession.workspaceId;
const layoutResult = closeSessionWorkspaceLayoutState(
nextWorkspaces,
workspaceId,
sessionId,
);
nextWorkspaces = layoutResult.workspaces;
nextSessions = applyCloseSessionToSessions(nextSessions, sessionId, layoutResult);
if (layoutResult.dissolvedWorkspaceId && layoutResult.lastRemainingSessionId) {
nextTabOrder = replaceDissolvedWorkspaceTabOrder(
nextTabOrder,
layoutResult.dissolvedWorkspaceId,
[layoutResult.lastRemainingSessionId],
);
}
const activeTabAfterClose = resolveActiveTabAfterCloseSession({
currentActiveTabId: nextActiveTabId,
closedSessionId: sessionId,
workspaceId,
layoutResult,
remainingSessions: nextSessions,
});
if (activeTabAfterClose) {
nextActiveTabId = activeTabAfterClose;
resolvedActiveTabId = activeTabAfterClose;
}
}
return {
sessions: nextSessions,
workspaces: nextWorkspaces,
tabOrder: nextTabOrder,
activeTabId: resolvedActiveTabId,
};
}
export function detachSessionFromWorkspaceState({
sessions,
workspaces,
sessionId,
}: DetachSessionFromWorkspaceStateOptions): DetachSessionFromWorkspaceStateResult {
const session = sessions.find((candidate) => candidate.id === sessionId);
if (!session?.workspaceId) {
return { changed: false, sessions, workspaces };
}
const workspaceId = session.workspaceId;
const targetWorkspace = workspaces.find((workspace) => workspace.id === workspaceId);
if (!targetWorkspace) {
return { changed: false, sessions, workspaces };
}
const prunedRoot = pruneWorkspaceNode(targetWorkspace.root, sessionId);
let nextSessions = sessions.map((candidate) => (
candidate.id === sessionId ? { ...candidate, workspaceId: undefined } : candidate
));
if (!prunedRoot) {
return {
changed: true,
sessions: nextSessions,
workspaces: workspaces.filter((workspace) => workspace.id !== workspaceId),
activeTabId: sessionId,
dissolvedWorkspaceId: workspaceId,
replacementTabIds: [sessionId],
};
}
const remainingSessionIds = collectSessionIds(prunedRoot);
if (remainingSessionIds.length === 1) {
nextSessions = nextSessions.map((candidate) => (
candidate.id === remainingSessionIds[0] ? { ...candidate, workspaceId: undefined } : candidate
));
return {
changed: true,
sessions: nextSessions,
workspaces: workspaces.filter((workspace) => workspace.id !== workspaceId),
activeTabId: sessionId,
dissolvedWorkspaceId: workspaceId,
replacementTabIds: [sessionId, ...remainingSessionIds],
};
}
const nextFocusedSessionId = remainingSessionIds.includes(targetWorkspace.focusedSessionId)
? targetWorkspace.focusedSessionId
: remainingSessionIds[0];
const nextFocusSessionOrder = (targetWorkspace.focusSessionOrder ?? [])
.filter((candidateId, index, list) => (
candidateId !== sessionId &&
remainingSessionIds.includes(candidateId) &&
list.indexOf(candidateId) === index
));
for (const remainingSessionId of remainingSessionIds) {
if (!nextFocusSessionOrder.includes(remainingSessionId)) {
nextFocusSessionOrder.push(remainingSessionId);
}
}
return {
changed: true,
sessions: nextSessions,
workspaces: workspaces.map((workspace) => (
workspace.id === workspaceId
? {
...workspace,
root: prunedRoot,
focusedSessionId: nextFocusedSessionId,
focusSessionOrder: nextFocusSessionOrder,
}
: workspace
)),
activeTabId: sessionId,
};
}