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,80 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
import React, { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { Window } from 'happy-dom';
import { I18nProvider } from '@/lib/i18n';
const desktopSshState = { instances: [], load: async () => undefined };
mock.module('@/lib/desktop', () => ({ isDesktopShell: () => false }));
mock.module('@/stores/useDesktopSshStore', () => ({
useDesktopSshStore: <T,>(selector: (state: typeof desktopSshState) => T): T => selector(desktopSshState),
}));
mock.module('@/lib/openchamberConfig', () => ({
getProjectActionsState: async () => ({
actions: [{ id: 'build', name: 'Build', command: 'echo build', icon: 'build' }],
primaryActionId: null,
}),
saveProjectActionsState: async () => true,
}));
const { ProjectActionsSection } = await import('./ProjectActionsSection');
describe('ProjectActionsSection', () => {
let windowInstance: Window;
let root: Root;
let host: HTMLDivElement;
beforeEach(() => {
windowInstance = new Window({ url: 'http://localhost/' });
Object.assign(globalThis, {
window: windowInstance,
document: windowInstance.document,
navigator: windowInstance.navigator,
Node: windowInstance.Node,
Element: windowInstance.Element,
HTMLElement: windowInstance.HTMLElement,
Event: windowInstance.Event,
MouseEvent: windowInstance.MouseEvent,
MutationObserver: windowInstance.MutationObserver,
getComputedStyle: windowInstance.getComputedStyle.bind(windowInstance),
requestAnimationFrame: windowInstance.requestAnimationFrame.bind(windowInstance),
cancelAnimationFrame: windowInstance.cancelAnimationFrame.bind(windowInstance),
IS_REACT_ACT_ENVIRONMENT: true,
});
host = document.createElement('div');
document.body.appendChild(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => root.unmount());
windowInstance.close();
});
test('shows the current worktree label when runIn is omitted', async () => {
await act(async () => {
root.render(
<I18nProvider>
<ProjectActionsSection projectRef={{ id: 'project-1', path: '/repo' }} />
</I18nProvider>,
);
await Promise.resolve();
});
const actionTrigger = Array.from(host.querySelectorAll('button'))
.find((button) => button.textContent?.includes('Build'));
if (!actionTrigger) {
throw new Error('expected saved action trigger');
}
await act(async () => {
actionTrigger.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
const runInTrigger = host.querySelector<HTMLButtonElement>('button[aria-label="Working directory for this action"]');
expect(runInTrigger?.textContent).toContain('Current worktree');
expect(runInTrigger?.textContent).not.toContain('__project__');
});
});
@@ -21,7 +21,7 @@ import {
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { toast } from '@/components/ui';
import { Icon } from "@/components/icon/Icon";
import { Icon } from '@/components/icon/Icon';
import { useDesktopSshStore } from '@/stores/useDesktopSshStore';
import { isDesktopShell } from '@/lib/desktop';
import {
@@ -40,7 +40,10 @@ import {
PROJECT_SETTINGS_CONTROL_WIDTH,
ProjectSettingsSubsection,
} from '@/components/sections/projects/ProjectSettingsSubsection';
import { SETTINGS_SELECT_SIZE } from '@/components/sections/shared/SettingsSection';
import {
SETTINGS_SELECT_SIZE,
SETTINGS_SELECT_TRIGGER_CLASS,
} from '@/components/sections/shared/SettingsSection';
import { SettingsInfoHint } from '@/components/sections/shared/SettingsInfoHint';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
@@ -48,6 +51,7 @@ import { cn } from '@/lib/utils';
type EditableProjectAction = OpenChamberProjectAction;
const AUTO_SAVE_DELAY_MS = 450;
const PROJECT_RUN_IN_PARENT_VALUE = '__project__';
const createActionId = (): string => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
@@ -342,6 +346,43 @@ export const ProjectActionsSection: React.FC<ProjectActionsSectionProps> = ({ pr
/>
</div>
<div className="py-1">
<div className="mb-0.5 flex items-center gap-2">
<p className="typography-meta text-muted-foreground">{t('settings.projects.actions.runIn.label')}</p>
<SettingsInfoHint contentClassName="max-w-xs">
{t('settings.projects.actions.runIn.info')}
</SettingsInfoHint>
</div>
<Select
value={action.runIn === 'parent' ? PROJECT_RUN_IN_PARENT_VALUE : 'worktree'}
onValueChange={(value) => {
updateAction(action.id, (current) => {
if (value === PROJECT_RUN_IN_PARENT_VALUE) {
return { ...current, runIn: 'parent' };
}
return { ...current, runIn: undefined };
});
}}
>
<SelectTrigger
size={SETTINGS_SELECT_SIZE}
className={SETTINGS_SELECT_TRIGGER_CLASS}
aria-label={t('settings.projects.actions.runIn.aria')}
>
<SelectValue>
{(value) => value === 'worktree'
? t('settings.projects.actions.runIn.worktree')
: t('settings.projects.actions.runIn.project')}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value={PROJECT_RUN_IN_PARENT_VALUE}>{t('settings.projects.actions.runIn.project')}</SelectItem>
<SelectItem value="worktree">{t('settings.projects.actions.runIn.worktree')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="py-1">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="typography-ui-label text-foreground">{t('settings.projects.actions.field.autoOpenUrl')}</span>