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:
@@ -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”.
|
||||
|
||||
Reference in New Issue
Block a user