fix: Project action terminal lifecycle (#3287)
* fix(terminal): make command sessions own action lifecycle * fix(ui): reconcile project action terminal state * feat(ui): show running project actions in terminal tabs * feat(ui): run project actions from linked worktrees * fix(ui): guard project action reconciliation * fix(ui): scope project action preview fallback * fix(ui): default project actions to worktrees * fix(ui): reveal project action terminals * fix(ui): retain terminal output after snapshot replay * fix(ui): restore running action terminals on revisit
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { resolveProjectActionsOwner } from './useProjectActionsContext';
|
||||
|
||||
const projects = [
|
||||
{ id: 'openchamber', path: '/workspace/openchamber', label: 'OpenChamber' },
|
||||
];
|
||||
|
||||
describe('resolveProjectActionsOwner', () => {
|
||||
test('resolves a worktree directory to its owning parent project', () => {
|
||||
const owner = resolveProjectActionsOwner({
|
||||
projects,
|
||||
worktreesByProject: new Map([
|
||||
['/workspace/openchamber', [{
|
||||
path: '/workspace/openchamber-feature',
|
||||
projectDirectory: '/workspace/openchamber',
|
||||
branch: 'feature',
|
||||
label: 'feature',
|
||||
}]],
|
||||
]),
|
||||
directory: '/workspace/openchamber-feature',
|
||||
activeProjectId: null,
|
||||
});
|
||||
|
||||
expect(owner).toEqual(projects[0]);
|
||||
});
|
||||
|
||||
test('resolves a directory under the project path to that project', () => {
|
||||
const owner = resolveProjectActionsOwner({
|
||||
projects,
|
||||
worktreesByProject: new Map(),
|
||||
directory: '/workspace/openchamber/packages/ui',
|
||||
activeProjectId: null,
|
||||
});
|
||||
|
||||
expect(owner).toEqual(projects[0]);
|
||||
});
|
||||
|
||||
test('falls back to the active project when the directory does not resolve', () => {
|
||||
const owner = resolveProjectActionsOwner({
|
||||
projects,
|
||||
worktreesByProject: new Map(),
|
||||
directory: '/some/other/project',
|
||||
activeProjectId: 'openchamber',
|
||||
});
|
||||
|
||||
expect(owner).toEqual(projects[0]);
|
||||
});
|
||||
|
||||
test('falls back to the active project when the directory is empty or null', () => {
|
||||
expect(resolveProjectActionsOwner({
|
||||
projects,
|
||||
worktreesByProject: new Map(),
|
||||
directory: '',
|
||||
activeProjectId: 'openchamber',
|
||||
})).toEqual(projects[0]);
|
||||
|
||||
expect(resolveProjectActionsOwner({
|
||||
projects,
|
||||
worktreesByProject: new Map(),
|
||||
directory: null,
|
||||
activeProjectId: 'openchamber',
|
||||
})).toEqual(projects[0]);
|
||||
});
|
||||
|
||||
test('returns null when the directory does not resolve and the active project is unknown', () => {
|
||||
const owner = resolveProjectActionsOwner({
|
||||
projects,
|
||||
worktreesByProject: new Map(),
|
||||
directory: '/some/other/project',
|
||||
activeProjectId: 'missing-project',
|
||||
});
|
||||
|
||||
expect(owner).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,47 @@
|
||||
import React from 'react';
|
||||
import type { ProjectEntry } from '@/lib/api/types';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useSession } from '@/sync/sync-context';
|
||||
import type { ProjectRef } from '@/lib/openchamberConfig';
|
||||
import { resolveProjectForSessionDirectory } from '@/lib/projectResolution';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
|
||||
export interface ProjectActionsContext {
|
||||
projectRef: ProjectRef;
|
||||
directory: string;
|
||||
}
|
||||
|
||||
interface ProjectActionsOwnerInput {
|
||||
projects: ProjectEntry[];
|
||||
worktreesByProject: Map<string, WorktreeMetadata[]>;
|
||||
directory: string | null;
|
||||
activeProjectId: string | null;
|
||||
}
|
||||
|
||||
const normalize = (value: string): string => {
|
||||
if (!value) return '';
|
||||
const replaced = value.replace(/\\/g, '/');
|
||||
return replaced === '/' ? '/' : replaced.replace(/\/+$/, '');
|
||||
};
|
||||
|
||||
export const resolveProjectActionsOwner = ({
|
||||
projects,
|
||||
worktreesByProject,
|
||||
directory,
|
||||
activeProjectId,
|
||||
}: ProjectActionsOwnerInput): ProjectEntry | null => {
|
||||
const normalizedDirectory = normalize(directory ?? '');
|
||||
if (normalizedDirectory) {
|
||||
const sessionProject = resolveProjectForSessionDirectory(projects, worktreesByProject, normalizedDirectory);
|
||||
if (sessionProject) {
|
||||
return sessionProject;
|
||||
}
|
||||
}
|
||||
|
||||
return projects.find((project) => project.id === activeProjectId) ?? null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves the active project ref + working directory used by
|
||||
* {@link ProjectActionsButton}. Directory priority mirrors the header:
|
||||
@@ -22,12 +49,9 @@ const normalize = (value: string): string => {
|
||||
* good context so the actions button doesn't flicker during session switches.
|
||||
*/
|
||||
export function useProjectActionsContext(): ProjectActionsContext | null {
|
||||
const activeProject = useProjectsStore((state) => {
|
||||
if (!state.activeProjectId) {
|
||||
return null;
|
||||
}
|
||||
return state.projects.find((project) => project.id === state.activeProjectId) ?? null;
|
||||
});
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
||||
const worktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject);
|
||||
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const currentSession = useSession(currentSessionId ?? null);
|
||||
@@ -50,16 +74,22 @@ export function useProjectActionsContext(): ProjectActionsContext | null {
|
||||
}, [currentSession?.directory]);
|
||||
|
||||
const openDirectory = worktreeDirectory || sessionDirectory || draftDirectory;
|
||||
const ownerProject = React.useMemo(() => resolveProjectActionsOwner({
|
||||
projects,
|
||||
worktreesByProject,
|
||||
directory: openDirectory,
|
||||
activeProjectId,
|
||||
}), [activeProjectId, openDirectory, projects, worktreesByProject]);
|
||||
const actionDirectory = React.useMemo(
|
||||
() => normalize(openDirectory || activeProject?.path || ''),
|
||||
[activeProject?.path, openDirectory],
|
||||
() => normalize(openDirectory || ownerProject?.path || ''),
|
||||
[openDirectory, ownerProject?.path],
|
||||
);
|
||||
const activeProjectRef = React.useMemo<ProjectRef | null>(() => {
|
||||
if (!activeProject) {
|
||||
if (!ownerProject) {
|
||||
return null;
|
||||
}
|
||||
return { id: activeProject.id, path: activeProject.path };
|
||||
}, [activeProject]);
|
||||
return { id: ownerProject.id, path: ownerProject.path };
|
||||
}, [ownerProject]);
|
||||
|
||||
const lastContextRef = React.useRef<ProjectActionsContext | null>(null);
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import React, { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { Window } from 'happy-dom';
|
||||
|
||||
import { useTerminalStore } from '@/stores/useTerminalStore';
|
||||
|
||||
const touchCalls: string[][] = [];
|
||||
|
||||
const terminal = {
|
||||
touchSessions: async (sessionIds: string[]) => {
|
||||
touchCalls.push(sessionIds);
|
||||
},
|
||||
};
|
||||
|
||||
mock.module('@/hooks/useRuntimeAPIs', () => ({
|
||||
useRuntimeAPIs: () => ({ terminal }),
|
||||
}));
|
||||
|
||||
const { useTerminalSessionKeepalive } = await import('./useTerminalSessionKeepalive');
|
||||
|
||||
const HookHarness = () => {
|
||||
useTerminalSessionKeepalive();
|
||||
return null;
|
||||
};
|
||||
|
||||
describe('useTerminalSessionKeepalive', () => {
|
||||
let windowInstance: Window;
|
||||
let host: HTMLDivElement;
|
||||
let root: Root;
|
||||
const originalSetInterval = globalThis.setInterval;
|
||||
const originalClearInterval = globalThis.clearInterval;
|
||||
const scheduledIntervals = new Map<number, { delay: number; callback: () => void }>();
|
||||
let nextIntervalId = 1;
|
||||
|
||||
beforeEach(() => {
|
||||
windowInstance = new Window({ url: 'http://localhost/' });
|
||||
scheduledIntervals.clear();
|
||||
nextIntervalId = 1;
|
||||
Object.assign(globalThis, {
|
||||
window: windowInstance,
|
||||
document: windowInstance.document,
|
||||
navigator: windowInstance.navigator,
|
||||
HTMLElement: windowInstance.HTMLElement,
|
||||
Element: windowInstance.Element,
|
||||
Node: windowInstance.Node,
|
||||
Event: windowInstance.Event,
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
});
|
||||
const windowSetInterval = windowInstance.setInterval.bind(windowInstance);
|
||||
const windowClearInterval = windowInstance.clearInterval.bind(windowInstance);
|
||||
const fakeSetInterval = (callback: TimerHandler, delay = 0, ...args: unknown[]) => {
|
||||
const liveHandle = windowSetInterval(() => undefined, 0);
|
||||
windowClearInterval(liveHandle);
|
||||
const id = nextIntervalId;
|
||||
nextIntervalId += 1;
|
||||
const callbackFn = () => {
|
||||
if (callback instanceof Function) {
|
||||
callback(...args);
|
||||
return;
|
||||
}
|
||||
new Function(String(callback))();
|
||||
};
|
||||
scheduledIntervals.set(id, {
|
||||
delay,
|
||||
callback: callbackFn,
|
||||
});
|
||||
return id;
|
||||
};
|
||||
const fakeClearInterval = (intervalId: number) => {
|
||||
scheduledIntervals.delete(intervalId);
|
||||
};
|
||||
Object.defineProperty(globalThis, 'setInterval', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: fakeSetInterval,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'clearInterval', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: fakeClearInterval,
|
||||
});
|
||||
|
||||
host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
touchCalls.length = 0;
|
||||
useTerminalStore.getState().clearAll();
|
||||
|
||||
const interactiveTabId = useTerminalStore.getState().createTab('/repo');
|
||||
useTerminalStore.getState().setTabSessionId('/repo', interactiveTabId, 'interactive-session');
|
||||
useTerminalStore.getState().setTabLifecycle('/repo', interactiveTabId, 'running');
|
||||
|
||||
const runningActionTabId = useTerminalStore.getState().createTab('/repo');
|
||||
useTerminalStore.getState().setTabPurpose('/repo', runningActionTabId, {
|
||||
type: 'project-action',
|
||||
actionId: 'build',
|
||||
executionId: 'exec-1',
|
||||
});
|
||||
useTerminalStore.getState().setTabSessionId('/repo', runningActionTabId, 'action-session');
|
||||
useTerminalStore.getState().setTabLifecycle('/repo', runningActionTabId, 'running');
|
||||
|
||||
const exitedTabId = useTerminalStore.getState().createTab('/repo');
|
||||
useTerminalStore.getState().setTabSessionId('/repo', exitedTabId, 'exited-session');
|
||||
useTerminalStore.getState().setTabLifecycle('/repo', exitedTabId, 'exited');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
Object.defineProperty(globalThis, 'setInterval', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: originalSetInterval,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'clearInterval', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: originalClearInterval,
|
||||
});
|
||||
useTerminalStore.getState().clearAll();
|
||||
});
|
||||
|
||||
test('touches non-exited tab sessions immediately and on the interval, without a TerminalView mount', async () => {
|
||||
await act(async () => {
|
||||
root.render(React.createElement(HookHarness));
|
||||
});
|
||||
|
||||
expect(touchCalls).toEqual([['interactive-session', 'action-session']]);
|
||||
expect([...scheduledIntervals.values()].map((entry) => entry.delay)).toEqual([10 * 60 * 1000]);
|
||||
|
||||
await act(async () => {
|
||||
[...scheduledIntervals.values()][0]?.callback();
|
||||
});
|
||||
|
||||
expect(touchCalls).toEqual([
|
||||
['interactive-session', 'action-session'],
|
||||
['interactive-session', 'action-session'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('skips touches while offline and clears the interval on unmount', async () => {
|
||||
Object.defineProperty(globalThis.navigator, 'onLine', {
|
||||
configurable: true,
|
||||
get: () => false,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(HookHarness));
|
||||
});
|
||||
|
||||
expect(touchCalls).toEqual([]);
|
||||
expect(scheduledIntervals.size).toBe(1);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
|
||||
expect(scheduledIntervals.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Every app root that renders terminals must own keepalive itself: the server
|
||||
* reaps sessions with no attached socket after 30 idle minutes, and the loop
|
||||
* no longer lives in TerminalView. The mobile shell runs its own root and
|
||||
* mounts neither desktop layout, so a missing call there silently reintroduces
|
||||
* background PTY reaping (reviewer finding on the revisit fix).
|
||||
*/
|
||||
describe('terminal keepalive root coverage', () => {
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const rootSources: Array<[string, string]> = [
|
||||
['MainLayout', join(__dirname, '..', 'components', 'layout', 'MainLayout.tsx')],
|
||||
['VSCodeLayout', join(__dirname, '..', 'components', 'layout', 'VSCodeLayout.tsx')],
|
||||
['MobileApp shell', join(__dirname, '..', 'apps', 'MobileApp.tsx')],
|
||||
];
|
||||
|
||||
for (const [rootName, sourcePath] of rootSources) {
|
||||
test(`${rootName} mounts useTerminalSessionKeepalive`, () => {
|
||||
const source = readFileSync(sourcePath, 'utf-8');
|
||||
expect(source).toContain('useTerminalSessionKeepalive()');
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useTerminalStore } from '@/stores/useTerminalStore';
|
||||
|
||||
const TERMINAL_SESSION_KEEPALIVE_INTERVAL_MS = 10 * 60 * 1000;
|
||||
|
||||
export const useTerminalSessionKeepalive = (): void => {
|
||||
const { terminal } = useRuntimeAPIs();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!terminal.touchSessions) {
|
||||
return;
|
||||
}
|
||||
const touch = () => {
|
||||
if (globalThis.navigator?.onLine === false) return;
|
||||
const ids: string[] = [];
|
||||
for (const dirState of useTerminalStore.getState().sessions.values()) {
|
||||
for (const tab of dirState.tabs) {
|
||||
if (tab.terminalSessionId && tab.lifecycle !== 'exited') ids.push(tab.terminalSessionId);
|
||||
}
|
||||
}
|
||||
if (ids.length > 0) void terminal.touchSessions?.(ids).catch(() => {});
|
||||
};
|
||||
touch();
|
||||
const interval = setInterval(touch, TERMINAL_SESSION_KEEPALIVE_INTERVAL_MS);
|
||||
return () => clearInterval(interval);
|
||||
}, [terminal]);
|
||||
};
|
||||
Reference in New Issue
Block a user