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
+66 -18
View File
@@ -9,6 +9,7 @@ import { DEFAULT_MONO_FONT, DEFAULT_UI_FONT, type MonoFontOption, type UiFontOpt
import { getStoredMobileKeyboardMode, type MobileKeyboardMode } from '@/lib/mobileKeyboardMode';
import type { LinearIssueListAssignee, LinearIssueListPriority, LinearIssueListStatus, TerminalShell } from '@/lib/api/types';
import type { ProjectRef } from '@/lib/projectContextApi';
import { directoryMayHaveActiveProjectAction, useTerminalStore } from '@/stores/useTerminalStore';
import { useFilesViewTabsStore } from './useFilesViewTabsStore';
import { isWindowsArm64 } from '@/lib/platform';
import { isVSCodeRuntime } from '@/lib/desktop';
@@ -108,6 +109,7 @@ type ContextPanelTab = {
id: string;
mode: ContextPanelMode;
targetPath: string | null;
targetDirectory: string | null;
/** Saved project plan this tab shows, for `plan` tabs opened from the notes
panel. Project plans are addressed by id because their markdown is
server-owned and has no client-visible path. */
@@ -128,6 +130,7 @@ type ContextPanelTab = {
type ContextPanelTabDescriptor = {
mode: ContextPanelMode;
targetPath?: string | null;
targetDirectory?: string | null;
projectPlanId?: string | null;
projectPlanRef?: ProjectRef | null;
dedupeKey?: string | null;
@@ -252,6 +255,15 @@ const normalizeContextTargetPath = (value: string | null | undefined): string |
return trimmed.replace(/\\/g, '/');
};
const normalizeContextTargetDirectory = (value: string | null | undefined): string | null => {
const normalizedPath = normalizeContextTargetPath(value);
if (!normalizedPath) {
return null;
}
return normalizeContextPanelDirectoryKey(normalizedPath) || null;
};
const normalizeContextTabLabel = (value: string | null | undefined): string | null => {
if (typeof value !== 'string') {
return null;
@@ -320,6 +332,9 @@ const buildContextPanelTabID = (mode: ContextPanelMode, dedupeKey: string): stri
const createContextPanelTab = (descriptor: ContextPanelTabDescriptor): ContextPanelTab => {
const normalizedTargetPath = normalizeContextTargetPath(descriptor.targetPath);
const normalizedTargetDirectory = descriptor.mode === 'terminal'
? normalizeContextTargetDirectory(descriptor.targetDirectory)
: null;
const dedupeKey = normalizeContextPanelTabDedupeKey(
descriptor.mode,
normalizedTargetPath,
@@ -329,6 +344,7 @@ const createContextPanelTab = (descriptor: ContextPanelTabDescriptor): ContextPa
id: buildContextPanelTabID(descriptor.mode, dedupeKey),
mode: descriptor.mode,
targetPath: normalizedTargetPath,
targetDirectory: normalizedTargetDirectory,
projectPlanId: typeof descriptor.projectPlanId === 'string' && descriptor.projectPlanId.trim()
? descriptor.projectPlanId.trim()
: null,
@@ -392,6 +408,7 @@ const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => {
const candidate = entry as {
mode?: unknown;
targetPath?: unknown;
targetDirectory?: string | null;
projectPlanId?: unknown;
projectPlanRef?: unknown;
dedupeKey?: unknown;
@@ -417,6 +434,9 @@ const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => {
}
const targetPath = normalizeContextTargetPath(typeof candidate.targetPath === 'string' ? candidate.targetPath : null);
const targetDirectory = candidate.mode === 'terminal'
? normalizeContextTargetDirectory(candidate.targetDirectory)
: null;
const projectPlanId = typeof candidate.projectPlanId === 'string' && candidate.projectPlanId.trim()
? candidate.projectPlanId.trim()
: null;
@@ -445,6 +465,7 @@ const sanitizeContextPanelTabs = (tabs: unknown): ContextPanelTab[] => {
id,
mode: candidate.mode,
targetPath,
targetDirectory,
projectPlanId,
projectPlanRef,
dedupeKey,
@@ -511,22 +532,23 @@ const upsertContextPanelTab = (
const existingIndex = baseTabs.findIndex((tab) => tab.id === nextTab.id);
const tabs = existingIndex === -1
? [...baseTabs, nextTab]
: baseTabs.map((tab, index) => (index === existingIndex
? {
...tab,
mode: nextTab.mode,
targetPath: nextTab.targetPath || tab.targetPath,
projectPlanId: nextTab.projectPlanId ?? tab.projectPlanId,
projectPlanRef: nextTab.projectPlanRef ?? tab.projectPlanRef,
dedupeKey: nextTab.dedupeKey,
label: nextTab.label,
sessionTitleFallback: nextTab.sessionTitleFallback || tab.sessionTitleFallback,
stagedDiff: nextTab.stagedDiff,
diffScope: nextTab.diffScope,
readOnly: nextTab.readOnly,
touchedAt: Date.now(),
}
: tab));
: baseTabs.map((tab, index) => (index === existingIndex
? {
...tab,
mode: nextTab.mode,
targetPath: nextTab.targetPath || tab.targetPath,
targetDirectory: nextTab.targetDirectory,
projectPlanId: nextTab.projectPlanId ?? tab.projectPlanId,
projectPlanRef: nextTab.projectPlanRef ?? tab.projectPlanRef,
dedupeKey: nextTab.dedupeKey,
label: nextTab.label,
sessionTitleFallback: nextTab.sessionTitleFallback || tab.sessionTitleFallback,
stagedDiff: nextTab.stagedDiff,
diffScope: nextTab.diffScope,
readOnly: nextTab.readOnly,
touchedAt: Date.now(),
}
: tab));
// A background upsert (an agent working a page) keeps the panel exactly as
// the user left it: closed stays closed, and whatever tab they were on
@@ -652,6 +674,7 @@ const sanitizeContextPanelByDirectory = (
touchedAt?: unknown;
mode?: unknown;
targetPath?: unknown;
targetDirectory?: string | null;
dedupeKey?: unknown;
label?: unknown;
};
@@ -663,10 +686,11 @@ const sanitizeContextPanelByDirectory = (
// no owner and cannot be migrated into an openable saved-plan tab — that
// combination is dropped by sanitize above. A generic filesystem plan tab
// (no plan id) revives fine from the descriptor alone.
if (tabs.length === 0 && (candidate.mode === 'diff' || candidate.mode === 'file' || candidate.mode === 'context' || candidate.mode === 'plan' || candidate.mode === 'chat')) {
if (tabs.length === 0 && (candidate.mode === 'diff' || candidate.mode === 'file' || candidate.mode === 'context' || candidate.mode === 'plan' || candidate.mode === 'chat' || candidate.mode === 'terminal')) {
tabs = [createContextPanelTab({
mode: candidate.mode,
targetPath: typeof candidate.targetPath === 'string' ? candidate.targetPath : null,
targetDirectory: candidate.targetDirectory,
dedupeKey: typeof candidate.dedupeKey === 'string' ? candidate.dedupeKey : null,
label: typeof candidate.label === 'string' ? candidate.label : null,
})];
@@ -1372,14 +1396,29 @@ export const useUIStore = create<UIStore>()(
const panelState = state.contextPanelByDirectory[normalizedDirectory];
const tabs = panelState?.tabs ?? [];
const activeTab = tabs.find((tab) => tab.id === panelState?.activeTabId) ?? null;
const clearTerminalTarget = () => {
if (mode === 'terminal') {
const terminalTab = tabs.find((tab) => tab.mode === 'terminal') ?? null;
const targetDirectory = terminalTab?.targetDirectory ?? null;
if (targetDirectory) {
const targetState = useTerminalStore.getState().getDirectoryState(targetDirectory);
if (directoryMayHaveActiveProjectAction(targetState)) {
return;
}
}
state.openContextPanelTab(normalizedDirectory, { mode: 'terminal', targetDirectory: null }, { reveal: false });
}
};
if (panelState?.isOpen && activeTab?.mode === mode) {
clearTerminalTarget();
state.closeContextPanel(normalizedDirectory);
return;
}
const tabsOfMode = tabs.filter((tab) => tab.mode === mode);
if (tabsOfMode.length > 0) {
clearTerminalTarget();
// `>=` so equal timestamps (same-millisecond opens) resolve to the
// later tab in insertion order.
const mostRecent = tabsOfMode.reduce((best, tab) => (tab.touchedAt >= best.touchedAt ? tab : best));
@@ -1403,12 +1442,21 @@ export const useUIStore = create<UIStore>()(
return;
}
const nextTab = tab.mode === 'terminal'
? {
...tab,
targetDirectory: normalizeContextTargetDirectory(tab.targetDirectory) === normalizedDirectory
? null
: normalizeContextTargetDirectory(tab.targetDirectory),
}
: tab;
set((state) => {
const prev = state.contextPanelByDirectory[normalizedDirectory];
const current = touchContextPanelState(prev);
const byDirectory = {
...state.contextPanelByDirectory,
[normalizedDirectory]: upsertContextPanelTab(current, tab, options),
[normalizedDirectory]: upsertContextPanelTab(current, nextTab, options),
};
return { contextPanelByDirectory: clampContextPanelRoots(byDirectory, 20) };