From 95c71789c41245b3993f19200cf4b8bc5ad920a7 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 27 Feb 2026 20:45:03 +0200 Subject: [PATCH] Project actions: run commands from header on web + mobile, with SSH-forward URL opening (#542) * feat: add project actions in header with reliable terminal run flow - Add per-project Actions settings with icons, platform filters, and default action - Run/stop actions from header using terminal tabs, including Ctrl+C then force-kill fallback - Improve terminal UX with stable selection, resize handling, and smarter URL auto-open for localhost ports * feat: add project actions UI with refined header dropdown behavior * feat: add parent-session back button in chat * fix: make web and desktop dev modes reliable and conflict-free - Separate desktop and web dev ports to avoid collisions - Add robust process-tree shutdown so Ctrl+C cleans sidecars - Add true web HMR mode and keep service worker out of dev * fix: linux safe scripts for dev * feat: run project actions on web and add desktop SSH forward URL opening * feat: add mobile project actions button with terminal tabs and tighter tab UI * revert: remove experimental mobile terminal selection UI * feat: show Add action button in header when empty * fix: make retry countdown human-readable in status row * fix: prevent nav rail actions from firing through overlays --- package.json | 3 +- packages/desktop/scripts/desktop-dev.mjs | 74 +- packages/desktop/scripts/dev-web-server.mjs | 110 ++- packages/desktop/src-tauri/src/main.rs | 4 +- packages/desktop/src-tauri/tauri.conf.json | 2 +- .../ui/src/components/chat/ChatContainer.tsx | 48 +- .../chat/message/parts/WorkingPlaceholder.tsx | 59 +- .../components/desktop/OpenInAppButton.tsx | 6 +- packages/ui/src/components/layout/Header.tsx | 38 +- packages/ui/src/components/layout/NavRail.tsx | 69 +- .../layout/ProjectActionsButton.tsx | 805 ++++++++++++++++++ .../projects/ProjectActionsSection.tsx | 434 ++++++++++ .../sections/projects/ProjectsPage.tsx | 18 +- .../components/terminal/TerminalViewport.tsx | 72 +- .../ui/src/components/ui/dropdown-menu.tsx | 2 + .../src/components/views/SettingsWindow.tsx | 13 + .../ui/src/components/views/TerminalView.tsx | 185 ++-- packages/ui/src/lib/openchamberConfig.ts | 148 ++++ packages/ui/src/lib/projectActions.ts | 199 +++++ packages/ui/src/stores/useTerminalStore.ts | 40 +- packages/ui/src/styles/mobile.css | 1 + packages/web/server/index.js | 11 +- packages/web/src/main.tsx | 26 +- packages/web/vite.config.ts | 3 +- scripts/dev-web-full.mjs | 214 +++++ scripts/dev-web-hmr.mjs | 136 +++ 26 files changed, 2572 insertions(+), 148 deletions(-) create mode 100644 packages/ui/src/components/layout/ProjectActionsButton.tsx create mode 100644 packages/ui/src/components/sections/projects/ProjectActionsSection.tsx create mode 100644 packages/ui/src/lib/projectActions.ts create mode 100644 scripts/dev-web-full.mjs create mode 100644 scripts/dev-web-hmr.mjs diff --git a/package.json b/package.json index 87897892..be050e08 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,8 @@ "postinstall": "patch-package", "dev:web": "bun run --cwd packages/web build:watch", "dev:web:server": "bun run --cwd packages/web dev:server:watch", - "dev:web:full": "concurrently -n \"api,build\" -c \"cyan,magenta\" \"bun run --cwd packages/web dev:server:watch\" \"bun run --cwd packages/web build:watch\"", + "dev:web:full": "node ./scripts/dev-web-full.mjs", + "dev:web:hmr": "node ./scripts/dev-web-hmr.mjs", "start:web": "bun run --cwd packages/web start", "pack:web": "bun pm pack --cwd packages/web", "desktop:start-cli": "node ./packages/desktop/scripts/opencode-cli.mjs start", diff --git a/packages/desktop/scripts/desktop-dev.mjs b/packages/desktop/scripts/desktop-dev.mjs index f7315b47..c80ed907 100644 --- a/packages/desktop/scripts/desktop-dev.mjs +++ b/packages/desktop/scripts/desktop-dev.mjs @@ -13,10 +13,70 @@ function spawnProcess(command, args, opts = {}) { cwd: repoRoot, env: { ...process.env }, stdio: 'inherit', + detached: process.platform !== 'win32', ...opts, }); } +function waitForExit(child, timeoutMs) { + return new Promise((resolve) => { + if (!child || child.exitCode !== null || child.signalCode !== null) { + resolve(); + return; + } + + const onExit = () => { + clearTimeout(timer); + resolve(); + }; + + const timer = setTimeout(() => { + child.off('exit', onExit); + resolve(); + }, timeoutMs); + + child.once('exit', onExit); + }); +} + +function signalChild(child, signal) { + if (!child || child.exitCode !== null || child.signalCode !== null) { + return; + } + + try { + if (process.platform !== 'win32') { + process.kill(-child.pid, signal); + return; + } + } catch { + } + + try { + child.kill(signal); + } catch { + } +} + +async function stopChildTree(child) { + if (!child || child.exitCode !== null || child.signalCode !== null) { + return; + } + + signalChild(child, 'SIGINT'); + await waitForExit(child, 2500); + + if (child.exitCode === null && child.signalCode === null) { + signalChild(child, 'SIGTERM'); + await waitForExit(child, 2500); + } + + if (child.exitCode === null && child.signalCode === null) { + signalChild(child, 'SIGKILL'); + await waitForExit(child, 1000); + } +} + async function main() { const tauriProcess = spawnProcess('bun', [ '--cwd', @@ -37,19 +97,7 @@ async function main() { } cleaning = true; - const stopChild = (child, label) => { - if (!child || child.killed) { - return; - } - try { - child.kill('SIGINT'); - } catch (error) { - console.warn(`[desktop:dev] Failed to stop ${label}:`, error); - } - }; - - stopChild(tauriProcess, 'Tauri dev process'); - + await stopChildTree(tauriProcess); process.exit(typeof code === 'number' ? code : 0); }; diff --git a/packages/desktop/scripts/dev-web-server.mjs b/packages/desktop/scripts/dev-web-server.mjs index cdc580da..0af68b5e 100644 --- a/packages/desktop/scripts/dev-web-server.mjs +++ b/packages/desktop/scripts/dev-web-server.mjs @@ -2,6 +2,8 @@ import path from 'node:path'; import { spawn, spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; +const DESKTOP_DEV_PORT = 3901; + const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -48,11 +50,12 @@ const run = (cmd, args, cwd) => { console.log('[desktop] ensuring sidecar + web-dist...'); run('node', ['./scripts/build-sidecar.mjs'], desktopDir); -console.log('[desktop] starting API server on http://127.0.0.1:3001 ...'); +console.log(`[desktop] starting API server on http://127.0.0.1:${DESKTOP_DEV_PORT} ...`); -const apiChild = spawn(sidecarPath, ['--port', '3001'], { +const apiChild = spawn(sidecarPath, ['--port', String(DESKTOP_DEV_PORT)], { cwd: repoRoot, stdio: 'inherit', + detached: process.platform !== 'win32', env: { ...process.env, OPENCHAMBER_HOST: '127.0.0.1', @@ -67,9 +70,10 @@ console.log('[desktop] starting Vite HMR server on http://127.0.0.1:5173 ...'); const webChild = spawn('bun', ['x', 'vite', '--host', '127.0.0.1', '--port', '5173', '--strictPort'], { cwd: webDir, stdio: 'inherit', + detached: process.platform !== 'win32', env: { ...process.env, - OPENCHAMBER_PORT: process.env.OPENCHAMBER_PORT || '3001', + OPENCHAMBER_PORT: String(DESKTOP_DEV_PORT), NO_PROXY: process.env.NO_PROXY || 'localhost,127.0.0.1', no_proxy: process.env.no_proxy || 'localhost,127.0.0.1', }, @@ -77,17 +81,80 @@ const webChild = spawn('bun', ['x', 'vite', '--host', '127.0.0.1', '--port', '51 let shuttingDown = false; -const shutdown = () => { +function waitForExit(child, timeoutMs) { + return new Promise((resolve) => { + if (!child || child.exitCode !== null || child.signalCode !== null) { + resolve(); + return; + } + + const onExit = () => { + clearTimeout(timer); + resolve(); + }; + + const timer = setTimeout(() => { + child.off('exit', onExit); + resolve(); + }, timeoutMs); + + child.once('exit', onExit); + }); +} + +function signalChild(child, signal) { + if (!child || child.exitCode !== null || child.signalCode !== null) { + return; + } + + try { + if (process.platform !== 'win32') { + process.kill(-child.pid, signal); + return; + } + } catch { + } + + try { + child.kill(signal); + } catch { + } +} + +async function requestApiShutdown() { + const url = `http://127.0.0.1:${DESKTOP_DEV_PORT}/api/system/shutdown`; + try { + await fetch(url, { method: 'POST' }); + } catch { + } +} + +async function stopChildTree(child) { + if (!child || child.exitCode !== null || child.signalCode !== null) { + return; + } + + signalChild(child, 'SIGINT'); + await waitForExit(child, 2500); + + if (child.exitCode === null && child.signalCode === null) { + signalChild(child, 'SIGTERM'); + await waitForExit(child, 2500); + } + + if (child.exitCode === null && child.signalCode === null) { + signalChild(child, 'SIGKILL'); + await waitForExit(child, 1000); + } +} + +const shutdown = async (exitCode = 0) => { if (shuttingDown) return; shuttingDown = true; - try { - apiChild.kill('SIGTERM'); - } catch {} - - try { - webChild.kill('SIGTERM'); - } catch {} + await requestApiShutdown(); + await Promise.all([stopChildTree(webChild), stopChildTree(apiChild)]); + process.exit(exitCode); }; const handleExit = (label) => (code, signal) => { @@ -99,8 +166,10 @@ const handleExit = (label) => (code, signal) => { console.error(`[desktop] ${label} exited unexpectedly (code=${code ?? 'null'} signal=${signal ?? 'none'})`); } - shutdown(); - process.exit(typeof code === 'number' ? code : 1); + shutdown(typeof code === 'number' ? code : 1).catch((error) => { + console.error('[desktop] shutdown failed:', error); + process.exit(1); + }); }; apiChild.on('exit', handleExit('API server')); @@ -111,13 +180,18 @@ const handleError = (label) => (error) => { return; } console.error(`[desktop] failed to start ${label}:`, error); - shutdown(); - process.exit(1); + shutdown(1).catch(() => process.exit(1)); }; apiChild.on('error', handleError('API server')); webChild.on('error', handleError('Vite server')); -process.on('SIGINT', shutdown); -process.on('SIGTERM', shutdown); -process.on('exit', shutdown); +process.on('SIGINT', () => { + shutdown(130).catch(() => process.exit(130)); +}); +process.on('SIGTERM', () => { + shutdown(143).catch(() => process.exit(143)); +}); +process.on('SIGHUP', () => { + shutdown(129).catch(() => process.exit(129)); +}); diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index 5c095cd1..809d2ad2 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -2732,8 +2732,8 @@ fn main() { let handle = app.handle().clone(); tauri::async_runtime::spawn(async move { let local_url = if cfg!(debug_assertions) { - let dev_url = "http://127.0.0.1:3001"; - if wait_for_health(dev_url).await { + let dev_url = "http://127.0.0.1:3901".to_string(); + if wait_for_health(&dev_url).await { dev_url.to_string() } else { match spawn_local_server(&handle).await { diff --git a/packages/desktop/src-tauri/tauri.conf.json b/packages/desktop/src-tauri/tauri.conf.json index 3bccd187..77ccc409 100644 --- a/packages/desktop/src-tauri/tauri.conf.json +++ b/packages/desktop/src-tauri/tauri.conf.json @@ -6,7 +6,7 @@ "build": { "beforeDevCommand": "node ./scripts/dev-web-server.mjs", "beforeBuildCommand": "bun run build:sidecar", - "devUrl": "http://127.0.0.1:3001", + "devUrl": "http://127.0.0.1:3901", "frontendDist": "../noop-dist" }, "app": { diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index c1aa1ceb..8d251642 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { RiArrowDownLine } from '@remixicon/react'; +import { RiArrowDownLine, RiArrowLeftLine } from '@remixicon/react'; import { useShallow } from 'zustand/react/shallow'; import type { Message, Part } from '@opencode-ai/sdk/v2'; @@ -14,6 +14,7 @@ import { useChatScrollManager } from '@/hooks/useChatScrollManager'; import { useDeviceInfo } from '@/lib/device'; import { getMemoryLimits } from '@/stores/types/sessionTypes'; import { Button } from '@/components/ui/button'; +import { ButtonSmall } from '@/components/ui/button-small'; import { OverlayScrollbar } from '@/components/ui/OverlayScrollbar'; import { TimelineDialog } from './TimelineDialog'; import type { PermissionRequest } from '@/types/permission'; @@ -76,6 +77,7 @@ export const ChatContainer: React.FC = () => { loadMoreMessages, updateViewportAnchor, openNewSessionDraft, + setCurrentSession, trimToViewportWindow, newSessionDraft, } = useSessionStore( @@ -86,6 +88,7 @@ export const ChatContainer: React.FC = () => { loadMoreMessages: state.loadMoreMessages, updateViewportAnchor: state.updateViewportAnchor, openNewSessionDraft: state.openNewSessionDraft, + setCurrentSession: state.setCurrentSession, trimToViewportWindow: state.trimToViewportWindow, newSessionDraft: state.newSessionDraft, })) @@ -112,6 +115,8 @@ export const ChatContainer: React.FC = () => { ) ); + const sessions = useSessionStore((state) => state.sessions); + const blockingRequestState = useSessionStore( useShallow((state) => ({ sessions: state.sessions, @@ -168,6 +173,42 @@ export const ChatContainer: React.FC = () => { const isDesktopExpandedInput = isExpandedInput && !isMobile; const messageListRef = React.useRef(null); + const parentSession = React.useMemo(() => { + if (!currentSessionId) { + return null; + } + + const current = sessions.find((session) => session.id === currentSessionId); + const parentID = current?.parentID; + if (!parentID) { + return null; + } + + return sessions.find((session) => session.id === parentID) ?? null; + }, [currentSessionId, sessions]); + + const handleReturnToParentSession = React.useCallback(() => { + if (!parentSession) { + return; + } + void setCurrentSession(parentSession.id); + }, [parentSession, setCurrentSession]); + + const returnToParentButton = parentSession ? ( + + + Parent + + ) : null; + React.useEffect(() => { if (!currentSessionId && !draftOpen) { openNewSessionDraft(); @@ -487,9 +528,10 @@ export const ChatContainer: React.FC = () => { if (!hasMessagesEntry) { return (
+ {returnToParentButton}
{[1, 2, 3].map((i) => ( @@ -515,6 +557,7 @@ export const ChatContainer: React.FC = () => { className="relative flex flex-col h-full bg-background transform-gpu" style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined} > + {returnToParentButton} {!isDesktopExpandedInput ? (
@@ -539,6 +582,7 @@ export const ChatContainer: React.FC = () => { className="relative flex flex-col h-full bg-background" style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined} > + {returnToParentButton}
{ + if (next >= EPOCH_MILLISECONDS_THRESHOLD) { + return next; + } + if (next >= EPOCH_SECONDS_THRESHOLD) { + return next * 1000; + } + return Date.now() + next; +}; + +const formatRetryCountdown = (seconds: number): string => { + if (seconds < 60) { + return `${seconds}s`; + } + + if (seconds < 3600) { + const minutes = Math.floor(seconds / 60); + const remainderSeconds = seconds % 60; + return remainderSeconds > 0 ? `${minutes}m ${remainderSeconds}s` : `${minutes}m`; + } + + if (seconds < 86400) { + const hours = Math.floor(seconds / 3600); + const remainderMinutes = Math.floor((seconds % 3600) / 60); + return remainderMinutes > 0 ? `${hours}h ${remainderMinutes}m` : `${hours}h`; + } + + const days = Math.floor(seconds / 86400); + const remainderHours = Math.floor((seconds % 86400) / 3600); + if (remainderHours > 0) { + return `${days}d ${remainderHours}h`; + } + + return `${days}d`; + +}; + export function WorkingPlaceholder({ isWorking, statusText, @@ -26,26 +66,19 @@ export function WorkingPlaceholder({ const processQueueTimerRef = React.useRef | null>(null); // Countdown state for retry mode - const retryNextRef = React.useRef(null); - const retryStartRef = React.useRef(null); const [retryCountdown, setRetryCountdown] = React.useState(null); React.useEffect(() => { - const next = retryInfo?.next; - if (!next || next <= 0) { - retryNextRef.current = null; - retryStartRef.current = null; + const rawNext = retryInfo?.next; + if (!rawNext || rawNext <= 0) { setRetryCountdown(null); return; } - // Start a fresh countdown when next value or attempt changes - retryNextRef.current = next; - retryStartRef.current = Date.now(); + const retryTargetAt = toRetryTargetTimestamp(rawNext); const update = () => { - const elapsed = Date.now() - (retryStartRef.current ?? Date.now()); - const remaining = Math.max(0, next - elapsed); + const remaining = Math.max(0, retryTargetAt - Date.now()); setRetryCountdown(Math.ceil(remaining / 1000)); }; @@ -151,7 +184,9 @@ export function WorkingPlaceholder({ // Retry state: show countdown and attempt info if (retryInfo) { const attemptLabel = retryInfo.attempt && retryInfo.attempt > 1 ? ` (attempt ${retryInfo.attempt})` : ''; - const countdownLabel = retryCountdown !== null && retryCountdown > 0 ? ` in ${retryCountdown}s` : ''; + const countdownLabel = retryCountdown !== null && retryCountdown > 0 + ? ` in ${formatRetryCountdown(retryCountdown)}` + : ''; const retryText = `Retrying${countdownLabel}${attemptLabel}...`; return ( diff --git a/packages/ui/src/components/desktop/OpenInAppButton.tsx b/packages/ui/src/components/desktop/OpenInAppButton.tsx index db61afb6..58d9c6fa 100644 --- a/packages/ui/src/components/desktop/OpenInAppButton.tsx +++ b/packages/ui/src/components/desktop/OpenInAppButton.tsx @@ -351,7 +351,11 @@ export const OpenInAppButton = ({ directory, className }: OpenInAppButtonProps) - + void handleCopyPath()}> Copy Path diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 03dd766a..85fad215 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -55,6 +55,7 @@ import type { GitHubAuthStatus } from '@/lib/api/types'; import type { SessionContextUsage } from '@/stores/types/sessionTypes'; import { DesktopHostSwitcherDialog } from '@/components/desktop/DesktopHostSwitcher'; import { OpenInAppButton } from '@/components/desktop/OpenInAppButton'; +import { ProjectActionsButton } from '@/components/layout/ProjectActionsButton'; import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop'; import { desktopHostsGet, locationMatchesHost, redactSensitiveUrl } from '@/lib/desktopHosts'; @@ -165,12 +166,13 @@ export const Header: React.FC = ({ return state.messages.get(currentSessionId); }); const sessions = useSessionStore((state) => state.sessions); - const activeProjectLabel = useProjectsStore((state) => { + const activeProject = useProjectsStore((state) => { if (!state.activeProjectId) { return null; } - - const activeProject = state.projects.find((project) => project.id === state.activeProjectId); + return state.projects.find((project) => project.id === state.activeProjectId) ?? null; + }); + const activeProjectLabel = React.useMemo(() => { if (!activeProject) { return null; } @@ -182,7 +184,7 @@ export const Header: React.FC = ({ const pathSegments = activeProject.path.split(/[\\/]/).filter(Boolean); return pathSegments[pathSegments.length - 1] ?? null; - }); + }, [activeProject]); const quotaResults = useQuotaStore((state) => state.results); const fetchAllQuotas = useQuotaStore((state) => state.fetchAllQuotas); const isQuotaLoading = useQuotaStore((state) => state.isLoading); @@ -491,6 +493,17 @@ export const Header: React.FC = ({ return worktreeDirectory || sessionDirectory || draftDirectory; }, [draftDirectory, sessionDirectory, worktreeDirectory]); + const actionDirectory = React.useMemo(() => { + return normalize(openDirectory || activeProject?.path || ''); + }, [activeProject?.path, openDirectory]); + + const activeProjectRef = React.useMemo(() => { + if (!activeProject) { + return null; + } + return { id: activeProject.id, path: activeProject.path }; + }, [activeProject]); + const [planTabAvailable, setPlanTabAvailable] = React.useState(false); const showPlanTab = planTabAvailable; @@ -967,6 +980,14 @@ export const Header: React.FC = ({
)} + {activeProjectRef && actionDirectory && ( + + )} + {tabs.length > 0 && (
{tabs.map((tab) => renderTab(tab))} @@ -1505,6 +1526,15 @@ export const Header: React.FC = ({
+ {activeProjectRef && actionDirectory && ( + + )} {/* Mobile Services Menu (Usage + MCP) */} void; + disabled?: boolean; ariaLabel: string; icon: React.ReactNode; tooltipLabel: string; @@ -76,6 +77,7 @@ type NavRailActionButtonProps = { const NavRailActionButton: React.FC = ({ onClick, + disabled = false, ariaLabel, icon, tooltipLabel, @@ -85,12 +87,56 @@ const NavRailActionButton: React.FC = ({ showExpandedContent, actionTextVisible, }) => { + const pointerTriggeredRef = React.useRef(false); + const pointerPressRef = React.useRef<{ active: boolean; pointerId: number | null }>({ + active: false, + pointerId: null, + }); + + const handlePointerDown = React.useCallback((event: React.PointerEvent) => { + if (disabled || event.button !== 0) { + pointerPressRef.current = { active: false, pointerId: null }; + return; + } + pointerPressRef.current = { active: true, pointerId: event.pointerId }; + }, [disabled]); + + const clearPointerPress = React.useCallback(() => { + pointerPressRef.current = { active: false, pointerId: null }; + }, []); + + const handlePointerUp = React.useCallback((event: React.PointerEvent) => { + if (disabled) return; + if (event.button !== 0) return; + const pointerPress = pointerPressRef.current; + if (!pointerPress.active || pointerPress.pointerId !== event.pointerId) { + return; + } + clearPointerPress(); + pointerTriggeredRef.current = true; + onClick(); + }, [clearPointerPress, disabled, onClick]); + + const handleClick = React.useCallback(() => { + if (disabled) return; + if (pointerTriggeredRef.current) { + pointerTriggeredRef.current = false; + return; + } + onClick(); + }, [disabled, onClick]); + const btn = ( + ); + } + + return ( + + ); + } + + 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 selectedRunKey = toProjectActionRunKey(normalizedDirectory, resolvedSelected.id); + const selectedRunning = runningByKey[selectedRunKey]; + const isStoppingSelected = selectedRunning?.status === 'stopping'; + + if (compact) { + return ( + + + + + + + + Add new action + + + {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 ( + { + handleSelectAction(entry, true); + }} + > + + {entry.name} + {isStopping + ? + : isRunning + ? + : null} + + ); + })} + + + ); + } + + return ( +
+ + + + + + + + + + Add new action + + + {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 ( + { + handleSelectAction(entry); + }} + > + + {entry.name} + {isStopping + ? + : isRunning + ? + : null} + + ); + })} + + +
+ ); +}; diff --git a/packages/ui/src/components/sections/projects/ProjectActionsSection.tsx b/packages/ui/src/components/sections/projects/ProjectActionsSection.tsx new file mode 100644 index 00000000..592f631d --- /dev/null +++ b/packages/ui/src/components/sections/projects/ProjectActionsSection.tsx @@ -0,0 +1,434 @@ +import React from 'react'; +import { + RiAddLine, + RiArrowDownSLine, + RiArrowRightSLine, + RiDeleteBinLine, + RiInformationLine, + RiPlayLine, +} from '@remixicon/react'; +import { ButtonSmall } from '@/components/ui/button-small'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { toast } from '@/components/ui'; +import { useDesktopSshStore } from '@/stores/useDesktopSshStore'; +import { isDesktopShell } from '@/lib/desktop'; +import { + getProjectActionsState, + saveProjectActionsState, + type OpenChamberProjectAction, + type ProjectRef, +} from '@/lib/openchamberConfig'; +import { + buildProjectActionDesktopForwardOptions, + PROJECT_ACTION_ICON_MAP, + PROJECT_ACTION_ICONS, + PROJECT_ACTIONS_UPDATED_EVENT, +} from '@/lib/projectActions'; +import { cn } from '@/lib/utils'; + +type EditableProjectAction = OpenChamberProjectAction; + +const createActionId = (): string => { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + return `action_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`; +}; + +const createEmptyAction = (): EditableProjectAction => ({ + id: createActionId(), + name: '', + command: '', + icon: 'play', +}); + +interface ProjectActionsSectionProps { + projectRef: ProjectRef; +} + +export const ProjectActionsSection: React.FC = ({ projectRef }) => { + const isDesktopShellApp = React.useMemo(() => isDesktopShell(), []); + const desktopSshInstances = useDesktopSshStore((state) => state.instances); + const loadDesktopSsh = useDesktopSshStore((state) => state.load); + + const [actions, setActions] = React.useState([]); + const [isLoading, setIsLoading] = React.useState(false); + const [isSaving, setIsSaving] = React.useState(false); + const [initialSnapshot, setInitialSnapshot] = React.useState(null); + const [expandedActions, setExpandedActions] = React.useState>({}); + + React.useEffect(() => { + if (!isDesktopShellApp) { + return; + } + void loadDesktopSsh().catch(() => undefined); + }, [isDesktopShellApp, loadDesktopSsh]); + + React.useEffect(() => { + let cancelled = false; + setIsLoading(true); + + (async () => { + try { + const state = await getProjectActionsState(projectRef); + if (cancelled) { + return; + } + setActions(state.actions); + setInitialSnapshot(JSON.stringify({ actions: state.actions })); + } catch { + if (cancelled) { + return; + } + setActions([]); + setInitialSnapshot(JSON.stringify({ actions: [] })); + } finally { + if (!cancelled) { + setIsLoading(false); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [projectRef]); + + const desktopForwardOptions = React.useMemo(() => { + if (!isDesktopShellApp) { + return []; + } + return buildProjectActionDesktopForwardOptions(desktopSshInstances); + }, [desktopSshInstances, isDesktopShellApp]); + + const validationError = React.useMemo(() => { + const hasIncomplete = actions.some((entry) => { + return entry.name.trim().length === 0 || entry.command.trim().length === 0; + }); + if (hasIncomplete) { + return 'Fill action name and command before saving.'; + } + return null; + }, [actions]); + + const hasChanges = React.useMemo(() => { + if (initialSnapshot === null) { + return false; + } + return initialSnapshot !== JSON.stringify({ actions }); + }, [actions, initialSnapshot]); + + const handleAddAction = React.useCallback(() => { + const nextAction = createEmptyAction(); + setActions((prev) => [...prev, nextAction]); + setExpandedActions((prev) => ({ + ...prev, + [nextAction.id]: true, + })); + }, []); + + const handleRemoveAction = React.useCallback((id: string) => { + setActions((prev) => prev.filter((entry) => entry.id !== id)); + setExpandedActions((prev) => { + const next = { ...prev }; + delete next[id]; + return next; + }); + }, []); + + const updateAction = React.useCallback((id: string, updater: (current: EditableProjectAction) => EditableProjectAction) => { + setActions((prev) => prev.map((entry) => (entry.id === id ? updater(entry) : entry))); + }, []); + + const handleSave = React.useCallback(async () => { + if (validationError) { + toast.error(validationError); + return; + } + setIsSaving(true); + try { + const ok = await saveProjectActionsState(projectRef, { + actions, + primaryActionId: null, + }); + if (!ok) { + toast.error('Failed to save actions'); + return; + } + setInitialSnapshot(JSON.stringify({ actions })); + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent(PROJECT_ACTIONS_UPDATED_EVENT, { + detail: { projectId: projectRef.id }, + })); + } + toast.success('Project actions saved'); + } catch { + toast.error('Failed to save actions'); + } finally { + setIsSaving(false); + } + }, [actions, projectRef, validationError]); + + const canSave = !isSaving && !isLoading && hasChanges && !validationError; + + return ( +
+
+
+

Actions

+

Per-project commands shown in header next to project name.

+
+ + + Add action + +
+ +
+ {isLoading ? ( +

Loading...

+ ) : actions.length === 0 ? ( +
+

No actions configured yet.

+
+ ) : ( +
+ {actions.map((action) => { + const selectedIconKey = (action.icon as keyof typeof PROJECT_ACTION_ICON_MAP) || 'play'; + const SelectedIcon = PROJECT_ACTION_ICON_MAP[selectedIconKey] || RiPlayLine; + const isOpen = expandedActions[action.id] ?? false; + const title = action.name.trim() || 'Untitled action'; + + return ( + { + setExpandedActions((prev) => ({ + ...prev, + [action.id]: open, + })); + }} + className={cn( + 'py-1.5' + )} + > +
+ + {isOpen ? ( + + ) : ( + + )} + +
+
+ {title} +
+
+
+ + handleRemoveAction(action.id)} + > + + +
+ + +
+
+ + + + + +
+ {PROJECT_ACTION_ICONS.map((entry) => { + const Icon = entry.Icon; + const selected = (action.icon || 'play') === entry.key; + return ( + + ); + })} +
+
+
+ + updateAction(action.id, (current) => ({ ...current, name: event.target.value }))} + placeholder="Action name" + className="h-7 max-w-[14rem]" + /> +
+ +
+

Command

+