Files
openchamber/packages/ui/src/components/layout/ProjectActionsButton.tsx
T
Bohdan Triapitsyn 4506c18f53 UI refresh: Base UI migration + flat tinted button language + mobile polish (#960)
* deps: bump @opencode-ai/sdk to 1.4.6 and add @base-ui/react

* build: align vite configs with Base UI and packages update

* feat(ui): migrate primitives to Base UI

Replace Radix-based wrappers (button, checkbox, collapsible, command,
dialog, dropdown-menu, input, radio, scroll-area, select, separator,
sonner, switch, textarea, toggle, tooltip) with @base-ui/react
equivalents and a refreshed visual language: flexoki theme tokens,
typography utilities, and a redesigned CommandPalette. Add new
fancy-button variant set and a local slot helper for asChild composition.

* refactor(ui consumers): adopt Base UI primitives across the app

Update chat, layout, git views, settings, multirun, session and agent
management screens to use the new Base UI-backed primitives (anchor
width token rename, dropdown/select/dialog usage adjustments, new
component APIs).

* fix(ScrollableOverlay): forward `disabled` to inner element

Add `disabled` to the type so it propagates via rest spread to the
underlying element (e.g. textarea in simple mode). Resolves a type
error reported after the main merge.

* refactor(dropdown consumers): migrate preventDefault sites to closeOnClick

Base UI's Menu.Item does not check `event.defaultPrevented` before
closing the menu, so the Radix-era `onSelect={(e) => e.preventDefault()}`
pattern no longer keeps the menu open. Replace those sites with the
Base UI `closeOnClick={false}` prop (VSCodeLayout rate-limit info
rows; AgentGroupDetail worktree actions).

* docs(AGENTS): note Base UI as primary source for UI primitives

* fix(ContextPanel): center expand/close buttons in tab header

Header (h-8) had the action row pinned with items-end + pb-1.5, which
placed the h-7 buttons ~2px above the header top and made them look
cropped. Switch to items-center so they sit on the same baseline as
the tabs.

* style(tabs): drop transitions on default variant, soften active pill

Default (underline) variant now switches instantly — no transform/width
transition on the underline indicator, no color transition on tab
buttons. Active pill bg is color-mixed down to 55% of
--interactive-selection so light-theme selection doesn't scream.

* style(button): pill-style elevation and introduce chip variant

Solid variants (default/destructive/neutral/primary/basic) now use the
active-pill shadow stack — hairline navy outer ring + stacked tight/soft
drops — instead of the pure-black inset/border approach. Outline
variant (and FancyButton basic) gets the same elevation without the
white top highlight (no fill to catch light).

Add a new chip variant: flat `border border-border/60` button without
elevation, intended for one-of-N toggle groups where a raised
button would read as the wrong affordance.

* style(header): apply pill-style shadow to OpenIn and Project Actions

Both split-button wrappers previously used a 1px CSS border for
definition. Replace that with the pill elevation stack so the header
buttons match the new Button variants and feel less hard-edged.

* refactor(toggles): migrate one-of-N segmented groups to chip variant

Color Mode, When-sessions-expire, voice/STT providers, auth method,
agent mode, MCP transport, tunnel type and terminal Ctrl/Cmd modifier
toggles all read as flat chip selectors rather than raised buttons.
Switch them from variant="outline" to the new chip variant so the
raised pill-style elevation doesn't fight the selected-state tint.

* fix(elevation): theme-adaptive pill-style shadow for dark mode

The navy (rgba(14,18,27,*)) outer ring was invisible on dark surfaces,
erasing the button/pill edge. Add dark: variants that use a subtle
white outer ring (rgba(255,255,255,0.10)) and darker drop shadows
(rgba(0,0,0,0.30/0.20)) so the elevation reads on both themes.

Applies to Button solid/outline variants, FancyButton equivalents,
the OpenIn and Project Actions header split-buttons, and the active
pill indicator in SortableTabsStrip.

* style(button): flat tinted variants with squircle corner-shape

Replace elevated pill-shadow design with a flat tinted language: pale tinted
fill + hairline tinted border + saturated tinted text for default/destructive.
Add CSS corner-shape: squircle with @supports gate so Chrome 136+ gets the
softened superellipse corners at 50px, Safari falls back to a slightly larger
round radius.

* style(tabs): replace active pill shadow with tinted border

* style(header): flat border and squircle corners for OpenIn / Actions

Drop pill-style shadow stacks in favor of a plain hairline border and apply
the same squircle corner-shape treatment as Button so header controls match
the new visual language.

* style(checkbox): drop filled background for tinted checkmark

Checked / indeterminate states now keep the transparent box, show a
primary-base check/minus glyph, and use a softened primary-tinted inset
border (50% mix) so the control reads as selected without a solid swatch.

* style(layout): widen sidebar-to-chat corner radius to 10px

* style(button): retune tinted variants and unify outline fill

- Bump primary fill toward noticeable but still soft (light 10/16/22,
  dark 16/22/30) and weaken both primary and destructive borders
  (12% light / 20% dark) so the tone reads without shouting.
- Further calm destructive across fill and border so revert-style
  actions match the muted reference.
- Align the outline variant fill with --surface-elevated so git-view
  generate/commit buttons share the header action button surface.

* style(tabs): align pill variant with new button language

Active pill now uses --surface-elevated fill and border-border/60 to
match the outline button. Track and tab/pill geometry adopt the same
squircle treatment as Button: 9-10px fallback with 50px radius under
@supports(corner-shape:squircle). Also tighten track padding (inline
2px, block 2px) and grow active tab height so the indicator fills
the track instead of floating inside it.

* refactor(theme): hardcode radius scale, drop per-theme override

Radius values are now fixed in the design system (flexoki-dark's scale
becomes the app default). Themes can no longer override --radius-* — the
config.radius block is removed from every theme JSON, dropped from the
Theme type, and no longer emitted by the CSS generator.

* chore(ui): lint/type cleanup across Base UI wrappers

* style(tabs): dial pill track background down to near-invisible

* refactor(chip): aria-pressed drives the selected tinted state

The chip variant now carries the tinted primary palette (same as the
default filled button) under aria-pressed, so consumers toggle selection
via a single aria attribute instead of repeating the border/text/bg
override classes. All existing chip consumers migrate to aria-pressed
and drop their hand-rolled selected className.

* chore(defaults): humanize variant label in thinking selector

* fix(diff): render all lines in single-file mode

Single-file mode wrapped PierreDiffViewer in its own layout=fill which
creates a nested virtual-root; combined with the outer CSS containment
on the wrapper, Pierre's virtualizer measured a clamped viewport and
stopped rendering lines past ~150. Mirror the stacked-mode approach:
hoist a ScrollableOverlay as the sole virtual root/content and render
PierreDiffViewer inline, so the shared virtualizer sees the real scroll
viewport and paints every line.

Also drops an unused cn import in SessionRetentionSettings.

* feat(files): collapse floating editor controls behind icon button

The floating file editor toolbar now starts as a single pill icon button
and expands into the full control row on hover or click. Clicking outside
or moving the pointer away collapses it back, keeping the editor surface
unobstructed while the file is being read/edited. Fullscreen overlay
keeps its always-visible toolbar.

* fix(chat): align mobile session status bar radius with chat input

* fix(mobile): keep radio/checkbox/switch at their native size

Mobile CSS forced min 36x36px on every <button>, which swallowed our
Radio and Checkbox primitives (also buttons with role=radio/checkbox)
and ballooned them on touch devices. Exclude role=radio/checkbox/switch
from the touch-target rule and pin the Radio/Checkbox box size with
arbitrary px so padding-scale overrides cannot stretch them either.

* chore(mobile): drop unused cornerRadius prop plumbing

The status bar now hardcodes var(--radius-lg) on both views, so the
cornerRadius prop and the useUIStore selector that sourced it in
ChatInput are dead weight.
2026-04-19 15:52:54 +03:00

828 lines
28 KiB
TypeScript

import React from 'react';
import {
RiAddLine,
RiArrowDownSLine,
RiLoader4Line,
RiPlayLine,
RiStopLine,
} from '@remixicon/react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { toast } from '@/components/ui';
import { cn } from '@/lib/utils';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useDeviceInfo } from '@/lib/device';
import { isDesktopShell } from '@/lib/desktop';
import { useUIStore } from '@/stores/useUIStore';
import { useTerminalStore } from '@/stores/useTerminalStore';
import { useDesktopSshStore } from '@/stores/useDesktopSshStore';
import { openExternalUrl } from '@/lib/url';
import {
getProjectActionsState,
type OpenChamberProjectAction,
type ProjectRef,
} from '@/lib/openchamberConfig';
import {
normalizeProjectActionDirectory,
PROJECT_ACTIONS_UPDATED_EVENT,
PROJECT_ACTION_ICON_MAP,
resolveProjectActionDesktopForwardUrl,
toProjectActionRunKey,
} from '@/lib/projectActions';
type RunningEntry = {
key: string;
directory: string;
actionId: string;
tabId: string;
sessionId: string;
status: 'running' | 'stopping';
};
type UrlWatchEntry = {
lastSeenChunkId: number | null;
openedUrl: boolean;
tail: string;
};
const sleep = (ms: number): Promise<void> => {
return new Promise((resolve) => {
window.setTimeout(resolve, ms);
});
};
interface ProjectActionsButtonProps {
projectRef: ProjectRef | null;
directory: string;
className?: string;
compact?: boolean;
allowMobile?: boolean;
}
const ANSI_ESCAPE_PREFIX = String.fromCharCode(27);
const ANSI_ESCAPE_PATTERN = new RegExp(`${ANSI_ESCAPE_PREFIX}\\[[0-9;?]*[ -/]*[@-~]`, 'g');
const URL_GLOBAL_PATTERN = /https?:\/\/[^\s<>'"`]+/gi;
const stripControlChars = (value: string): string => {
let next = '';
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index);
const isControl = (code >= 0 && code <= 8)
|| code === 11
|| code === 12
|| (code >= 14 && code <= 31)
|| code === 127;
if (!isControl) {
next += value[index];
}
}
return next;
};
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;
}
};
const extractBestUrl = (value: string): string | null => {
const cleaned = value.replace(ANSI_ESCAPE_PATTERN, '');
const matches = cleaned.match(URL_GLOBAL_PATTERN);
if (!matches || matches.length === 0) {
return null;
}
const normalized = matches
.map((entry) => entry.replace(/[),.;]+$/, ''))
.filter(Boolean);
if (normalized.length === 0) {
return null;
}
const portCandidates: Array<{ raw: string; parsed: URL }> = [];
for (const candidate of normalized) {
try {
const parsed = new URL(candidate);
if (parsed.port && parsed.port.length > 0) {
portCandidates.push({ raw: candidate, parsed });
}
} catch {
// noop
}
}
if (portCandidates.length > 0) {
const scoreCandidate = (entry: { raw: string; parsed: URL }): number => {
const { parsed } = entry;
const host = parsed.hostname.toLowerCase();
const isLocalHost = host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0' || host === '::1';
const normalizedPath = parsed.pathname || '/';
const pathSegments = normalizedPath.split('/').filter(Boolean).length;
const hasRootPath = normalizedPath === '/' || normalizedPath === '';
const hasQueryOrHash = Boolean(parsed.search || parsed.hash);
let score = 0;
if (isLocalHost) score += 50;
if (hasRootPath) score += 30;
score -= Math.min(pathSegments * 5, 20);
if (hasQueryOrHash) score -= 10;
return score;
};
portCandidates.sort((a, b) => scoreCandidate(b) - scoreCandidate(a));
return portCandidates[0]?.parsed.origin ?? portCandidates[0]?.raw ?? null;
}
return normalized[0] ?? null;
};
const formatActionButtonLabel = (value: string): string => {
const trimmed = value.trim();
if (!trimmed) {
return 'Action';
}
const words = trimmed.split(/\s+/).filter(Boolean);
if (words.length >= 2) {
const first = words[0];
const second = words[1].slice(0, 3);
const shortTwoWord = `${first} ${second}`.trim();
if (words.length > 2 || shortTwoWord.length < trimmed.length) {
return `${shortTwoWord}...`;
}
return shortTwoWord;
}
return trimmed.length > 12 ? `${trimmed.slice(0, 9).trimEnd()}...` : trimmed;
};
export const ProjectActionsButton = ({
projectRef,
directory,
className,
compact = false,
allowMobile = false,
}: ProjectActionsButtonProps) => {
const { terminal, runtime } = useRuntimeAPIs();
const { isMobile } = useDeviceInfo();
const isDesktopShellApp = React.useMemo(() => isDesktopShell(), []);
const desktopSshInstances = useDesktopSshStore((state) => state.instances);
const loadDesktopSsh = useDesktopSshStore((state) => state.load);
const setBottomTerminalOpen = useUIStore((state) => state.setBottomTerminalOpen);
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
const setSettingsProjectsSelectedId = useUIStore((state) => state.setSettingsProjectsSelectedId);
const terminalSessions = useTerminalStore((state) => state.sessions);
const ensureDirectory = useTerminalStore((state) => state.ensureDirectory);
const setTabLabel = useTerminalStore((state) => state.setTabLabel);
const setActiveTab = useTerminalStore((state) => state.setActiveTab);
const setConnecting = useTerminalStore((state) => state.setConnecting);
const setTabSessionId = useTerminalStore((state) => state.setTabSessionId);
const [actions, setActions] = React.useState<OpenChamberProjectAction[]>([]);
const [selectedActionId, setSelectedActionId] = React.useState<string | null>(null);
const [isLoading, setIsLoading] = React.useState(false);
const [runningByKey, setRunningByKey] = React.useState<Record<string, RunningEntry>>({});
const tabByKeyRef = React.useRef<Record<string, string>>({});
const urlWatchByRunKeyRef = React.useRef<Record<string, UrlWatchEntry>>({});
const loadRequestIdRef = React.useRef(0);
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) => {
await openExternalUrl(url);
}, []);
const loadActions = React.useCallback(async () => {
if (!stableProjectRef) {
return;
}
const requestId = loadRequestIdRef.current + 1;
loadRequestIdRef.current = requestId;
setIsLoading(true);
try {
const state = await getProjectActionsState(stableProjectRef);
if (loadRequestIdRef.current !== requestId) {
return;
}
const filtered = state.actions;
setActions(filtered);
setSelectedActionId((current) => {
if (filtered.length === 0) {
return null;
}
if (current && filtered.some((entry) => entry.id === current)) {
return current;
}
return filtered[0]?.id ?? null;
});
} catch {
if (loadRequestIdRef.current !== requestId) {
return;
}
// Keep last known actions while next project loads or transient fetch fails.
} finally {
if (loadRequestIdRef.current === requestId) {
setIsLoading(false);
}
}
}, [stableProjectRef]);
React.useEffect(() => {
void loadActions();
}, [loadActions]);
React.useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ projectId?: string }>).detail;
if (!projectId) {
return;
}
if (detail?.projectId && detail.projectId !== projectId) {
return;
}
void loadActions();
};
window.addEventListener(PROJECT_ACTIONS_UPDATED_EVENT, handler);
return () => {
window.removeEventListener(PROJECT_ACTIONS_UPDATED_EVENT, handler);
};
}, [loadActions, projectId]);
React.useEffect(() => {
if (!selectedActionId) {
return;
}
if (!actions.some((entry) => entry.id === selectedActionId)) {
setSelectedActionId(actions[0]?.id ?? null);
}
}, [actions, selectedActionId]);
React.useEffect(() => {
setRunningByKey((prev) => {
let changed = false;
const next: Record<string, RunningEntry> = {};
for (const [key, entry] of Object.entries(prev)) {
const directoryState = terminalSessions.get(entry.directory);
const tab = directoryState?.tabs.find((item) => item.id === entry.tabId);
if (!tab || tab.terminalSessionId !== entry.sessionId) {
changed = true;
continue;
}
next[key] = entry;
}
return changed ? next : prev;
});
}, [terminalSessions]);
React.useEffect(() => {
for (const [runKey, entry] of Object.entries(runningByKey)) {
const watch = urlWatchByRunKeyRef.current[runKey] ?? { lastSeenChunkId: null, openedUrl: false, tail: '' };
urlWatchByRunKeyRef.current[runKey] = watch;
const action = actions.find((item) => item.id === entry.actionId);
if (!action) {
continue;
}
const directoryState = terminalSessions.get(entry.directory);
const tab = directoryState?.tabs.find((item) => item.id === entry.tabId);
if (!tab || !Array.isArray(tab.bufferChunks) || tab.bufferChunks.length === 0) {
continue;
}
const nextChunks = tab.bufferChunks.filter((chunk) => {
if (watch.lastSeenChunkId === null) {
return true;
}
return chunk.id > watch.lastSeenChunkId;
});
if (nextChunks.length === 0) {
continue;
}
const combined = nextChunks.map((chunk) => chunk.data).join('');
const textForScan = `${watch.tail}${combined}`;
const maybeUrl = !watch.openedUrl && action.autoOpenUrl === true ? extractBestUrl(textForScan) : null;
const lastChunkId = nextChunks[nextChunks.length - 1]?.id ?? watch.lastSeenChunkId;
watch.lastSeenChunkId = lastChunkId;
watch.tail = textForScan.slice(-512);
if (maybeUrl) {
watch.openedUrl = true;
void openExternal(maybeUrl);
toast.success('Opened URL from action output');
}
urlWatchByRunKeyRef.current[runKey] = watch;
}
for (const runKey of Object.keys(urlWatchByRunKeyRef.current)) {
if (!runningByKey[runKey]) {
delete urlWatchByRunKeyRef.current[runKey];
}
}
}, [actions, openExternal, runningByKey, terminalSessions]);
const normalizedDirectory = React.useMemo(() => {
return normalizeProjectActionDirectory(directory || stableProjectRef?.path || '');
}, [directory, stableProjectRef?.path]);
const selectedAction = React.useMemo(() => {
if (!selectedActionId) {
return actions[0] ?? null;
}
return actions.find((entry) => entry.id === selectedActionId) ?? actions[0] ?? null;
}, [actions, selectedActionId]);
const getOrCreateActionTab = React.useCallback(async (action: OpenChamberProjectAction) => {
if (!normalizedDirectory) {
throw new Error('No active directory');
}
const key = toProjectActionRunKey(normalizedDirectory, action.id);
ensureDirectory(normalizedDirectory);
const currentStore = useTerminalStore.getState();
const existingDirectoryState = currentStore.getDirectoryState(normalizedDirectory);
let tabId = tabByKeyRef.current[key] || null;
const hasTab = tabId
? Boolean(existingDirectoryState?.tabs.some((entry) => entry.id === tabId))
: false;
if (!tabId || !hasTab) {
tabId = currentStore.createTab(normalizedDirectory);
tabByKeyRef.current[key] = tabId;
}
setTabLabel(normalizedDirectory, tabId, `Action: ${action.name}`);
setActiveTab(normalizedDirectory, tabId);
setBottomTerminalOpen(true);
setActiveMainTab('terminal');
const stateAfterTab = useTerminalStore.getState().getDirectoryState(normalizedDirectory);
const tab = stateAfterTab?.tabs.find((entry) => entry.id === tabId);
return {
key,
tabId,
sessionId: tab?.terminalSessionId ?? null,
};
}, [
ensureDirectory,
normalizedDirectory,
setActiveMainTab,
setActiveTab,
setBottomTerminalOpen,
setTabLabel,
]);
const runAction = React.useCallback(async (action: OpenChamberProjectAction) => {
if (runtime.isVSCode || (!allowMobile && isMobile)) {
return;
}
if (!normalizedDirectory) {
toast.error('No active directory for action');
return;
}
const runKey = toProjectActionRunKey(normalizedDirectory, action.id);
const existingRun = runningByKey[runKey];
if (existingRun && existingRun.status === 'running') {
return;
}
try {
const { key, tabId, sessionId } = await getOrCreateActionTab(action);
let activeSessionId = sessionId;
let createdSession = false;
if (!activeSessionId) {
setConnecting(normalizedDirectory, tabId, true);
try {
const created = await terminal.createSession({ cwd: normalizedDirectory });
activeSessionId = created.sessionId;
createdSession = true;
setTabSessionId(normalizedDirectory, tabId, activeSessionId);
} finally {
setConnecting(normalizedDirectory, tabId, false);
}
}
if (!activeSessionId) {
throw new Error('Failed to create terminal session');
}
if (createdSession) {
await sleep(350);
}
setRunningByKey((prev) => ({
...prev,
[key]: {
key,
directory: normalizedDirectory,
actionId: action.id,
tabId,
sessionId: activeSessionId,
status: 'running',
},
}));
const hasCustomOpenUrl = action.autoOpenUrl === true && (action.openUrl || '').trim().length > 0;
const hasDesktopForwardSelection = action.autoOpenUrl === true
&& isDesktopShellApp
&& (action.desktopOpenSshForward || '').trim().length > 0;
const manualOpenUrl = action.autoOpenUrl ? normalizeManualOpenUrl(action.openUrl) : null;
const desktopForwardUrl = action.autoOpenUrl && isDesktopShellApp
? resolveProjectActionDesktopForwardUrl(action.desktopOpenSshForward, desktopSshInstances)
: null;
if (desktopForwardUrl) {
void openExternal(desktopForwardUrl);
toast.success('Opened forwarded URL');
} else if (manualOpenUrl) {
void openExternal(manualOpenUrl);
toast.success('Opened action URL');
} else if (hasCustomOpenUrl) {
toast.error('Invalid custom URL format');
} else if (hasDesktopForwardSelection) {
toast.error('Selected desktop SSH forward is unavailable');
}
urlWatchByRunKeyRef.current[key] = {
lastSeenChunkId: null,
openedUrl: Boolean(desktopForwardUrl) || Boolean(manualOpenUrl) || hasCustomOpenUrl,
tail: '',
};
const normalizedCommand = stripControlChars(action.command.trim().replace(/\r\n|\r/g, '\n'));
await terminal.sendInput(activeSessionId, `${normalizedCommand}\r`);
} catch (error) {
setRunningByKey((prev) => {
const next = { ...prev };
delete next[runKey];
return next;
});
delete urlWatchByRunKeyRef.current[runKey];
toast.error(error instanceof Error ? error.message : 'Failed to run action');
}
}, [
desktopSshInstances,
getOrCreateActionTab,
allowMobile,
isMobile,
isDesktopShellApp,
normalizedDirectory,
openExternal,
runningByKey,
runtime.isVSCode,
setConnecting,
setTabSessionId,
terminal,
]);
const stopAction = React.useCallback(async (action: OpenChamberProjectAction) => {
const runKey = toProjectActionRunKey(normalizedDirectory, action.id);
const activeRun = runningByKey[runKey];
if (!activeRun) {
return;
}
setRunningByKey((prev) => ({
...prev,
[runKey]: {
...activeRun,
status: 'stopping',
},
}));
try {
await terminal.sendInput(activeRun.sessionId, '\x03');
} catch {
// noop
}
await new Promise((resolve) => {
window.setTimeout(resolve, 1000);
});
const afterTab = useTerminalStore.getState().getDirectoryState(activeRun.directory)?.tabs
.find((entry) => entry.id === activeRun.tabId);
const sessionStillSame = afterTab?.terminalSessionId === activeRun.sessionId;
if (sessionStillSame) {
if (typeof terminal.forceKill === 'function') {
try {
await terminal.forceKill({ sessionId: activeRun.sessionId });
} catch {
// noop
}
} else {
try {
await terminal.close(activeRun.sessionId);
} catch {
// noop
}
}
setTabSessionId(activeRun.directory, activeRun.tabId, null);
}
setRunningByKey((prev) => {
const next = { ...prev };
delete next[runKey];
return next;
});
delete urlWatchByRunKeyRef.current[runKey];
}, [normalizedDirectory, runningByKey, setTabSessionId, terminal]);
const handlePrimaryClick = React.useCallback(() => {
if (!selectedAction) {
return;
}
const runKey = toProjectActionRunKey(normalizedDirectory, selectedAction.id);
const runningEntry = runningByKey[runKey];
if (runningEntry?.status === 'stopping') {
return;
}
if (runningEntry) {
void stopAction(selectedAction);
return;
}
void runAction(selectedAction);
}, [normalizedDirectory, runAction, runningByKey, selectedAction, stopAction]);
const handleSelectAction = React.useCallback((action: OpenChamberProjectAction, toggleStopIfRunning = false) => {
setSelectedActionId(action.id);
if (!toggleStopIfRunning) {
void runAction(action);
return;
}
const runKey = toProjectActionRunKey(normalizedDirectory, action.id);
const runningEntry = runningByKey[runKey];
if (runningEntry?.status === 'stopping') {
return;
}
if (runningEntry) {
void stopAction(action);
return;
}
void runAction(action);
}, [normalizedDirectory, runAction, runningByKey, stopAction]);
const openProjectActionsSettings = React.useCallback(() => {
if (!stableProjectRef?.id) {
return;
}
setSettingsProjectsSelectedId(stableProjectRef.id);
setSettingsPage('projects');
setSettingsDialogOpen(true);
}, [setSettingsDialogOpen, setSettingsPage, setSettingsProjectsSelectedId, stableProjectRef?.id]);
if (runtime.isVSCode || (!allowMobile && isMobile) || !stableProjectRef || !normalizedDirectory) {
return null;
}
if (actions.length === 0) {
if (compact) {
return (
<button
type="button"
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',
className
)}
aria-label="Add action"
onClick={openProjectActionsSettings}
>
<RiAddLine className="h-5 w-5" />
</button>
);
}
return (
<button
type="button"
className={cn(
'app-region-no-drag inline-flex h-7 shrink-0 items-center gap-2 self-center rounded-[9px] [corner-shape:squircle] supports-[corner-shape:squircle]:rounded-[50px]',
'bg-[var(--surface-elevated)] px-3 typography-ui-label font-medium text-foreground hover:bg-interactive-hover transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
'border border-border/60',
className
)}
onClick={openProjectActionsSettings}
>
<RiAddLine className="h-4 w-4 text-muted-foreground" />
<span className="header-open-label whitespace-nowrap">Add action</span>
</button>
);
}
const resolvedSelected = selectedAction ?? actions[0] ?? null;
if (!resolvedSelected) {
return null;
}
const selectedIconKey = (resolvedSelected.icon || 'play') as keyof typeof PROJECT_ACTION_ICON_MAP;
const SelectedIcon = PROJECT_ACTION_ICON_MAP[selectedIconKey] || RiPlayLine;
const selectedButtonLabel = formatActionButtonLabel(resolvedSelected.name);
const selectedRunKey = toProjectActionRunKey(normalizedDirectory, resolvedSelected.id);
const selectedRunning = runningByKey[selectedRunKey];
const isStoppingSelected = selectedRunning?.status === 'stopping';
if (compact) {
return (
<DropdownMenu>
<DropdownMenuTrigger 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
)}
aria-label={selectedRunning ? `Stop ${resolvedSelected.name}` : `Run ${resolvedSelected.name}`}
>
{isStoppingSelected
? <RiLoader4Line className="h-5 w-5 animate-spin text-[var(--status-warning)]" />
: selectedRunning
? <RiStopLine className="h-5 w-5 text-[var(--status-warning)]" />
: <SelectedIcon className="h-5 w-5" />}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52 max-h-[70vh] overflow-y-auto">
<DropdownMenuItem className="flex items-center gap-2" onClick={openProjectActionsSettings}>
<RiAddLine className="h-4 w-4" />
<span className="typography-ui-label text-foreground">Add new action</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
{actions.map((entry) => {
const iconKey = (entry.icon || 'play') as keyof typeof PROJECT_ACTION_ICON_MAP;
const Icon = PROJECT_ACTION_ICON_MAP[iconKey] || RiPlayLine;
const runKey = toProjectActionRunKey(normalizedDirectory, entry.id);
const runState = runningByKey[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);
}}
>
<Icon className="h-4 w-4" />
<span className="typography-ui-label text-foreground truncate">{entry.name}</span>
{isStopping
? <RiLoader4Line className="ml-auto h-4 w-4 animate-spin text-[var(--status-warning)]" />
: isRunning
? <RiStopLine className="ml-auto h-4 w-4 text-[var(--status-warning)]" />
: null}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
return (
<div
className={cn(
'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',
compact ? 'h-9' : 'h-7',
className
)}
>
<button
type="button"
onClick={handlePrimaryClick}
disabled={isLoading || isStoppingSelected}
className={cn(
'inline-flex h-full items-center typography-ui-label font-medium text-foreground hover:bg-interactive-hover',
compact ? 'w-9 justify-center px-0' : 'gap-2 px-3',
'transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:cursor-not-allowed'
)}
aria-label={selectedRunning ? `Stop ${resolvedSelected.name}` : `Run ${resolvedSelected.name}`}
>
<span className="inline-flex h-4 w-4 shrink-0 items-center justify-center">
{isStoppingSelected
? <RiLoader4Line className="h-4 w-4 animate-spin text-[var(--status-warning)]" />
: selectedRunning
? <RiStopLine className="h-4 w-4 text-[var(--status-warning)]" />
: <SelectedIcon className="h-4 w-4" />}
</span>
{!compact ? <span className="header-open-label whitespace-nowrap">{selectedButtonLabel}</span> : null}
</button>
<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',
'hover:bg-interactive-hover hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary'
)}
aria-label="Choose project action"
>
<RiArrowDownSLine className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="w-52 max-h-[70vh] overflow-y-auto" style={{ translate: '-30px 0' }}>
<DropdownMenuItem className="flex items-center gap-2" onClick={openProjectActionsSettings}>
<RiAddLine className="h-4 w-4" />
<span className="typography-ui-label text-foreground">Add new action</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
{actions.map((entry) => {
const iconKey = (entry.icon || 'play') as keyof typeof PROJECT_ACTION_ICON_MAP;
const Icon = PROJECT_ACTION_ICON_MAP[iconKey] || RiPlayLine;
const runKey = toProjectActionRunKey(normalizedDirectory, entry.id);
const runState = runningByKey[runKey];
const isRunning = Boolean(runState);
const isStopping = runState?.status === 'stopping';
return (
<DropdownMenuItem
key={entry.id}
className="flex items-center gap-2"
onClick={() => {
handleSelectAction(entry);
}}
>
<Icon className="h-4 w-4" />
<span className="typography-ui-label text-foreground truncate">{entry.name}</span>
{isStopping
? <RiLoader4Line className="ml-auto h-4 w-4 animate-spin text-[var(--status-warning)]" />
: isRunning
? <RiStopLine className="ml-auto h-4 w-4 text-[var(--status-warning)]" />
: null}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
};