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:
Matt Visnovsky
2026-09-05 12:04:36 +03:00
committed by GitHub
parent 58dfc789a2
commit 4e0eed717d
53 changed files with 5194 additions and 608 deletions
@@ -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]);
};