2026-02-27 20:45:03 +02:00
|
|
|
import React from 'react';
|
|
|
|
|
import {
|
|
|
|
|
DropdownMenu,
|
|
|
|
|
DropdownMenuContent,
|
|
|
|
|
DropdownMenuItem,
|
|
|
|
|
DropdownMenuSeparator,
|
|
|
|
|
DropdownMenuTrigger,
|
|
|
|
|
} from '@/components/ui/dropdown-menu';
|
2026-04-29 17:03:38 -04:00
|
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
2026-02-27 20:45:03 +02:00
|
|
|
import { toast } from '@/components/ui';
|
2026-05-13 13:26:15 +03:00
|
|
|
import { Icon } from "@/components/icon/Icon";
|
2026-09-05 03:04:36 -06:00
|
|
|
import type { IconName } from '@/components/icon/icons';
|
2026-02-27 20:45:03 +02:00
|
|
|
import { cn } from '@/lib/utils';
|
|
|
|
|
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
2026-09-05 03:04:36 -06:00
|
|
|
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
2026-02-27 20:45:03 +02:00
|
|
|
import { useDeviceInfo } from '@/lib/device';
|
|
|
|
|
import { isDesktopShell } from '@/lib/desktop';
|
|
|
|
|
import { useUIStore } from '@/stores/useUIStore';
|
|
|
|
|
import { useTerminalStore } from '@/stores/useTerminalStore';
|
2026-08-13 22:44:13 +03:00
|
|
|
import { extractAnnouncedUrls, extractProjectActionUrl } from '@/lib/terminalPreview';
|
|
|
|
|
import { setAnnouncedDevServers } from '@/lib/browser/announcedServers';
|
2026-07-17 13:17:21 +03:00
|
|
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
2026-02-27 20:45:03 +02:00
|
|
|
import { useDesktopSshStore } from '@/stores/useDesktopSshStore';
|
2026-03-20 19:11:45 +08:00
|
|
|
import { openExternalUrl } from '@/lib/url';
|
2026-04-26 14:03:39 +03:00
|
|
|
import { useI18n } from '@/lib/i18n';
|
2026-02-27 20:45:03 +02:00
|
|
|
import {
|
|
|
|
|
getProjectActionsState,
|
|
|
|
|
type OpenChamberProjectAction,
|
|
|
|
|
type ProjectRef,
|
|
|
|
|
} from '@/lib/openchamberConfig';
|
|
|
|
|
import {
|
|
|
|
|
normalizeProjectActionDirectory,
|
2026-09-05 03:04:36 -06:00
|
|
|
PROJECT_ACTION_ICONS,
|
2026-02-27 20:45:03 +02:00
|
|
|
PROJECT_ACTIONS_UPDATED_EVENT,
|
|
|
|
|
resolveProjectActionDesktopForwardUrl,
|
|
|
|
|
toProjectActionRunKey,
|
|
|
|
|
} from '@/lib/projectActions';
|
2026-04-29 17:03:38 -04:00
|
|
|
import { detectDevServerCommand, readPackageJsonScripts } from '@/lib/detectDevServer';
|
2026-09-05 03:04:36 -06:00
|
|
|
import {
|
|
|
|
|
createProjectActionTerminalSession,
|
|
|
|
|
normalizeProjectActionCommand,
|
|
|
|
|
reconcileTerminalSessionAuthority,
|
|
|
|
|
stopProjectActionTerminalSession,
|
|
|
|
|
} from '@/lib/projectActionTerminal';
|
2026-09-05 12:05:39 +03:00
|
|
|
import { observeTerminalSessions } from '@/lib/terminalSessionObserver';
|
2026-09-05 03:04:36 -06:00
|
|
|
import type { TerminalTab } from '@/stores/useTerminalStore';
|
2026-02-27 20:45:03 +02:00
|
|
|
|
|
|
|
|
type UrlWatchEntry = {
|
2026-09-05 03:04:36 -06:00
|
|
|
hostDirectory: string;
|
|
|
|
|
directory: string;
|
|
|
|
|
tabId: string;
|
|
|
|
|
actionId: string;
|
|
|
|
|
executionId: string;
|
2026-02-27 20:45:03 +02:00
|
|
|
lastSeenChunkId: number | null;
|
|
|
|
|
openedUrl: boolean;
|
|
|
|
|
tail: string;
|
2026-04-29 17:03:38 -04:00
|
|
|
openInPreview: boolean;
|
2026-08-13 22:44:13 +03:00
|
|
|
/** Addresses announced so far by an auto-discovery run, in announcement order. */
|
|
|
|
|
announced: string[];
|
|
|
|
|
/** Set once the panel is showing these candidates and wants later ones too. */
|
|
|
|
|
offering: boolean;
|
2026-02-27 20:45:03 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
interface ProjectActionsButtonProps {
|
|
|
|
|
projectRef: ProjectRef | null;
|
|
|
|
|
directory: string;
|
|
|
|
|
className?: string;
|
|
|
|
|
compact?: boolean;
|
|
|
|
|
allowMobile?: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 17:03:38 -04:00
|
|
|
const AUTO_DISCOVER_ACTION_ID = '__openchamber_auto_discover_preview__';
|
|
|
|
|
const AUTO_DISCOVER_PREVIEW_WAIT_TIMEOUT_MS = 15_000;
|
2026-08-13 22:44:13 +03:00
|
|
|
/**
|
|
|
|
|
* How long to keep listening after the first server announces itself. A project
|
|
|
|
|
* that starts several at once staggers them by a second or two, and opening the
|
|
|
|
|
* first to speak would just be a race.
|
|
|
|
|
*/
|
|
|
|
|
const AUTO_DISCOVER_SETTLE_MS = 3_000;
|
2026-02-27 20:45:03 +02:00
|
|
|
|
2026-09-05 03:04:36 -06:00
|
|
|
const resolveProjectActionIconName = (action: Pick<OpenChamberProjectAction, 'id' | 'icon'>): IconName => {
|
|
|
|
|
if (action.id === AUTO_DISCOVER_ACTION_ID) {
|
|
|
|
|
return 'scan-2';
|
2026-02-27 20:45:03 +02:00
|
|
|
}
|
2026-09-05 03:04:36 -06:00
|
|
|
const matchedIcon = PROJECT_ACTION_ICONS.find((entry) => entry.key === action.icon);
|
|
|
|
|
return matchedIcon?.Icon ?? 'play';
|
2026-02-27 20:45:03 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const normalizeManualOpenUrl = (value: string | undefined): string | null => {
|
|
|
|
|
const raw = (value || '').trim();
|
|
|
|
|
if (!raw) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const candidate = /^https?:\/\//i.test(raw) ? raw : `http://${raw}`;
|
|
|
|
|
try {
|
|
|
|
|
const parsed = new URL(candidate);
|
|
|
|
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
return parsed.toString();
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export const ProjectActionsButton = ({
|
|
|
|
|
projectRef,
|
|
|
|
|
directory,
|
|
|
|
|
className,
|
|
|
|
|
compact = false,
|
|
|
|
|
allowMobile = false,
|
|
|
|
|
}: ProjectActionsButtonProps) => {
|
2026-04-26 14:03:39 +03:00
|
|
|
const { t } = useI18n();
|
2026-07-17 13:17:21 +03:00
|
|
|
const { currentTheme } = useThemeSystem();
|
2026-02-27 20:45:03 +02:00
|
|
|
const { terminal, runtime } = useRuntimeAPIs();
|
2026-09-05 03:04:36 -06:00
|
|
|
const effectiveDirectory = useEffectiveDirectory();
|
2026-02-27 20:45:03 +02:00
|
|
|
const { isMobile } = useDeviceInfo();
|
|
|
|
|
const isDesktopShellApp = React.useMemo(() => isDesktopShell(), []);
|
|
|
|
|
const desktopSshInstances = useDesktopSshStore((state) => state.instances);
|
|
|
|
|
const loadDesktopSsh = useDesktopSshStore((state) => state.load);
|
|
|
|
|
|
2026-07-17 13:17:21 +03:00
|
|
|
const terminalShell = useUIStore((state) => state.terminalShell);
|
|
|
|
|
const terminalLoginShell = useUIStore((state) => state.terminalLoginShells.includes(state.terminalShell));
|
2026-02-27 20:45:03 +02:00
|
|
|
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
|
|
|
|
|
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
|
|
|
|
|
const setSettingsProjectsSelectedId = useUIStore((state) => state.setSettingsProjectsSelectedId);
|
2026-04-29 17:03:38 -04:00
|
|
|
const openContextPreview = useUIStore((state) => state.openContextPreview);
|
2026-02-27 20:45:03 +02:00
|
|
|
|
|
|
|
|
const ensureDirectory = useTerminalStore((state) => state.ensureDirectory);
|
2026-09-05 03:04:36 -06:00
|
|
|
const reconcileServerSessions = useTerminalStore((state) => state.reconcileServerSessions);
|
2026-02-27 20:45:03 +02:00
|
|
|
const setTabLabel = useTerminalStore((state) => state.setTabLabel);
|
2026-04-29 17:03:38 -04:00
|
|
|
const setTabIconKey = useTerminalStore((state) => state.setTabIconKey);
|
2026-02-27 20:45:03 +02:00
|
|
|
const setActiveTab = useTerminalStore((state) => state.setActiveTab);
|
|
|
|
|
const setConnecting = useTerminalStore((state) => state.setConnecting);
|
|
|
|
|
const setTabSessionId = useTerminalStore((state) => state.setTabSessionId);
|
2026-09-05 03:04:36 -06:00
|
|
|
const setTabPurpose = useTerminalStore((state) => state.setTabPurpose);
|
|
|
|
|
const allocateActionExecution = useTerminalStore((state) => state.allocateActionExecution);
|
|
|
|
|
const setTabLifecycle = useTerminalStore((state) => state.setTabLifecycle);
|
2026-04-29 17:03:38 -04:00
|
|
|
const setTabPreviewUrl = useTerminalStore((state) => state.setTabPreviewUrl);
|
2026-09-05 03:04:36 -06:00
|
|
|
const matchesActionExecution = useTerminalStore((state) => state.matchesActionExecution);
|
|
|
|
|
const captureStartedActionMutationRevisions = useTerminalStore((state) => state.captureStartedActionMutationRevisions);
|
2026-02-27 20:45:03 +02:00
|
|
|
|
|
|
|
|
const [actions, setActions] = React.useState<OpenChamberProjectAction[]>([]);
|
|
|
|
|
const [selectedActionId, setSelectedActionId] = React.useState<string | null>(null);
|
|
|
|
|
const [isLoading, setIsLoading] = React.useState(false);
|
|
|
|
|
const urlWatchByRunKeyRef = React.useRef<Record<string, UrlWatchEntry>>({});
|
2026-04-29 17:03:38 -04:00
|
|
|
const streamCleanupByRunKeyRef = React.useRef<Record<string, () => void>>({});
|
|
|
|
|
const previewWaitTimeoutByRunKeyRef = React.useRef<Record<string, number>>({});
|
2026-07-17 13:17:21 +03:00
|
|
|
const startingRunKeysRef = React.useRef<Set<string>>(new Set());
|
2026-03-02 02:11:33 +02:00
|
|
|
const loadRequestIdRef = React.useRef(0);
|
2026-09-05 03:04:36 -06:00
|
|
|
const [waitingForPreviewByExecution, setWaitingForPreviewByExecution] = React.useState<Record<string, true>>({});
|
2026-02-27 20:45:03 +02:00
|
|
|
|
|
|
|
|
const projectId = projectRef?.id ?? null;
|
|
|
|
|
const projectPath = projectRef?.path ?? '';
|
|
|
|
|
|
|
|
|
|
const stableProjectRef = React.useMemo(() => {
|
|
|
|
|
if (!projectId) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
return { id: projectId, path: projectPath };
|
|
|
|
|
}, [projectId, projectPath]);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (!isDesktopShellApp) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
void loadDesktopSsh().catch(() => undefined);
|
|
|
|
|
}, [isDesktopShellApp, loadDesktopSsh]);
|
|
|
|
|
|
|
|
|
|
const openExternal = React.useCallback(async (url: string) => {
|
2026-03-20 19:11:45 +08:00
|
|
|
await openExternalUrl(url);
|
2026-02-27 20:45:03 +02:00
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const loadActions = React.useCallback(async () => {
|
|
|
|
|
if (!stableProjectRef) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-02 02:11:33 +02:00
|
|
|
const requestId = loadRequestIdRef.current + 1;
|
|
|
|
|
loadRequestIdRef.current = requestId;
|
|
|
|
|
|
2026-02-27 20:45:03 +02:00
|
|
|
setIsLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
const state = await getProjectActionsState(stableProjectRef);
|
2026-03-02 02:11:33 +02:00
|
|
|
if (loadRequestIdRef.current !== requestId) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-27 20:45:03 +02:00
|
|
|
const filtered = state.actions;
|
|
|
|
|
setActions(filtered);
|
2026-03-02 02:11:33 +02:00
|
|
|
setSelectedActionId((current) => {
|
2026-04-29 17:03:38 -04:00
|
|
|
if (current === AUTO_DISCOVER_ACTION_ID) {
|
|
|
|
|
return current;
|
2026-03-02 02:11:33 +02:00
|
|
|
}
|
|
|
|
|
if (current && filtered.some((entry) => entry.id === current)) {
|
|
|
|
|
return current;
|
|
|
|
|
}
|
2026-04-29 17:03:38 -04:00
|
|
|
return null;
|
2026-03-02 02:11:33 +02:00
|
|
|
});
|
2026-02-27 20:45:03 +02:00
|
|
|
} catch {
|
2026-03-02 02:11:33 +02:00
|
|
|
if (loadRequestIdRef.current !== requestId) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
// Keep last known actions while next project loads or transient fetch fails.
|
2026-02-27 20:45:03 +02:00
|
|
|
} finally {
|
2026-03-02 02:11:33 +02:00
|
|
|
if (loadRequestIdRef.current === requestId) {
|
|
|
|
|
setIsLoading(false);
|
|
|
|
|
}
|
2026-02-27 20:45:03 +02:00
|
|
|
}
|
|
|
|
|
}, [stableProjectRef]);
|
|
|
|
|
|
2026-04-29 17:03:38 -04:00
|
|
|
const normalizedDirectory = React.useMemo(() => {
|
|
|
|
|
return normalizeProjectActionDirectory(directory || stableProjectRef?.path || '');
|
|
|
|
|
}, [directory, stableProjectRef?.path]);
|
|
|
|
|
|
2026-09-05 03:04:36 -06:00
|
|
|
const normalizedProjectDirectory = React.useMemo(() => {
|
|
|
|
|
return normalizeProjectActionDirectory(stableProjectRef?.path || '');
|
|
|
|
|
}, [stableProjectRef?.path]);
|
|
|
|
|
|
|
|
|
|
const contextHostDirectory = React.useMemo(() => {
|
|
|
|
|
return normalizeProjectActionDirectory(effectiveDirectory || '') || normalizedDirectory;
|
|
|
|
|
}, [effectiveDirectory, normalizedDirectory]);
|
|
|
|
|
const contextHostDirectoryRef = React.useRef(contextHostDirectory);
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
contextHostDirectoryRef.current = contextHostDirectory;
|
|
|
|
|
}, [contextHostDirectory]);
|
|
|
|
|
|
2026-09-05 14:47:21 +03:00
|
|
|
// The store owns its directory key form; reading `sessions` directly with a
|
|
|
|
|
// project-action-normalized path misses the entry whenever the two spellings
|
|
|
|
|
// differ (Windows drive letters and separators).
|
2026-09-05 03:04:36 -06:00
|
|
|
const directoryTerminalState = useTerminalStore((state) => (
|
2026-09-05 14:47:21 +03:00
|
|
|
normalizedDirectory ? state.getDirectoryState(normalizedDirectory) : undefined
|
2026-09-05 03:04:36 -06:00
|
|
|
));
|
|
|
|
|
|
|
|
|
|
const projectTerminalState = useTerminalStore((state) => (
|
|
|
|
|
normalizedProjectDirectory && normalizedProjectDirectory !== normalizedDirectory
|
2026-09-05 14:47:21 +03:00
|
|
|
? state.getDirectoryState(normalizedProjectDirectory)
|
2026-09-05 03:04:36 -06:00
|
|
|
: undefined
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
const watchedTerminalStates = React.useMemo(() => {
|
|
|
|
|
const states = normalizedDirectory
|
|
|
|
|
? [{ directory: normalizedDirectory, state: directoryTerminalState }]
|
|
|
|
|
: [];
|
|
|
|
|
if (normalizedProjectDirectory && normalizedProjectDirectory !== normalizedDirectory) {
|
|
|
|
|
states.push({ directory: normalizedProjectDirectory, state: projectTerminalState });
|
|
|
|
|
}
|
|
|
|
|
return states;
|
|
|
|
|
}, [directoryTerminalState, normalizedDirectory, normalizedProjectDirectory, projectTerminalState]);
|
|
|
|
|
|
|
|
|
|
const watchedTerminalDirectories = React.useMemo(() => {
|
|
|
|
|
const directories = normalizedDirectory ? [normalizedDirectory] : [];
|
|
|
|
|
if (normalizedProjectDirectory && normalizedProjectDirectory !== normalizedDirectory) {
|
|
|
|
|
directories.push(normalizedProjectDirectory);
|
|
|
|
|
}
|
|
|
|
|
return directories;
|
|
|
|
|
}, [normalizedDirectory, normalizedProjectDirectory]);
|
|
|
|
|
|
|
|
|
|
const executionDirectoryFor = React.useCallback((action: OpenChamberProjectAction): string => {
|
|
|
|
|
if (action.id !== AUTO_DISCOVER_ACTION_ID && action.runIn === 'parent') {
|
|
|
|
|
return normalizedProjectDirectory || normalizedDirectory;
|
|
|
|
|
}
|
|
|
|
|
return normalizedDirectory;
|
|
|
|
|
}, [normalizedDirectory, normalizedProjectDirectory]);
|
|
|
|
|
|
|
|
|
|
const executionKey = React.useCallback((executionDirectory: string, actionId: string, executionId: string) => (
|
|
|
|
|
`${executionDirectory}::${actionId}::${executionId}`
|
|
|
|
|
), []);
|
|
|
|
|
|
|
|
|
|
const getActionTab = React.useCallback((executionDirectory: string, actionId: string, state = useTerminalStore.getState()): TerminalTab | null => {
|
|
|
|
|
if (!executionDirectory) return null;
|
|
|
|
|
return state.getDirectoryState(executionDirectory)?.tabs.find((tab) => (
|
|
|
|
|
tab.purpose.type === 'project-action' && tab.purpose.actionId === actionId
|
|
|
|
|
)) ?? null;
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const projectActionRuns = React.useMemo(() => {
|
|
|
|
|
const runs: Record<string, { directory: string; actionId: string; tabId: string; sessionId: string; executionId: string; status: 'running' | 'waiting-for-preview' | 'stopping' }> = {};
|
|
|
|
|
for (const { directory: tabDirectory, state } of watchedTerminalStates) {
|
|
|
|
|
for (const tab of state?.tabs ?? []) {
|
|
|
|
|
if (tab.purpose.type !== 'project-action' || !tab.purpose.executionId || !tab.terminalSessionId) continue;
|
|
|
|
|
if (tab.lifecycle === 'idle' || tab.lifecycle === 'exited') continue;
|
|
|
|
|
const runKey = toProjectActionRunKey(tabDirectory, tab.purpose.actionId);
|
|
|
|
|
const execKey = executionKey(tabDirectory, tab.purpose.actionId, tab.purpose.executionId);
|
|
|
|
|
runs[runKey] = {
|
|
|
|
|
directory: tabDirectory,
|
|
|
|
|
actionId: tab.purpose.actionId,
|
|
|
|
|
tabId: tab.id,
|
|
|
|
|
sessionId: tab.terminalSessionId,
|
|
|
|
|
executionId: tab.purpose.executionId,
|
|
|
|
|
status: tab.lifecycle === 'stopping'
|
|
|
|
|
? 'stopping'
|
|
|
|
|
: waitingForPreviewByExecution[execKey]
|
|
|
|
|
? 'waiting-for-preview'
|
|
|
|
|
: 'running',
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return runs;
|
|
|
|
|
}, [executionKey, waitingForPreviewByExecution, watchedTerminalStates]);
|
|
|
|
|
|
|
|
|
|
const clearExecutionUi = React.useCallback((executionDirectory: string, actionId: string, executionId: string) => {
|
|
|
|
|
const actionRunKey = toProjectActionRunKey(executionDirectory, actionId);
|
|
|
|
|
const executionStateKey = executionKey(executionDirectory, actionId, executionId);
|
|
|
|
|
const watch = urlWatchByRunKeyRef.current[actionRunKey];
|
|
|
|
|
const ownsActionScopedUi = watch?.executionId === executionId;
|
|
|
|
|
const browserWindow = globalThis.window;
|
|
|
|
|
const clearPreviewWaitTimeout = (key: string) => {
|
|
|
|
|
browserWindow?.clearTimeout(previewWaitTimeoutByRunKeyRef.current[key]);
|
|
|
|
|
delete previewWaitTimeoutByRunKeyRef.current[key];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (ownsActionScopedUi) {
|
|
|
|
|
delete urlWatchByRunKeyRef.current[actionRunKey];
|
|
|
|
|
clearPreviewWaitTimeout(actionRunKey);
|
|
|
|
|
}
|
|
|
|
|
streamCleanupByRunKeyRef.current[executionStateKey]?.();
|
|
|
|
|
delete streamCleanupByRunKeyRef.current[executionStateKey];
|
|
|
|
|
clearPreviewWaitTimeout(executionStateKey);
|
|
|
|
|
setWaitingForPreviewByExecution((current) => {
|
|
|
|
|
if (!current[executionStateKey]) return current;
|
|
|
|
|
const next = { ...current };
|
|
|
|
|
delete next[executionStateKey];
|
|
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
}, [executionKey]);
|
|
|
|
|
|
|
|
|
|
const closeTrackedSubscription = React.useCallback((executionStateKey: string) => {
|
|
|
|
|
streamCleanupByRunKeyRef.current[executionStateKey]?.();
|
|
|
|
|
delete streamCleanupByRunKeyRef.current[executionStateKey];
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const clearTrackedPreviewTimeout = React.useCallback((executionStateKey: string) => {
|
|
|
|
|
window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[executionStateKey]);
|
|
|
|
|
delete previewWaitTimeoutByRunKeyRef.current[executionStateKey];
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
// The refs hold mutable maps whose identity never changes; reading the
|
|
|
|
|
// container once inside the effect keeps the latest entries visible to
|
|
|
|
|
// the unmount cleanup without re-reading `.current` there.
|
|
|
|
|
const trackedStreams = streamCleanupByRunKeyRef.current;
|
|
|
|
|
const trackedTimeouts = previewWaitTimeoutByRunKeyRef.current;
|
|
|
|
|
return () => {
|
|
|
|
|
for (const executionStateKey of Object.keys(trackedStreams)) {
|
|
|
|
|
closeTrackedSubscription(executionStateKey);
|
|
|
|
|
}
|
|
|
|
|
for (const executionStateKey of Object.keys(trackedTimeouts)) {
|
|
|
|
|
clearTrackedPreviewTimeout(executionStateKey);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}, [clearTrackedPreviewTimeout, closeTrackedSubscription]);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
const watchedDirectories = new Set(watchedTerminalDirectories);
|
2026-09-05 12:05:39 +03:00
|
|
|
for (const watch of Object.values(urlWatchByRunKeyRef.current)) {
|
|
|
|
|
if (!watchedDirectories.has(watch.directory)) clearExecutionUi(watch.directory, watch.actionId, watch.executionId);
|
|
|
|
|
}
|
2026-09-05 03:04:36 -06:00
|
|
|
for (const executionStateKey of Object.keys(streamCleanupByRunKeyRef.current)) {
|
|
|
|
|
const executionDirectory = executionStateKey.split('::', 1)[0] ?? '';
|
|
|
|
|
if (!watchedDirectories.has(executionDirectory)) {
|
|
|
|
|
closeTrackedSubscription(executionStateKey);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-05 12:05:39 +03:00
|
|
|
}, [clearExecutionUi, closeTrackedSubscription, watchedTerminalDirectories]);
|
2026-09-05 03:04:36 -06:00
|
|
|
|
|
|
|
|
const revealProjectActionTerminal = React.useCallback((hostDirectory: string, executionDirectory: string) => {
|
|
|
|
|
useUIStore.getState().openContextPanelTab(hostDirectory, {
|
|
|
|
|
mode: 'terminal',
|
|
|
|
|
targetDirectory: executionDirectory === hostDirectory ? null : executionDirectory,
|
|
|
|
|
});
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-04-29 17:03:38 -04:00
|
|
|
const selectedAction = React.useMemo(() => {
|
|
|
|
|
if (!selectedActionId) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
return actions.find((entry) => entry.id === selectedActionId) ?? null;
|
|
|
|
|
}, [actions, selectedActionId]);
|
|
|
|
|
|
|
|
|
|
const autoDiscoverAction = React.useMemo<OpenChamberProjectAction>(() => ({
|
|
|
|
|
id: AUTO_DISCOVER_ACTION_ID,
|
|
|
|
|
name: t('projectActions.actions.autoDiscover'),
|
|
|
|
|
command: '',
|
2026-07-18 00:06:30 +03:00
|
|
|
icon: 'scan-2',
|
2026-04-29 17:03:38 -04:00
|
|
|
autoOpenUrl: true,
|
|
|
|
|
}), [t]);
|
|
|
|
|
|
|
|
|
|
const canUseAutoDiscover = !isMobile;
|
|
|
|
|
const displayActions = React.useMemo(
|
|
|
|
|
() => canUseAutoDiscover ? [autoDiscoverAction, ...actions] : actions,
|
|
|
|
|
[actions, autoDiscoverAction, canUseAutoDiscover]
|
|
|
|
|
);
|
|
|
|
|
|
2026-02-27 20:45:03 +02:00
|
|
|
React.useEffect(() => {
|
|
|
|
|
void loadActions();
|
|
|
|
|
}, [loadActions]);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
2026-09-05 12:05:39 +03:00
|
|
|
const cleanups = watchedTerminalDirectories.map(executionDirectory => observeTerminalSessions(
|
|
|
|
|
terminal, executionDirectory, captureStartedActionMutationRevisions,
|
|
|
|
|
result => reconcileServerSessions(executionDirectory, result.sessions, {
|
|
|
|
|
startedActionMutationRevisions: result.startedActionMutationRevisions,
|
|
|
|
|
}),
|
|
|
|
|
));
|
|
|
|
|
return () => { for (const close of cleanups) close(); };
|
2026-09-05 03:04:36 -06:00
|
|
|
}, [captureStartedActionMutationRevisions, reconcileServerSessions, terminal, watchedTerminalDirectories]);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
for (const { directory: tabDirectory, state } of watchedTerminalStates) {
|
|
|
|
|
if (!tabDirectory) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
for (const tab of state?.tabs ?? []) {
|
|
|
|
|
if (tab.purpose.type !== 'project-action') continue;
|
|
|
|
|
const actionId = tab.purpose.actionId;
|
|
|
|
|
const action = displayActions.find((entry) => entry.id === actionId);
|
|
|
|
|
const nextLabel = action?.name ?? actionId;
|
|
|
|
|
const nextIcon = action?.icon || 'play';
|
|
|
|
|
if (tab.label !== nextLabel) {
|
|
|
|
|
setTabLabel(tabDirectory, tab.id, nextLabel);
|
|
|
|
|
}
|
|
|
|
|
if (tab.iconKey !== nextIcon) {
|
|
|
|
|
setTabIconKey(tabDirectory, tab.id, nextIcon);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, [displayActions, setTabIconKey, setTabLabel, watchedTerminalStates]);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
const browserWindow = globalThis.window;
|
|
|
|
|
if (!browserWindow) {
|
2026-02-27 20:45:03 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handler = (event: Event) => {
|
2026-09-05 03:04:36 -06:00
|
|
|
// SAFETY: this event name is only dispatched by our own project-actions update helper with this detail payload.
|
2026-02-27 20:45:03 +02:00
|
|
|
const detail = (event as CustomEvent<{ projectId?: string }>).detail;
|
|
|
|
|
if (!projectId) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (detail?.projectId && detail.projectId !== projectId) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
void loadActions();
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-05 03:04:36 -06:00
|
|
|
browserWindow.addEventListener(PROJECT_ACTIONS_UPDATED_EVENT, handler);
|
2026-02-27 20:45:03 +02:00
|
|
|
return () => {
|
2026-09-05 03:04:36 -06:00
|
|
|
browserWindow.removeEventListener(PROJECT_ACTIONS_UPDATED_EVENT, handler);
|
2026-02-27 20:45:03 +02:00
|
|
|
};
|
|
|
|
|
}, [loadActions, projectId]);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
|
|
|
|
if (!selectedActionId) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-29 17:03:38 -04:00
|
|
|
if (selectedActionId === AUTO_DISCOVER_ACTION_ID && canUseAutoDiscover) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-27 20:45:03 +02:00
|
|
|
if (!actions.some((entry) => entry.id === selectedActionId)) {
|
2026-04-29 17:03:38 -04:00
|
|
|
setSelectedActionId(null);
|
2026-02-27 20:45:03 +02:00
|
|
|
}
|
2026-04-29 17:03:38 -04:00
|
|
|
}, [actions, canUseAutoDiscover, selectedActionId]);
|
2026-02-27 20:45:03 +02:00
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
2026-08-13 22:44:13 +03:00
|
|
|
/**
|
|
|
|
|
* Decides what an auto-discovery run found, once its servers have had a
|
|
|
|
|
* moment to announce themselves. One address is opened; several are offered
|
|
|
|
|
* in the browser panel, because choosing between them would be a guess
|
|
|
|
|
* dressed up as a feature.
|
|
|
|
|
*/
|
|
|
|
|
const settleAutoDiscovery = (runKey: string) => {
|
|
|
|
|
delete previewWaitTimeoutByRunKeyRef.current[runKey];
|
|
|
|
|
const watch = urlWatchByRunKeyRef.current[runKey];
|
|
|
|
|
if (!watch || watch.openedUrl) return;
|
|
|
|
|
|
2026-09-05 03:04:36 -06:00
|
|
|
const executionStateKey = executionKey(watch.directory, watch.actionId, watch.executionId);
|
2026-08-13 22:44:13 +03:00
|
|
|
|
|
|
|
|
const candidates = watch.announced;
|
|
|
|
|
if (candidates.length === 0) return;
|
2026-09-05 03:04:36 -06:00
|
|
|
window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[executionStateKey]);
|
|
|
|
|
delete previewWaitTimeoutByRunKeyRef.current[executionStateKey];
|
2026-08-13 22:44:13 +03:00
|
|
|
watch.openedUrl = true;
|
2026-09-05 03:04:36 -06:00
|
|
|
setWaitingForPreviewByExecution((current) => {
|
|
|
|
|
if (!current[executionStateKey]) return current;
|
|
|
|
|
const next = { ...current };
|
|
|
|
|
delete next[executionStateKey];
|
|
|
|
|
return next;
|
|
|
|
|
});
|
2026-08-13 22:44:13 +03:00
|
|
|
|
|
|
|
|
if (candidates.length === 1) {
|
2026-09-05 03:04:36 -06:00
|
|
|
setAnnouncedDevServers(watch.directory, []);
|
|
|
|
|
setTabPreviewUrl(watch.directory, watch.tabId, candidates[0], { locked: false, autoOpened: true });
|
|
|
|
|
openContextPreview(watch.directory, candidates[0]);
|
2026-08-13 22:44:13 +03:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
watch.offering = true;
|
2026-09-05 03:04:36 -06:00
|
|
|
setAnnouncedDevServers(watch.directory, candidates);
|
|
|
|
|
useUIStore.getState().openContextSurface(watch.directory, 'browser');
|
2026-08-13 22:44:13 +03:00
|
|
|
toast.info(t('projectActions.toast.multipleServers'));
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-17 13:17:21 +03:00
|
|
|
const monitorRuns = () => {
|
2026-07-30 13:48:24 +03:00
|
|
|
const terminalStore = useTerminalStore.getState();
|
|
|
|
|
const terminalSessions = terminalStore.sessions;
|
2026-09-05 03:04:36 -06:00
|
|
|
const currentRuns = projectActionRuns;
|
2026-07-17 13:17:21 +03:00
|
|
|
for (const [runKey, entry] of Object.entries(currentRuns)) {
|
|
|
|
|
const directoryState = terminalSessions.get(entry.directory);
|
|
|
|
|
const tab = directoryState?.tabs.find((item) => item.id === entry.tabId);
|
|
|
|
|
if (!tab || tab.terminalSessionId !== entry.sessionId) {
|
2026-09-05 03:04:36 -06:00
|
|
|
clearExecutionUi(entry.directory, entry.actionId, entry.executionId);
|
2026-07-17 13:17:21 +03:00
|
|
|
continue;
|
2026-02-27 20:45:03 +02:00
|
|
|
}
|
|
|
|
|
|
2026-09-05 03:04:36 -06:00
|
|
|
const existingWatch = urlWatchByRunKeyRef.current[runKey];
|
|
|
|
|
const watch = existingWatch?.executionId === entry.executionId
|
|
|
|
|
? existingWatch
|
|
|
|
|
: {
|
|
|
|
|
hostDirectory: contextHostDirectoryRef.current || entry.directory,
|
|
|
|
|
directory: entry.directory,
|
|
|
|
|
tabId: entry.tabId,
|
|
|
|
|
actionId: entry.actionId,
|
|
|
|
|
executionId: entry.executionId,
|
|
|
|
|
lastSeenChunkId: null,
|
2026-09-05 12:05:39 +03:00
|
|
|
openedUrl: true,
|
2026-09-05 03:04:36 -06:00
|
|
|
tail: '',
|
|
|
|
|
openInPreview: false,
|
|
|
|
|
announced: [],
|
|
|
|
|
offering: false,
|
|
|
|
|
};
|
2026-07-17 13:17:21 +03:00
|
|
|
urlWatchByRunKeyRef.current[runKey] = watch;
|
|
|
|
|
const action = displayActions.find((item) => item.id === entry.actionId);
|
2026-07-30 13:48:24 +03:00
|
|
|
const bufferChunks = terminalStore.getBuffer(entry.directory, entry.tabId).chunks;
|
|
|
|
|
if (!action || bufferChunks.length === 0) continue;
|
2026-07-17 13:17:21 +03:00
|
|
|
|
2026-07-30 13:48:24 +03:00
|
|
|
const nextChunks = bufferChunks.filter((chunk) => watch.lastSeenChunkId === null || chunk.id > watch.lastSeenChunkId);
|
2026-07-17 13:17:21 +03:00
|
|
|
if (nextChunks.length === 0) continue;
|
|
|
|
|
|
|
|
|
|
const combined = nextChunks.map((chunk) => chunk.data).join('');
|
|
|
|
|
const textForScan = `${watch.tail}${combined}`;
|
2026-08-13 22:44:13 +03:00
|
|
|
// Auto-discovery inferred the command; it must not also infer the
|
|
|
|
|
// address. It collects what the servers announce and decides once they
|
|
|
|
|
// have had a moment to all speak up.
|
|
|
|
|
// Keep listening after the panel starts offering candidates: servers in
|
|
|
|
|
// one project can be seconds apart, and a list that froze at whoever was
|
|
|
|
|
// ready first would quietly omit the rest.
|
|
|
|
|
if (watch.openInPreview && (!watch.openedUrl || watch.offering)) {
|
|
|
|
|
const announced = extractAnnouncedUrls(textForScan);
|
|
|
|
|
const before = watch.announced.length;
|
|
|
|
|
for (const url of announced) {
|
|
|
|
|
if (!watch.announced.includes(url)) watch.announced.push(url);
|
|
|
|
|
}
|
|
|
|
|
const added = watch.announced.length - before;
|
|
|
|
|
|
|
|
|
|
if (watch.offering && added > 0) {
|
|
|
|
|
setAnnouncedDevServers(entry.directory, watch.announced);
|
|
|
|
|
} else if (!watch.openedUrl && before === 0 && watch.announced.length > 0) {
|
|
|
|
|
window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[runKey]);
|
|
|
|
|
previewWaitTimeoutByRunKeyRef.current[runKey] = window.setTimeout(
|
|
|
|
|
() => settleAutoDiscovery(runKey),
|
|
|
|
|
AUTO_DISCOVER_SETTLE_MS,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const maybeUrl = !watch.openedUrl && action.autoOpenUrl === true && !watch.openInPreview
|
|
|
|
|
? extractProjectActionUrl(textForScan)
|
|
|
|
|
: null;
|
2026-07-17 13:17:21 +03:00
|
|
|
const lastChunkId = nextChunks[nextChunks.length - 1]?.id ?? watch.lastSeenChunkId;
|
|
|
|
|
|
|
|
|
|
watch.lastSeenChunkId = lastChunkId;
|
|
|
|
|
watch.tail = textForScan.slice(-512);
|
|
|
|
|
|
|
|
|
|
if (maybeUrl) {
|
|
|
|
|
watch.openedUrl = true;
|
|
|
|
|
if (watch.openInPreview) {
|
|
|
|
|
const run = currentRuns[runKey];
|
|
|
|
|
if (run) {
|
2026-09-05 03:04:36 -06:00
|
|
|
setTabPreviewUrl(run.directory, run.tabId, maybeUrl, { locked: false, autoOpened: false, expectedExecutionId: run.executionId });
|
|
|
|
|
if (run.status === 'waiting-for-preview') {
|
|
|
|
|
setWaitingForPreviewByExecution((current) => {
|
|
|
|
|
const executionStateKey = executionKey(run.directory, run.actionId, run.executionId);
|
|
|
|
|
if (!current[executionStateKey]) return current;
|
|
|
|
|
const next = { ...current };
|
|
|
|
|
delete next[executionStateKey];
|
|
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-07-17 13:17:21 +03:00
|
|
|
window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[runKey]);
|
|
|
|
|
delete previewWaitTimeoutByRunKeyRef.current[runKey];
|
|
|
|
|
openContextPreview(run.directory, maybeUrl);
|
2026-04-29 17:03:38 -04:00
|
|
|
}
|
2026-07-17 13:17:21 +03:00
|
|
|
} else {
|
|
|
|
|
void openExternal(maybeUrl);
|
|
|
|
|
toast.success(t('projectActions.toast.openedUrlFromOutput'));
|
2026-04-29 17:03:38 -04:00
|
|
|
}
|
|
|
|
|
}
|
2026-07-17 13:17:21 +03:00
|
|
|
urlWatchByRunKeyRef.current[runKey] = watch;
|
2026-02-27 20:45:03 +02:00
|
|
|
}
|
|
|
|
|
|
2026-07-17 13:17:21 +03:00
|
|
|
for (const runKey of Object.keys(urlWatchByRunKeyRef.current)) {
|
|
|
|
|
if (!currentRuns[runKey]) {
|
2026-09-05 03:04:36 -06:00
|
|
|
const watch = urlWatchByRunKeyRef.current[runKey];
|
|
|
|
|
const currentTab = watch
|
|
|
|
|
? terminalSessions.get(watch.directory)?.tabs.find((tab) => tab.id === watch.tabId)
|
|
|
|
|
: undefined;
|
|
|
|
|
const watchStillOwnedByActiveExecution = currentTab?.purpose.type === 'project-action'
|
|
|
|
|
&& currentTab.purpose.executionId === watch?.executionId
|
|
|
|
|
&& Boolean(currentTab.terminalSessionId)
|
|
|
|
|
&& currentTab.lifecycle !== 'idle'
|
|
|
|
|
&& currentTab.lifecycle !== 'exited';
|
|
|
|
|
if (watchStillOwnedByActiveExecution) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-07-17 13:17:21 +03:00
|
|
|
delete urlWatchByRunKeyRef.current[runKey];
|
|
|
|
|
window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[runKey]);
|
|
|
|
|
delete previewWaitTimeoutByRunKeyRef.current[runKey];
|
|
|
|
|
}
|
2026-02-27 20:45:03 +02:00
|
|
|
}
|
2026-07-17 13:17:21 +03:00
|
|
|
};
|
2026-02-27 20:45:03 +02:00
|
|
|
|
2026-07-17 13:17:21 +03:00
|
|
|
monitorRuns();
|
|
|
|
|
return useTerminalStore.subscribe((state, previousState) => {
|
2026-07-30 13:48:24 +03:00
|
|
|
if (state.sessions !== previousState.sessions || state.buffers !== previousState.buffers) monitorRuns();
|
2026-07-17 13:17:21 +03:00
|
|
|
});
|
2026-09-05 03:04:36 -06:00
|
|
|
}, [clearExecutionUi, contextHostDirectoryRef, displayActions, executionKey, openContextPreview, openExternal, projectActionRuns, setTabPreviewUrl, t]);
|
2026-02-27 20:45:03 +02:00
|
|
|
|
2026-09-05 03:04:36 -06:00
|
|
|
React.useEffect(() => {
|
|
|
|
|
for (const { directory: tabDirectory, state } of watchedTerminalStates) {
|
|
|
|
|
for (const tab of state?.tabs ?? []) {
|
|
|
|
|
if (tab.purpose.type !== 'project-action' || !tab.purpose.executionId || !tab.terminalSessionId) continue;
|
|
|
|
|
if (tab.lifecycle !== 'running') continue;
|
|
|
|
|
const actionId = tab.purpose.actionId;
|
|
|
|
|
const currentExecutionId = tab.purpose.executionId;
|
|
|
|
|
const streamKey = executionKey(tabDirectory, actionId, currentExecutionId);
|
|
|
|
|
if (streamCleanupByRunKeyRef.current[streamKey]) continue;
|
|
|
|
|
const subscription = terminal.connect(tab.terminalSessionId, {
|
|
|
|
|
onEvent: (event) => {
|
|
|
|
|
if (!matchesActionExecution(tabDirectory, tab.id, currentExecutionId)) return;
|
|
|
|
|
if (event.type === 'snapshot') {
|
|
|
|
|
useTerminalStore.getState().replaceBuffer(tabDirectory, tab.id, event.data ?? '', event.sequence ?? 0);
|
|
|
|
|
if (event.status === 'running') {
|
|
|
|
|
useTerminalStore.getState().setTabLifecycle(tabDirectory, tab.id, 'running', { expectedExecutionId: currentExecutionId });
|
|
|
|
|
}
|
|
|
|
|
if (event.status === 'exited') {
|
|
|
|
|
useTerminalStore.getState().setTabLifecycle(tabDirectory, tab.id, 'exited', { expectedExecutionId: currentExecutionId });
|
|
|
|
|
useTerminalStore.getState().setTabPurpose(tabDirectory, tab.id, { type: 'project-action', actionId, executionId: null });
|
|
|
|
|
clearExecutionUi(tabDirectory, actionId, currentExecutionId);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const output = event.type === 'data' ? (event.data ?? '') : '';
|
|
|
|
|
if (output) {
|
|
|
|
|
useTerminalStore.getState().appendToBuffer(tabDirectory, tab.id, output, event.sequence, event.replayData);
|
|
|
|
|
}
|
|
|
|
|
if (event.type === 'exit') {
|
|
|
|
|
useTerminalStore.getState().setTabLifecycle(tabDirectory, tab.id, 'exited', { expectedExecutionId: currentExecutionId });
|
|
|
|
|
useTerminalStore.getState().setTabPurpose(tabDirectory, tab.id, { type: 'project-action', actionId, executionId: null });
|
|
|
|
|
clearExecutionUi(tabDirectory, actionId, currentExecutionId);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
onError: (_error, fatal) => {
|
|
|
|
|
if (!fatal || !matchesActionExecution(tabDirectory, tab.id, currentExecutionId)) return;
|
|
|
|
|
useTerminalStore.getState().setTabLifecycle(tabDirectory, tab.id, 'exited', { expectedExecutionId: currentExecutionId });
|
|
|
|
|
useTerminalStore.getState().setTabSessionId(tabDirectory, tab.id, null, { expectedExecutionId: currentExecutionId });
|
|
|
|
|
useTerminalStore.getState().setTabPurpose(tabDirectory, tab.id, { type: 'project-action', actionId, executionId: null });
|
|
|
|
|
clearExecutionUi(tabDirectory, actionId, currentExecutionId);
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
streamCleanupByRunKeyRef.current[streamKey] = subscription.close;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}, [clearExecutionUi, executionKey, matchesActionExecution, terminal, watchedTerminalStates]);
|
|
|
|
|
|
|
|
|
|
const getOrCreateActionTab = React.useCallback(async (action: OpenChamberProjectAction) => {
|
|
|
|
|
const executionDirectory = executionDirectoryFor(action);
|
|
|
|
|
if (!executionDirectory) {
|
2026-04-26 14:03:39 +03:00
|
|
|
throw new Error(t('projectActions.error.noActiveDirectory'));
|
2026-02-27 20:45:03 +02:00
|
|
|
}
|
|
|
|
|
|
2026-09-05 03:04:36 -06:00
|
|
|
const key = toProjectActionRunKey(executionDirectory, action.id);
|
|
|
|
|
ensureDirectory(executionDirectory);
|
2026-02-27 20:45:03 +02:00
|
|
|
|
|
|
|
|
const currentStore = useTerminalStore.getState();
|
2026-09-05 03:04:36 -06:00
|
|
|
const existingTab = getActionTab(executionDirectory, action.id, currentStore);
|
|
|
|
|
const tabId = existingTab?.id ?? currentStore.createTab(executionDirectory);
|
2026-02-27 20:45:03 +02:00
|
|
|
|
2026-09-05 03:04:36 -06:00
|
|
|
setTabLabel(executionDirectory, tabId, action.name);
|
|
|
|
|
setTabIconKey(executionDirectory, tabId, action.icon || 'play');
|
|
|
|
|
if (!existingTab) {
|
|
|
|
|
setTabPurpose(executionDirectory, tabId, { type: 'project-action', actionId: action.id, executionId: null });
|
2026-04-29 17:03:38 -04:00
|
|
|
}
|
2026-09-05 03:04:36 -06:00
|
|
|
setActiveTab(executionDirectory, tabId);
|
2026-02-27 20:45:03 +02:00
|
|
|
|
2026-09-05 03:04:36 -06:00
|
|
|
const stateAfterTab = useTerminalStore.getState().getDirectoryState(executionDirectory);
|
2026-02-27 20:45:03 +02:00
|
|
|
const tab = stateAfterTab?.tabs.find((entry) => entry.id === tabId);
|
|
|
|
|
return {
|
2026-09-05 03:04:36 -06:00
|
|
|
executionDirectory,
|
2026-02-27 20:45:03 +02:00
|
|
|
key,
|
|
|
|
|
tabId,
|
|
|
|
|
sessionId: tab?.terminalSessionId ?? null,
|
2026-09-05 03:04:36 -06:00
|
|
|
executionId: tab?.purpose.type === 'project-action' ? tab.purpose.executionId : null,
|
2026-02-27 20:45:03 +02:00
|
|
|
};
|
|
|
|
|
}, [
|
|
|
|
|
ensureDirectory,
|
2026-09-05 03:04:36 -06:00
|
|
|
executionDirectoryFor,
|
|
|
|
|
getActionTab,
|
2026-02-27 20:45:03 +02:00
|
|
|
setActiveTab,
|
2026-04-29 17:03:38 -04:00
|
|
|
setTabIconKey,
|
2026-02-27 20:45:03 +02:00
|
|
|
setTabLabel,
|
2026-09-05 03:04:36 -06:00
|
|
|
setTabPurpose,
|
2026-04-26 16:58:07 +03:00
|
|
|
t,
|
2026-02-27 20:45:03 +02:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const runAction = React.useCallback(async (action: OpenChamberProjectAction) => {
|
|
|
|
|
if (runtime.isVSCode || (!allowMobile && isMobile)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!normalizedDirectory) {
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.error(t('projectActions.error.noActiveDirectoryForAction'));
|
2026-02-27 20:45:03 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-05 03:04:36 -06:00
|
|
|
const runKey = toProjectActionRunKey(executionDirectoryFor(action), action.id);
|
2026-04-29 17:03:38 -04:00
|
|
|
const existingRun = projectActionRuns[runKey];
|
2026-02-27 20:45:03 +02:00
|
|
|
if (existingRun && existingRun.status === 'running') {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-07-17 13:17:21 +03:00
|
|
|
if (startingRunKeysRef.current.has(runKey)) return;
|
|
|
|
|
startingRunKeysRef.current.add(runKey);
|
2026-09-05 12:05:39 +03:00
|
|
|
let requestedExecution: { directory: string; tabId: string; id: string } | null = null;
|
2026-02-27 20:45:03 +02:00
|
|
|
|
|
|
|
|
try {
|
2026-04-29 17:03:38 -04:00
|
|
|
const discovered = action.id === AUTO_DISCOVER_ACTION_ID
|
|
|
|
|
? await (async (): Promise<OpenChamberProjectAction> => {
|
|
|
|
|
const [actionsState, scripts] = await Promise.all([
|
|
|
|
|
getProjectActionsState({ id: stableProjectRef?.id ?? '', path: normalizedDirectory }),
|
|
|
|
|
readPackageJsonScripts(normalizedDirectory),
|
|
|
|
|
]);
|
|
|
|
|
const devServer = await detectDevServerCommand(normalizedDirectory, actionsState.actions, scripts);
|
|
|
|
|
if (!devServer) {
|
|
|
|
|
throw new Error(t('contextPanel.preview.noDevServer'));
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
id: AUTO_DISCOVER_ACTION_ID,
|
|
|
|
|
name: t('projectActions.actions.autoDiscover'),
|
|
|
|
|
command: devServer.command,
|
2026-07-18 00:06:30 +03:00
|
|
|
icon: 'scan-2',
|
2026-04-29 17:03:38 -04:00
|
|
|
autoOpenUrl: true,
|
|
|
|
|
openUrl: devServer.previewUrlHint || '',
|
|
|
|
|
};
|
|
|
|
|
})()
|
|
|
|
|
: action;
|
|
|
|
|
|
|
|
|
|
const hasCustomOpenUrl = discovered.autoOpenUrl === true && (discovered.openUrl || '').trim().length > 0;
|
2026-07-17 13:17:21 +03:00
|
|
|
const revealTerminal = !hasCustomOpenUrl && action.id !== AUTO_DISCOVER_ACTION_ID;
|
2026-09-05 03:04:36 -06:00
|
|
|
const launchContextHostDirectory = contextHostDirectoryRef.current || normalizedDirectory;
|
2026-09-05 12:05:39 +03:00
|
|
|
const { executionDirectory, key, tabId } = await getOrCreateActionTab(discovered);
|
2026-09-05 03:04:36 -06:00
|
|
|
const normalizedCommand = normalizeProjectActionCommand(discovered.command);
|
|
|
|
|
if (!normalizedCommand) {
|
|
|
|
|
throw new Error(t('projectActions.error.failedToRunAction'));
|
|
|
|
|
}
|
2026-02-27 20:45:03 +02:00
|
|
|
|
2026-09-05 03:04:36 -06:00
|
|
|
const hasDesktopForwardSelection = discovered.autoOpenUrl === true
|
|
|
|
|
&& isDesktopShellApp
|
|
|
|
|
&& (discovered.desktopOpenSshForward || '').trim().length > 0;
|
|
|
|
|
const manualOpenUrl = discovered.autoOpenUrl ? normalizeManualOpenUrl(discovered.openUrl) : null;
|
|
|
|
|
const desktopForwardUrl = discovered.autoOpenUrl && isDesktopShellApp
|
|
|
|
|
? resolveProjectActionDesktopForwardUrl(discovered.desktopOpenSshForward, desktopSshInstances)
|
|
|
|
|
: null;
|
|
|
|
|
|
|
|
|
|
if (terminal.listSessions) {
|
|
|
|
|
const currentTab = getActionTab(executionDirectory, discovered.id);
|
|
|
|
|
if (currentTab?.purpose.type === 'project-action' && currentTab.purpose.executionId === null) {
|
|
|
|
|
const result = await reconcileTerminalSessionAuthority(terminal, executionDirectory, {
|
|
|
|
|
captureStartedActionMutationRevisions,
|
|
|
|
|
});
|
|
|
|
|
if (result) {
|
|
|
|
|
reconcileServerSessions(executionDirectory, result.sessions, {
|
|
|
|
|
startedActionMutationRevisions: result.startedActionMutationRevisions,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const priorTab = getActionTab(executionDirectory, discovered.id);
|
2026-09-05 12:05:39 +03:00
|
|
|
let activeSessionId: string;
|
|
|
|
|
let adoptedExecutionId: string;
|
|
|
|
|
if (priorTab?.lifecycle === 'running' && priorTab.terminalSessionId
|
|
|
|
|
&& priorTab.purpose.type === 'project-action' && priorTab.purpose.executionId) {
|
|
|
|
|
activeSessionId = priorTab.terminalSessionId;
|
|
|
|
|
adoptedExecutionId = priorTab.purpose.executionId;
|
|
|
|
|
} else {
|
|
|
|
|
const priorExecutionId = priorTab?.purpose.type === 'project-action' ? priorTab.purpose.executionId : null;
|
|
|
|
|
if (priorExecutionId) clearExecutionUi(executionDirectory, discovered.id, priorExecutionId);
|
|
|
|
|
const requestedExecutionId = allocateActionExecution(executionDirectory, tabId, discovered.id);
|
|
|
|
|
if (!requestedExecutionId) throw new Error(t('projectActions.error.failedToCreateTerminalSession'));
|
|
|
|
|
requestedExecution = { directory: executionDirectory, tabId, id: requestedExecutionId };
|
|
|
|
|
setConnecting(executionDirectory, tabId, true, { expectedExecutionId: requestedExecutionId });
|
2026-09-05 03:04:36 -06:00
|
|
|
const created = await createProjectActionTerminalSession({
|
|
|
|
|
terminal,
|
|
|
|
|
createOptions: {
|
|
|
|
|
cwd: executionDirectory,
|
2026-07-17 13:17:21 +03:00
|
|
|
shell: terminalShell,
|
|
|
|
|
loginShell: terminalLoginShell,
|
|
|
|
|
themeMode: currentTheme.metadata.variant === 'light' ? 'light' : 'dark',
|
|
|
|
|
terminalBackground: currentTheme.colors.surface.background,
|
|
|
|
|
terminalForeground: currentTheme.colors.syntax.base.foreground,
|
2026-09-05 03:04:36 -06:00
|
|
|
},
|
|
|
|
|
command: normalizedCommand,
|
|
|
|
|
isRunStillExpected: () => matchesActionExecution(executionDirectory, tabId, requestedExecutionId),
|
|
|
|
|
purpose: { type: 'project-action', actionId: discovered.id, executionId: requestedExecutionId },
|
|
|
|
|
});
|
|
|
|
|
if (!matchesActionExecution(executionDirectory, tabId, requestedExecutionId)) {
|
2026-09-05 12:05:39 +03:00
|
|
|
if (created.sessionId === requestedExecutionId) await terminal.close(created.sessionId).catch(() => undefined);
|
2026-09-05 03:04:36 -06:00
|
|
|
return;
|
2026-02-27 20:45:03 +02:00
|
|
|
}
|
2026-09-05 03:04:36 -06:00
|
|
|
adoptedExecutionId = created.purpose?.type === 'project-action' ? created.purpose.executionId : requestedExecutionId;
|
|
|
|
|
activeSessionId = created.sessionId;
|
2026-09-05 12:05:39 +03:00
|
|
|
setTabPurpose(executionDirectory, tabId, { type: 'project-action', actionId: discovered.id, executionId: adoptedExecutionId });
|
2026-09-05 03:04:36 -06:00
|
|
|
setTabSessionId(executionDirectory, tabId, activeSessionId, { expectedExecutionId: adoptedExecutionId });
|
|
|
|
|
setConnecting(executionDirectory, tabId, false, { expectedExecutionId: adoptedExecutionId });
|
2026-02-27 20:45:03 +02:00
|
|
|
}
|
|
|
|
|
|
2026-09-05 03:04:36 -06:00
|
|
|
if (revealTerminal && launchContextHostDirectory) {
|
|
|
|
|
revealProjectActionTerminal(launchContextHostDirectory, executionDirectory);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-05 12:05:39 +03:00
|
|
|
urlWatchByRunKeyRef.current[key] = {
|
|
|
|
|
hostDirectory: launchContextHostDirectory,
|
|
|
|
|
directory: executionDirectory,
|
|
|
|
|
tabId,
|
|
|
|
|
actionId: discovered.id,
|
|
|
|
|
executionId: adoptedExecutionId,
|
|
|
|
|
lastSeenChunkId: null,
|
|
|
|
|
openedUrl: Boolean(desktopForwardUrl) || Boolean(manualOpenUrl) || hasCustomOpenUrl,
|
|
|
|
|
tail: '',
|
|
|
|
|
openInPreview: discovered.id === AUTO_DISCOVER_ACTION_ID,
|
|
|
|
|
announced: [],
|
|
|
|
|
offering: false,
|
|
|
|
|
};
|
|
|
|
|
|
2026-09-05 03:04:36 -06:00
|
|
|
const executionStateKey = executionKey(executionDirectory, discovered.id, adoptedExecutionId);
|
|
|
|
|
setConnecting(executionDirectory, tabId, true, { expectedExecutionId: adoptedExecutionId });
|
2026-07-17 13:17:21 +03:00
|
|
|
const subscription = terminal.connect(
|
2026-04-29 17:03:38 -04:00
|
|
|
activeSessionId,
|
2026-07-17 13:17:21 +03:00
|
|
|
{ onEvent: (event) => {
|
2026-09-05 12:05:39 +03:00
|
|
|
if (!matchesActionExecution(executionDirectory, tabId, adoptedExecutionId)) return;
|
|
|
|
|
if (event.purpose?.type === 'project-action' && event.purpose.executionId !== adoptedExecutionId) return;
|
2026-07-17 13:17:21 +03:00
|
|
|
if (event.type === 'snapshot') {
|
2026-09-05 03:04:36 -06:00
|
|
|
useTerminalStore.getState().replaceBuffer(executionDirectory, tabId, event.data ?? '', event.sequence ?? 0);
|
|
|
|
|
useTerminalStore.getState().setConnecting(executionDirectory, tabId, false, { expectedExecutionId: adoptedExecutionId });
|
|
|
|
|
if (event.purpose?.type === 'project-action') {
|
|
|
|
|
useTerminalStore.getState().setTabPurpose(executionDirectory, tabId, { type: 'project-action', actionId: event.purpose.actionId, executionId: event.purpose.executionId });
|
|
|
|
|
}
|
|
|
|
|
if (event.status === 'running') {
|
|
|
|
|
useTerminalStore.getState().setTabLifecycle(executionDirectory, tabId, 'running', { expectedExecutionId: adoptedExecutionId });
|
|
|
|
|
}
|
|
|
|
|
if (event.status === 'exited') {
|
|
|
|
|
useTerminalStore.getState().setTabLifecycle(executionDirectory, tabId, 'exited', { expectedExecutionId: adoptedExecutionId });
|
|
|
|
|
useTerminalStore.getState().setTabPurpose(executionDirectory, tabId, { type: 'project-action', actionId: discovered.id, executionId: null });
|
|
|
|
|
clearExecutionUi(executionDirectory, discovered.id, adoptedExecutionId);
|
|
|
|
|
}
|
2026-07-17 13:17:21 +03:00
|
|
|
}
|
2026-09-05 03:04:36 -06:00
|
|
|
const output = event.type === 'data' ? (event.data ?? '') : '';
|
|
|
|
|
if (output) {
|
|
|
|
|
useTerminalStore.getState().appendToBuffer(executionDirectory, tabId, output, event.sequence, event.replayData);
|
2026-04-29 17:03:38 -04:00
|
|
|
}
|
|
|
|
|
if (event.type === 'exit') {
|
2026-09-05 03:04:36 -06:00
|
|
|
useTerminalStore.getState().setTabLifecycle(executionDirectory, tabId, 'exited', { expectedExecutionId: adoptedExecutionId });
|
|
|
|
|
useTerminalStore.getState().setConnecting(executionDirectory, tabId, false, { expectedExecutionId: adoptedExecutionId });
|
|
|
|
|
useTerminalStore.getState().setTabPurpose(executionDirectory, tabId, { type: 'project-action', actionId: discovered.id, executionId: null });
|
|
|
|
|
clearExecutionUi(executionDirectory, discovered.id, adoptedExecutionId);
|
2026-04-29 17:03:38 -04:00
|
|
|
}
|
2026-07-17 13:17:21 +03:00
|
|
|
}, onError: (_error, fatal) => {
|
2026-09-05 12:05:39 +03:00
|
|
|
if (!matchesActionExecution(executionDirectory, tabId, adoptedExecutionId)) return;
|
2026-09-05 03:04:36 -06:00
|
|
|
useTerminalStore.getState().setConnecting(executionDirectory, tabId, false, { expectedExecutionId: adoptedExecutionId });
|
2026-07-17 13:17:21 +03:00
|
|
|
if (fatal) {
|
2026-09-05 03:04:36 -06:00
|
|
|
useTerminalStore.getState().setTabLifecycle(executionDirectory, tabId, 'exited', { expectedExecutionId: adoptedExecutionId });
|
|
|
|
|
useTerminalStore.getState().setTabSessionId(executionDirectory, tabId, null, { expectedExecutionId: adoptedExecutionId });
|
|
|
|
|
useTerminalStore.getState().setTabPurpose(executionDirectory, tabId, { type: 'project-action', actionId: discovered.id, executionId: null });
|
|
|
|
|
clearExecutionUi(executionDirectory, discovered.id, adoptedExecutionId);
|
2026-07-17 13:17:21 +03:00
|
|
|
}
|
|
|
|
|
} },
|
2026-04-29 17:03:38 -04:00
|
|
|
);
|
2026-09-05 03:04:36 -06:00
|
|
|
if (!matchesActionExecution(executionDirectory, tabId, adoptedExecutionId)) {
|
|
|
|
|
subscription.close();
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-09-05 12:05:39 +03:00
|
|
|
streamCleanupByRunKeyRef.current[executionStateKey]?.();
|
2026-09-05 03:04:36 -06:00
|
|
|
streamCleanupByRunKeyRef.current[executionStateKey] = subscription.close;
|
2026-02-27 20:45:03 +02:00
|
|
|
|
2026-09-05 03:04:36 -06:00
|
|
|
window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[executionStateKey]);
|
|
|
|
|
delete previewWaitTimeoutByRunKeyRef.current[executionStateKey];
|
2026-04-29 17:03:38 -04:00
|
|
|
if (discovered.id === AUTO_DISCOVER_ACTION_ID && !manualOpenUrl) {
|
2026-09-05 03:04:36 -06:00
|
|
|
setWaitingForPreviewByExecution((current) => ({ ...current, [executionStateKey]: true }));
|
|
|
|
|
previewWaitTimeoutByRunKeyRef.current[executionStateKey] = window.setTimeout(() => {
|
|
|
|
|
delete previewWaitTimeoutByRunKeyRef.current[executionStateKey];
|
|
|
|
|
const watch = urlWatchByRunKeyRef.current[key];
|
|
|
|
|
if (!watch || watch.executionId !== adoptedExecutionId || watch.openedUrl || watch.offering) {
|
|
|
|
|
return;
|
2026-07-17 13:17:21 +03:00
|
|
|
}
|
2026-09-05 03:04:36 -06:00
|
|
|
if (!matchesActionExecution(executionDirectory, tabId, adoptedExecutionId)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setWaitingForPreviewByExecution((current) => {
|
|
|
|
|
if (!current[executionStateKey]) return current;
|
|
|
|
|
const next = { ...current };
|
|
|
|
|
delete next[executionStateKey];
|
|
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
useTerminalStore.getState().setActiveTab(executionDirectory, tabId);
|
|
|
|
|
revealProjectActionTerminal(watch.hostDirectory, executionDirectory);
|
2026-04-29 17:03:38 -04:00
|
|
|
}, AUTO_DISCOVER_PREVIEW_WAIT_TIMEOUT_MS);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 13:17:21 +03:00
|
|
|
|
2026-02-27 20:45:03 +02:00
|
|
|
if (desktopForwardUrl) {
|
2026-09-05 03:04:36 -06:00
|
|
|
setTabPreviewUrl(executionDirectory, tabId, null, { locked: true, expectedExecutionId: adoptedExecutionId });
|
2026-02-27 20:45:03 +02:00
|
|
|
void openExternal(desktopForwardUrl);
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.success(t('projectActions.toast.openedForwardedUrl'));
|
2026-02-27 20:45:03 +02:00
|
|
|
} else if (manualOpenUrl) {
|
2026-09-05 03:04:36 -06:00
|
|
|
setTabPreviewUrl(executionDirectory, tabId, manualOpenUrl, { locked: true, autoOpened: true, expectedExecutionId: adoptedExecutionId });
|
2026-09-05 12:05:39 +03:00
|
|
|
openContextPreview(launchContextHostDirectory, manualOpenUrl);
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.success(t('projectActions.toast.openedActionUrl'));
|
2026-02-27 20:45:03 +02:00
|
|
|
} else if (hasCustomOpenUrl) {
|
2026-09-05 03:04:36 -06:00
|
|
|
setTabPreviewUrl(executionDirectory, tabId, null, { locked: true, expectedExecutionId: adoptedExecutionId });
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.error(t('projectActions.error.invalidCustomUrlFormat'));
|
2026-02-27 20:45:03 +02:00
|
|
|
} else if (hasDesktopForwardSelection) {
|
2026-09-05 03:04:36 -06:00
|
|
|
setTabPreviewUrl(executionDirectory, tabId, null, { locked: true, expectedExecutionId: adoptedExecutionId });
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.error(t('projectActions.error.selectedDesktopSshForwardUnavailable'));
|
2026-04-29 17:03:38 -04:00
|
|
|
} else {
|
2026-09-05 03:04:36 -06:00
|
|
|
setTabPreviewUrl(executionDirectory, tabId, null, { locked: false, autoOpened: false, expectedExecutionId: adoptedExecutionId });
|
2026-02-27 20:45:03 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
2026-09-05 12:05:39 +03:00
|
|
|
if (requestedExecution && matchesActionExecution(requestedExecution.directory, requestedExecution.tabId, requestedExecution.id)) {
|
|
|
|
|
const { directory: failedDirectory, tabId: failedTabId, id } = requestedExecution;
|
|
|
|
|
clearExecutionUi(failedDirectory, action.id, id);
|
|
|
|
|
setTabLifecycle(failedDirectory, failedTabId, 'exited', { expectedExecutionId: id });
|
|
|
|
|
setTabPurpose(failedDirectory, failedTabId, { type: 'project-action', actionId: action.id, executionId: null });
|
2026-09-05 03:04:36 -06:00
|
|
|
}
|
|
|
|
|
if (error instanceof Error && error.message === 'PROJECT_ACTION_RUN_CANCELLED') {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (error instanceof Error && (error.message === 'COMMAND_MODE_UNSUPPORTED' || error.message === 'PROJECT_ACTION_PURPOSE_UNSUPPORTED')) {
|
|
|
|
|
toast.error(t('projectActions.error.failedToCreateTerminalSession'));
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-04-26 14:03:39 +03:00
|
|
|
toast.error(error instanceof Error ? error.message : t('projectActions.error.failedToRunAction'));
|
2026-07-17 13:17:21 +03:00
|
|
|
} finally {
|
|
|
|
|
startingRunKeysRef.current.delete(runKey);
|
2026-02-27 20:45:03 +02:00
|
|
|
}
|
|
|
|
|
}, [
|
2026-07-17 13:17:21 +03:00
|
|
|
currentTheme.colors.surface.background,
|
|
|
|
|
currentTheme.colors.syntax.base.foreground,
|
|
|
|
|
currentTheme.metadata.variant,
|
2026-09-05 03:04:36 -06:00
|
|
|
contextHostDirectoryRef,
|
2026-02-27 20:45:03 +02:00
|
|
|
desktopSshInstances,
|
|
|
|
|
getOrCreateActionTab,
|
|
|
|
|
allowMobile,
|
|
|
|
|
isMobile,
|
|
|
|
|
isDesktopShellApp,
|
|
|
|
|
normalizedDirectory,
|
2026-07-17 13:17:21 +03:00
|
|
|
terminalLoginShell,
|
|
|
|
|
terminalShell,
|
2026-02-27 20:45:03 +02:00
|
|
|
openExternal,
|
2026-04-29 17:03:38 -04:00
|
|
|
openContextPreview,
|
|
|
|
|
projectActionRuns,
|
2026-09-05 03:04:36 -06:00
|
|
|
revealProjectActionTerminal,
|
2026-02-27 20:45:03 +02:00
|
|
|
runtime.isVSCode,
|
2026-09-05 03:04:36 -06:00
|
|
|
executionDirectoryFor,
|
|
|
|
|
matchesActionExecution,
|
|
|
|
|
clearExecutionUi,
|
|
|
|
|
executionKey,
|
|
|
|
|
getActionTab,
|
|
|
|
|
reconcileServerSessions,
|
|
|
|
|
allocateActionExecution,
|
|
|
|
|
captureStartedActionMutationRevisions,
|
2026-02-27 20:45:03 +02:00
|
|
|
setConnecting,
|
2026-09-05 03:04:36 -06:00
|
|
|
setTabLifecycle,
|
|
|
|
|
setTabPurpose,
|
2026-04-29 17:03:38 -04:00
|
|
|
setTabPreviewUrl,
|
2026-02-27 20:45:03 +02:00
|
|
|
setTabSessionId,
|
2026-04-29 17:03:38 -04:00
|
|
|
stableProjectRef?.id,
|
2026-04-26 16:58:07 +03:00
|
|
|
t,
|
2026-02-27 20:45:03 +02:00
|
|
|
terminal,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const stopAction = React.useCallback(async (action: OpenChamberProjectAction) => {
|
2026-09-05 03:04:36 -06:00
|
|
|
const runKey = toProjectActionRunKey(executionDirectoryFor(action), action.id);
|
2026-04-29 17:03:38 -04:00
|
|
|
const activeRun = projectActionRuns[runKey];
|
2026-02-27 20:45:03 +02:00
|
|
|
if (!activeRun) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-05 03:04:36 -06:00
|
|
|
await stopProjectActionTerminalSession({
|
|
|
|
|
terminal,
|
|
|
|
|
sessionId: activeRun.sessionId,
|
|
|
|
|
isExecutionStillCurrent: () => matchesActionExecution(activeRun.directory, activeRun.tabId, activeRun.executionId),
|
|
|
|
|
markStopping: () => {
|
|
|
|
|
setTabLifecycle(activeRun.directory, activeRun.tabId, 'stopping', { expectedExecutionId: activeRun.executionId });
|
|
|
|
|
},
|
|
|
|
|
restoreRunning: () => {
|
|
|
|
|
setTabLifecycle(activeRun.directory, activeRun.tabId, 'running', { expectedExecutionId: activeRun.executionId });
|
|
|
|
|
},
|
|
|
|
|
clearSession: () => {
|
|
|
|
|
setTabSessionId(activeRun.directory, activeRun.tabId, null, { expectedExecutionId: activeRun.executionId });
|
|
|
|
|
},
|
|
|
|
|
finalizeExit: () => {
|
|
|
|
|
setTabLifecycle(activeRun.directory, activeRun.tabId, 'exited', { expectedExecutionId: activeRun.executionId });
|
|
|
|
|
setTabPurpose(activeRun.directory, activeRun.tabId, { type: 'project-action', actionId: activeRun.actionId, executionId: null });
|
|
|
|
|
clearExecutionUi(activeRun.directory, activeRun.actionId, activeRun.executionId);
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}, [clearExecutionUi, executionDirectoryFor, matchesActionExecution, projectActionRuns, setTabLifecycle, setTabPurpose, setTabSessionId, terminal]);
|
2026-02-27 20:45:03 +02:00
|
|
|
|
|
|
|
|
const handlePrimaryClick = React.useCallback(() => {
|
2026-04-29 17:03:38 -04:00
|
|
|
const action = selectedAction ?? displayActions[0];
|
|
|
|
|
if (!action) {
|
2026-02-27 20:45:03 +02:00
|
|
|
return;
|
|
|
|
|
}
|
2026-09-05 03:04:36 -06:00
|
|
|
const runKey = toProjectActionRunKey(executionDirectoryFor(action), action.id);
|
2026-04-29 17:03:38 -04:00
|
|
|
const runningEntry = projectActionRuns[runKey];
|
2026-02-27 20:45:03 +02:00
|
|
|
if (runningEntry?.status === 'stopping') {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (runningEntry) {
|
2026-04-29 17:03:38 -04:00
|
|
|
void stopAction(action);
|
2026-02-27 20:45:03 +02:00
|
|
|
return;
|
|
|
|
|
}
|
2026-04-29 17:03:38 -04:00
|
|
|
void runAction(action);
|
2026-09-05 03:04:36 -06:00
|
|
|
}, [displayActions, executionDirectoryFor, runAction, projectActionRuns, selectedAction, stopAction]);
|
2026-02-27 20:45:03 +02:00
|
|
|
|
|
|
|
|
const handleSelectAction = React.useCallback((action: OpenChamberProjectAction, toggleStopIfRunning = false) => {
|
|
|
|
|
setSelectedActionId(action.id);
|
|
|
|
|
|
|
|
|
|
if (!toggleStopIfRunning) {
|
|
|
|
|
void runAction(action);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-05 03:04:36 -06:00
|
|
|
const runKey = toProjectActionRunKey(executionDirectoryFor(action), action.id);
|
2026-04-29 17:03:38 -04:00
|
|
|
const runningEntry = projectActionRuns[runKey];
|
2026-02-27 20:45:03 +02:00
|
|
|
if (runningEntry?.status === 'stopping') {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (runningEntry) {
|
|
|
|
|
void stopAction(action);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
void runAction(action);
|
2026-09-05 03:04:36 -06:00
|
|
|
}, [executionDirectoryFor, runAction, projectActionRuns, stopAction]);
|
2026-02-27 20:45:03 +02:00
|
|
|
|
|
|
|
|
const openProjectActionsSettings = React.useCallback(() => {
|
|
|
|
|
if (!stableProjectRef?.id) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setSettingsProjectsSelectedId(stableProjectRef.id);
|
|
|
|
|
setSettingsPage('projects');
|
|
|
|
|
setSettingsDialogOpen(true);
|
|
|
|
|
}, [setSettingsDialogOpen, setSettingsPage, setSettingsProjectsSelectedId, stableProjectRef?.id]);
|
|
|
|
|
|
2026-07-17 13:17:21 +03:00
|
|
|
const previewAction = selectedAction ?? displayActions[0] ?? null;
|
2026-09-05 03:04:36 -06:00
|
|
|
const previewRun = previewAction ? projectActionRuns[toProjectActionRunKey(executionDirectoryFor(previewAction), previewAction.id)] : null;
|
2026-07-17 13:17:21 +03:00
|
|
|
const selectedRunPreviewUrl = useTerminalStore((state) => {
|
|
|
|
|
if (!previewRun) return null;
|
2026-09-05 14:47:21 +03:00
|
|
|
return state.getDirectoryState(previewRun.directory)?.tabs.find((tab) => tab.id === previewRun.tabId)?.previewUrl ?? null;
|
2026-07-17 13:17:21 +03:00
|
|
|
});
|
|
|
|
|
|
2026-02-27 20:45:03 +02:00
|
|
|
if (runtime.isVSCode || (!allowMobile && isMobile) || !stableProjectRef || !normalizedDirectory) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 17:03:38 -04:00
|
|
|
const resolvedSelected = selectedAction ?? displayActions[0] ?? null;
|
2026-02-27 20:45:03 +02:00
|
|
|
if (!resolvedSelected) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-05 03:04:36 -06:00
|
|
|
const selectedIconName = resolveProjectActionIconName(resolvedSelected);
|
|
|
|
|
const selectedRunKey = toProjectActionRunKey(executionDirectoryFor(resolvedSelected), resolvedSelected.id);
|
2026-04-29 17:03:38 -04:00
|
|
|
const selectedRunning = projectActionRuns[selectedRunKey];
|
2026-02-27 20:45:03 +02:00
|
|
|
const isStoppingSelected = selectedRunning?.status === 'stopping';
|
2026-04-29 17:03:38 -04:00
|
|
|
const isWaitingForSelectedPreview = selectedRunning?.status === 'waiting-for-preview';
|
|
|
|
|
const showSelectedPreviewButton = Boolean(selectedRunning && selectedRunPreviewUrl);
|
|
|
|
|
const handleOpenSelectedPreview = () => {
|
|
|
|
|
if (!selectedRunning || !selectedRunPreviewUrl) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
openContextPreview(selectedRunning.directory, selectedRunPreviewUrl);
|
|
|
|
|
};
|
2026-07-18 00:06:30 +03:00
|
|
|
const isAutoDiscoverSelected = resolvedSelected.id === AUTO_DISCOVER_ACTION_ID;
|
2026-02-27 20:45:03 +02:00
|
|
|
|
|
|
|
|
if (compact) {
|
|
|
|
|
return (
|
2026-04-29 17:03:38 -04:00
|
|
|
<div className="inline-flex items-center">
|
2026-07-18 00:06:30 +03:00
|
|
|
<Tooltip>
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
disabled={isLoading || isStoppingSelected}
|
|
|
|
|
className={cn(
|
|
|
|
|
'app-region-no-drag inline-flex h-9 w-9 items-center justify-center rounded-[10px] [corner-shape:squircle] supports-[corner-shape:squircle]:rounded-[50px] p-2',
|
|
|
|
|
'typography-ui-label font-medium text-muted-foreground hover:bg-interactive-hover hover:text-foreground transition-colors',
|
|
|
|
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
|
|
|
|
|
'disabled:cursor-not-allowed',
|
|
|
|
|
className
|
|
|
|
|
)}
|
|
|
|
|
onClick={handlePrimaryClick}
|
|
|
|
|
aria-label={selectedRunning
|
|
|
|
|
? t('projectActions.actions.stopNamedAria', { name: resolvedSelected.name })
|
|
|
|
|
: t('projectActions.actions.runNamedAria', { name: resolvedSelected.name })}
|
|
|
|
|
>
|
|
|
|
|
{isStoppingSelected || isWaitingForSelectedPreview
|
|
|
|
|
? <Icon name="loader-4" className="h-5 w-5 animate-spin text-[var(--status-warning)]" />
|
|
|
|
|
: selectedRunning
|
|
|
|
|
? <Icon name="stop" className="h-5 w-5 text-[var(--status-warning)]" />
|
|
|
|
|
: <Icon name={selectedIconName} className="h-5 w-5" />}
|
|
|
|
|
</button>
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
{isAutoDiscoverSelected ? (
|
|
|
|
|
<TooltipContent sideOffset={6}>{t('projectActions.actions.autoDiscoverTooltip')}</TooltipContent>
|
|
|
|
|
) : null}
|
|
|
|
|
</Tooltip>
|
2026-04-29 17:03:38 -04:00
|
|
|
{showSelectedPreviewButton ? (
|
2026-04-30 22:41:33 +03:00
|
|
|
<Tooltip>
|
2026-04-29 17:03:38 -04:00
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="app-region-no-drag -ml-1 inline-flex h-9 w-7 items-center justify-center rounded-[10px] text-muted-foreground hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
|
|
|
|
aria-label={t('projectActions.actions.openPreview')}
|
|
|
|
|
onClick={handleOpenSelectedPreview}
|
2026-02-27 20:45:03 +02:00
|
|
|
>
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="global" className="h-4 w-4" />
|
2026-04-29 17:03:38 -04:00
|
|
|
</button>
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
<TooltipContent sideOffset={6}>{t('projectActions.actions.openPreview')}</TooltipContent>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
) : null}
|
|
|
|
|
<DropdownMenu>
|
|
|
|
|
<DropdownMenuTrigger asChild>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="app-region-no-drag -ml-1 inline-flex h-9 w-5 items-center justify-center rounded-[10px] text-muted-foreground hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
|
|
|
|
aria-label={t('projectActions.actions.chooseActionAria')}
|
|
|
|
|
>
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="arrow-down-s" className="h-3.5 w-3.5" />
|
2026-04-29 17:03:38 -04:00
|
|
|
</button>
|
|
|
|
|
</DropdownMenuTrigger>
|
|
|
|
|
<DropdownMenuContent align="end" className="w-52 max-h-[70vh] overflow-y-auto">
|
|
|
|
|
<DropdownMenuItem className="flex items-center gap-2" onClick={openProjectActionsSettings}>
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="add" className="h-4 w-4" />
|
2026-04-29 17:03:38 -04:00
|
|
|
<span className="typography-ui-label text-foreground">{t('projectActions.actions.addNewAction')}</span>
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
<DropdownMenuSeparator />
|
|
|
|
|
{displayActions.map((entry) => {
|
2026-09-05 03:04:36 -06:00
|
|
|
const iconName = resolveProjectActionIconName(entry);
|
|
|
|
|
const runKey = toProjectActionRunKey(executionDirectoryFor(entry), entry.id);
|
2026-04-29 17:03:38 -04:00
|
|
|
const runState = projectActionRuns[runKey];
|
|
|
|
|
const isRunning = Boolean(runState);
|
|
|
|
|
const isStopping = runState?.status === 'stopping';
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<DropdownMenuItem
|
|
|
|
|
key={entry.id}
|
|
|
|
|
className="flex items-center gap-2"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
handleSelectAction(entry, true);
|
|
|
|
|
}}
|
|
|
|
|
>
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name={iconName} className="h-4 w-4" />
|
2026-04-29 17:03:38 -04:00
|
|
|
<span className="typography-ui-label text-foreground truncate">{entry.name}</span>
|
|
|
|
|
{isStopping || runState?.status === 'waiting-for-preview'
|
2026-05-13 13:26:15 +03:00
|
|
|
? <Icon name="loader-4" className="ml-auto h-4 w-4 animate-spin text-[var(--status-warning)]" />
|
2026-04-29 17:03:38 -04:00
|
|
|
: isRunning
|
2026-05-13 13:26:15 +03:00
|
|
|
? <Icon name="stop" className="ml-auto h-4 w-4 text-[var(--status-warning)]" />
|
2026-04-29 17:03:38 -04:00
|
|
|
: null}
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</DropdownMenuContent>
|
|
|
|
|
</DropdownMenu>
|
|
|
|
|
</div>
|
2026-02-27 20:45:03 +02:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
2026-04-19 15:52:54 +03:00
|
|
|
'app-region-no-drag inline-flex shrink-0 items-center self-center rounded-[9px] [corner-shape:squircle] supports-[corner-shape:squircle]:rounded-[50px]',
|
|
|
|
|
'bg-[var(--surface-elevated)] overflow-hidden',
|
|
|
|
|
'border border-border/60',
|
2026-02-27 20:45:03 +02:00
|
|
|
compact ? 'h-9' : 'h-7',
|
|
|
|
|
className
|
|
|
|
|
)}
|
|
|
|
|
>
|
2026-07-18 00:06:30 +03:00
|
|
|
<Tooltip>
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={handlePrimaryClick}
|
|
|
|
|
disabled={isLoading || isStoppingSelected}
|
|
|
|
|
className={cn(
|
|
|
|
|
'inline-flex h-full items-center justify-center typography-ui-label font-medium text-foreground hover:bg-interactive-hover',
|
|
|
|
|
compact ? 'w-9 px-0' : 'px-2.5',
|
|
|
|
|
'transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:cursor-not-allowed'
|
|
|
|
|
)}
|
|
|
|
|
aria-label={selectedRunning
|
|
|
|
|
? t('projectActions.actions.stopNamedAria', { name: resolvedSelected.name })
|
|
|
|
|
: t('projectActions.actions.runNamedAria', { name: resolvedSelected.name })}
|
|
|
|
|
>
|
|
|
|
|
<span className="inline-flex h-4 w-4 shrink-0 items-center justify-center">
|
|
|
|
|
{isStoppingSelected || isWaitingForSelectedPreview
|
|
|
|
|
? <Icon name="loader-4" className="h-4 w-4 animate-spin text-[var(--status-warning)]" />
|
|
|
|
|
: selectedRunning
|
|
|
|
|
? <Icon name="stop" className="h-4 w-4 text-[var(--status-warning)]" />
|
|
|
|
|
: <Icon name={selectedIconName} className="h-4 w-4" />}
|
|
|
|
|
</span>
|
|
|
|
|
</button>
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
{isAutoDiscoverSelected ? (
|
|
|
|
|
<TooltipContent sideOffset={6}>{t('projectActions.actions.autoDiscoverTooltip')}</TooltipContent>
|
|
|
|
|
) : null}
|
|
|
|
|
</Tooltip>
|
2026-02-27 20:45:03 +02:00
|
|
|
|
2026-04-29 17:03:38 -04:00
|
|
|
{showSelectedPreviewButton ? (
|
2026-04-30 22:41:33 +03:00
|
|
|
<Tooltip>
|
2026-04-29 17:03:38 -04:00
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={handleOpenSelectedPreview}
|
|
|
|
|
className={cn(
|
|
|
|
|
compact ? 'inline-flex h-full w-8 items-center justify-center' : 'inline-flex h-full w-7 items-center justify-center',
|
|
|
|
|
'border-l border-[var(--interactive-border)] text-foreground',
|
|
|
|
|
'hover:bg-interactive-hover transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary'
|
|
|
|
|
)}
|
|
|
|
|
aria-label={t('projectActions.actions.openPreview')}
|
|
|
|
|
>
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="global" className="h-4 w-4" />
|
2026-04-29 17:03:38 -04:00
|
|
|
</button>
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
<TooltipContent sideOffset={6}>{t('projectActions.actions.openPreview')}</TooltipContent>
|
|
|
|
|
</Tooltip>
|
|
|
|
|
) : null}
|
|
|
|
|
|
2026-02-27 20:45:03 +02:00
|
|
|
<DropdownMenu>
|
|
|
|
|
<DropdownMenuTrigger asChild>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className={cn(
|
|
|
|
|
compact ? 'inline-flex h-full w-8 items-center justify-center' : 'inline-flex h-full w-7 items-center justify-center',
|
|
|
|
|
'border-l border-[var(--interactive-border)] text-muted-foreground',
|
2026-03-20 01:01:03 +02:00
|
|
|
'hover:bg-interactive-hover hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary'
|
2026-02-27 20:45:03 +02:00
|
|
|
)}
|
2026-04-26 14:03:39 +03:00
|
|
|
aria-label={t('projectActions.actions.chooseActionAria')}
|
2026-02-27 20:45:03 +02:00
|
|
|
>
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="arrow-down-s" className="h-4 w-4" />
|
2026-02-27 20:45:03 +02:00
|
|
|
</button>
|
|
|
|
|
</DropdownMenuTrigger>
|
2026-05-20 23:22:09 +03:00
|
|
|
<DropdownMenuContent align="start" className="w-52 max-h-[70vh] overflow-y-auto">
|
2026-02-27 20:45:03 +02:00
|
|
|
<DropdownMenuItem className="flex items-center gap-2" onClick={openProjectActionsSettings}>
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name="add" className="h-4 w-4" />
|
2026-04-26 14:03:39 +03:00
|
|
|
<span className="typography-ui-label text-foreground">{t('projectActions.actions.addNewAction')}</span>
|
2026-02-27 20:45:03 +02:00
|
|
|
</DropdownMenuItem>
|
|
|
|
|
<DropdownMenuSeparator />
|
2026-04-29 17:03:38 -04:00
|
|
|
{displayActions.map((entry) => {
|
2026-09-05 03:04:36 -06:00
|
|
|
const iconName = resolveProjectActionIconName(entry);
|
|
|
|
|
const runKey = toProjectActionRunKey(executionDirectoryFor(entry), entry.id);
|
2026-04-29 17:03:38 -04:00
|
|
|
const runState = projectActionRuns[runKey];
|
2026-02-27 20:45:03 +02:00
|
|
|
const isRunning = Boolean(runState);
|
|
|
|
|
const isStopping = runState?.status === 'stopping';
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<DropdownMenuItem
|
|
|
|
|
key={entry.id}
|
|
|
|
|
className="flex items-center gap-2"
|
|
|
|
|
onClick={() => {
|
2026-05-20 23:22:09 +03:00
|
|
|
handleSelectAction(entry, true);
|
2026-02-27 20:45:03 +02:00
|
|
|
}}
|
|
|
|
|
>
|
2026-05-13 13:26:15 +03:00
|
|
|
<Icon name={iconName} className="h-4 w-4" />
|
2026-02-27 20:45:03 +02:00
|
|
|
<span className="typography-ui-label text-foreground truncate">{entry.name}</span>
|
2026-04-29 17:03:38 -04:00
|
|
|
{isStopping || runState?.status === 'waiting-for-preview'
|
2026-05-13 13:26:15 +03:00
|
|
|
? <Icon name="loader-4" className="ml-auto h-4 w-4 animate-spin text-[var(--status-warning)]" />
|
2026-02-27 20:45:03 +02:00
|
|
|
: isRunning
|
2026-05-13 13:26:15 +03:00
|
|
|
? <Icon name="stop" className="ml-auto h-4 w-4 text-[var(--status-warning)]" />
|
2026-02-27 20:45:03 +02:00
|
|
|
: null}
|
|
|
|
|
</DropdownMenuItem>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</DropdownMenuContent>
|
|
|
|
|
</DropdownMenu>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|