fix(sessions): keep missing-worktree relocation manual

Remove automatic moves on session activation, terminal failures, and archive restoration while preserving manual moves and worktree deletion.

Replace directory listing probes with a stat-only endpoint using Node built-ins, including an isolated module-load regression test for packaged desktop.

Validation: focused session, worktree, filesystem, localization, and bridge tests; workspace type-check and lint; web and VS Code builds. Desktop startup and behavior verified by the maintainer.
This commit is contained in:
Bohdan Triapitsyn
2026-09-07 02:10:06 +03:00
parent eb6f7b0904
commit 3132d1361a
32 changed files with 211 additions and 763 deletions
@@ -28,7 +28,6 @@ import { useShallow } from 'zustand/react/shallow';
import {
listProjectWorktrees,
partitionWorktreesByRegisteredProject,
subscribeWorktreeTopologyChanged,
worktreeMapsEqual,
} from '@/lib/worktrees/worktreeManager';
import { checkIsGitRepository } from '@/lib/gitApi';
@@ -324,11 +323,6 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
});
}, [isVSCode]);
React.useEffect(() => {
if (isVSCode) return;
return subscribeWorktreeTopologyChanged(() => requestWorktreeDiscovery());
}, [isVSCode]);
const isDesktopShellRuntime = React.useMemo(() => isDesktopShell(), []);
const { isTablet } = useDeviceInfo();
@@ -67,7 +67,7 @@ make every row observe unrelated streaming updates.
- Folder membership may contain both a parent session and its descendants. Rendering treats only the highest assigned ancestors as folder roots because their normal session trees already include assigned descendants; persisted membership remains unchanged for cleanup and move semantics.
- Sidebar selection holds the clicked row's viewport position across navigation-driven sidebar updates. Wheel or touch input cancels the hold immediately, so programmatic compensation never fights intentional scrolling.
- Global session subscriptions are structural: create/delete, title, share, archive, directory, parent, and slug changes invalidate the tree. Recency-only `time.updated` changes do not trigger a rebuild. The separate lifecycle rank invalidates ordering only on `settled ↔ active` transitions, with root sessions ranked among roots and child sessions only among siblings of the same parent.
- A worktree git still registers but whose directory is gone (`prunable` in `git worktree list`) stays in the topology with `worktreeStatus: 'missing'` and a warning icon on its group header. Dropping it would hide every session that lived there, and a hidden session cannot be opened, so it could never be relocated. Opening one of those sessions relocates it to the project root (`recoverMissingSessionDirectory`), and the empty group is removed through the ordinary worktree delete action, which `git worktree remove --force` accepts for a missing directory. Topology refresh stays event-driven: besides `session-created`, the sidebar rediscovers on `subscribeWorktreeTopologyChanged`, which the relocation raises after the server confirmed a directory missing. No idle polling is added.
- A worktree Git still registers but whose directory is gone (`prunable` in `git worktree list`) stays in the topology with `worktreeStatus: 'missing'` and a warning icon on its group header. Its sessions remain accessible for manual movement or archiving through worktree deletion. Opening a session does not move it. The ordinary worktree delete action accepts a missing directory. Topology discovery remains event-driven, including `session-created`, with no idle polling.
- Opening the root-session `Move to worktree` submenu force-refreshes the owning project's worktree topology so externally created worktrees appear without a full reload. While that refresh runs, the menu keeps the last known primary/linked topology visible; if the refresh fails, the stale topology remains and the load failure state stays explicit. Failure cleanup never removes or manages an existing destination worktree.
- CLI/server-created sessions use the low-frequency OpenChamber control event stream to refresh only the created session directory. The same event retriggers bounded worktree discovery so a newly created external worktree gains ownership without a view reload; it does not re-enable broad session or streaming subscriptions.
- Recent membership includes active root sessions immediately even when their last committed `time.updated` falls outside the 48-hour window. Children and archived sessions remain excluded, and inactive roots remain timestamp-based. The active-ID subscription is disabled while the sidebar is hidden and ignores retry/status detail changes, avoiding streaming-frequency rerenders.
@@ -17,7 +17,7 @@ import { Icon } from "@/components/icon/Icon";
import type { IconName } from '@/components/icon/icons';
import { useDeviceInfo } from '@/lib/device';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { isTerminalCwdMissingError, terminalSnapshotSize } from '@/lib/terminalApi';
import { terminalSnapshotSize } from '@/lib/terminalApi';
import { extractTerminalPreviewUrl, isTerminalPreviewUrlAvailable } from '@/lib/terminalPreview';
import { useI18n } from '@/lib/i18n';
import { PROJECT_ACTION_ICONS } from '@/lib/projectActions';
@@ -40,14 +40,6 @@ const resolveTabIconName = (iconKey: string | null): IconName => {
export const TerminalView: React.FC<TerminalViewProps> = ({ visible, directory }) => {
const { t } = useI18n();
const { terminal, runtime } = useRuntimeAPIs();
// The server rejects a working directory that no longer exists (a worktree
// deleted outside OpenChamber). The session is what is stranded, not the
// terminal: relocating it to its project changes the effective directory,
// and this view then starts a terminal there on its own.
const recoverCurrentSessionDirectory = React.useCallback(() => {
const sessionId = useSessionUIStore.getState().currentSessionId;
if (sessionId) void useSessionUIStore.getState().recoverMissingSessionDirectory(sessionId);
}, []);
const { currentTheme } = useThemeSystem();
const terminalAppearanceRef = React.useRef<{ themeMode: 'light' | 'dark'; terminalBackground: string; terminalForeground: string }>({ themeMode: 'dark', terminalBackground: '', terminalForeground: '' });
terminalAppearanceRef.current = { themeMode: currentTheme.metadata.variant === 'light' ? 'light' : 'dark', terminalBackground: currentTheme.colors.surface.background, terminalForeground: currentTheme.colors.syntax.base.foreground };
@@ -546,7 +538,6 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible, directory }
// this tab stopped owning the request; use current store
// ownership so a rejected create cannot leave it spinning.
if (directoryRef.current !== directory || activeTabIdRef.current !== tabId) return;
if (isTerminalCwdMissingError(error)) recoverCurrentSessionDirectory();
setConnectionError(
error instanceof Error
? error.message
@@ -590,7 +581,6 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible, directory }
setTabSessionId,
startStream,
disconnectStream,
recoverCurrentSessionDirectory,
t,
terminal,
terminalLoginShell,
@@ -654,7 +644,6 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible, directory }
|| directoryRef.current !== terminalDirectory
|| activeTabIdRef.current !== tabId
) return;
if (isTerminalCwdMissingError(error)) recoverCurrentSessionDirectory();
setConnectionError(
error instanceof Error ? error.message : t('terminalView.error.restartFailed')
);
@@ -665,7 +654,7 @@ export const TerminalView: React.FC<TerminalViewProps> = ({ visible, directory }
} finally {
setIsRestarting(false);
}
}, [activeTabId, disconnectStream, terminalDirectory, enableTabs, isActionTab, isRestarting, recoverCurrentSessionDirectory, resetTerminalPreviewScan, setTabLifecycle, setTabSessionId, startStream, t, terminal, terminalLoginShell, terminalShell]);
}, [activeTabId, disconnectStream, terminalDirectory, enableTabs, isActionTab, isRestarting, resetTerminalPreviewScan, setTabLifecycle, setTabSessionId, startStream, t, terminal, terminalLoginShell, terminalShell]);
const handleHardRestart = React.useCallback(async () => {
// Keep semantics: “close tab -> new clean tab”.