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
This commit is contained in:
Bohdan Triapitsyn
2026-02-27 20:45:03 +02:00
committed by GitHub
parent d948aa5557
commit 95c71789c4
26 changed files with 2572 additions and 148 deletions
+2 -1
View File
@@ -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",
+61 -13
View File
@@ -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);
};
+92 -18
View File
@@ -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));
});
+2 -2
View File
@@ -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 {
+1 -1
View File
@@ -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": {
@@ -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<MessageListHandle | null>(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 ? (
<ButtonSmall
type="button"
variant="outline"
size="xs"
onClick={handleReturnToParentSession}
className="absolute left-3 top-3 z-20 !font-normal bg-[var(--surface-background)]/95"
aria-label="Return to parent session"
title={parentSession.title?.trim() ? `Return to: ${parentSession.title}` : 'Return to parent session'}
>
<RiArrowLeftLine className="h-4 w-4" />
Parent
</ButtonSmall>
) : null;
React.useEffect(() => {
if (!currentSessionId && !draftOpen) {
openNewSessionDraft();
@@ -487,9 +528,10 @@ export const ChatContainer: React.FC = () => {
if (!hasMessagesEntry) {
return (
<div
className="flex flex-col h-full bg-background gap-0"
className="relative flex flex-col h-full bg-background gap-0"
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
>
{returnToParentButton}
<div className="flex-1 overflow-y-auto p-4 bg-background">
<div className="chat-message-column space-y-4">
{[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 ? (
<div className="flex-1 flex items-center justify-center">
<ChatEmptyState />
@@ -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}
<div
className={cn(
'relative min-h-0',
@@ -11,6 +11,46 @@ interface WorkingPlaceholderProps {
const STATUS_DISPLAY_TIME_MS = 1200;
const EPOCH_SECONDS_THRESHOLD = 1_000_000_000;
const EPOCH_MILLISECONDS_THRESHOLD = 1_000_000_000_000;
const toRetryTargetTimestamp = (next: number): number => {
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<ReturnType<typeof setTimeout> | null>(null);
// Countdown state for retry mode
const retryNextRef = React.useRef<number | null>(null);
const retryStartRef = React.useRef<number | null>(null);
const [retryCountdown, setRetryCountdown] = React.useState<number | null>(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 (
@@ -351,7 +351,11 @@ export const OpenInAppButton = ({ directory, className }: OpenInAppButtonProps)
<RiArrowDownSLine className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64 max-h-[70vh] overflow-y-auto">
<DropdownMenuContent
align="center"
className="w-56 max-h-[70vh] overflow-y-auto"
style={{ translate: '-30px 0' }}
>
<DropdownMenuItem className="flex items-center gap-2" onClick={() => void handleCopyPath()}>
<RiFileCopyLine className="h-4 w-4" />
<span className="typography-ui-label text-foreground">Copy Path</span>
+34 -4
View File
@@ -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<HeaderProps> = ({
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<HeaderProps> = ({
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<HeaderProps> = ({
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<HeaderProps> = ({
</div>
)}
{activeProjectRef && actionDirectory && (
<ProjectActionsButton
projectRef={activeProjectRef}
directory={actionDirectory}
className="mr-1"
/>
)}
{tabs.length > 0 && (
<div className="flex items-center gap-1 rounded-lg bg-[var(--surface-muted)]/50 p-1">
{tabs.map((tab) => renderTab(tab))}
@@ -1505,6 +1526,15 @@ export const Header: React.FC<HeaderProps> = ({
</div>
<div className="flex items-center gap-1 shrink-0">
{activeProjectRef && actionDirectory && (
<ProjectActionsButton
projectRef={activeProjectRef}
directory={actionDirectory}
compact
allowMobile
className="h-9"
/>
)}
{/* Mobile Services Menu (Usage + MCP) */}
<DropdownMenu
+68 -1
View File
@@ -64,6 +64,7 @@ const ACTION_TEXT_FADE_IN_DELAY_MS = 60;
type NavRailActionButtonProps = {
onClick: () => void;
disabled?: boolean;
ariaLabel: string;
icon: React.ReactNode;
tooltipLabel: string;
@@ -76,6 +77,7 @@ type NavRailActionButtonProps = {
const NavRailActionButton: React.FC<NavRailActionButtonProps> = ({
onClick,
disabled = false,
ariaLabel,
icon,
tooltipLabel,
@@ -85,12 +87,56 @@ const NavRailActionButton: React.FC<NavRailActionButtonProps> = ({
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<HTMLButtonElement>) => {
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<HTMLButtonElement>) => {
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 = (
<button
type="button"
onClick={onClick}
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onPointerCancel={clearPointerPress}
onPointerLeave={clearPointerPress}
onClick={handleClick}
className={buttonClassName}
aria-label={ariaLabel}
disabled={disabled}
>
{showExpandedContent && (
<span
@@ -423,6 +469,19 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
const setSettingsDialogOpen = useUIStore((s) => s.setSettingsDialogOpen);
const setAboutDialogOpen = useUIStore((s) => s.setAboutDialogOpen);
const toggleHelpDialog = useUIStore((s) => s.toggleHelpDialog);
const isOverlayBlockingNavRailActions = useUIStore((s) => (
s.isSettingsDialogOpen
|| s.isHelpDialogOpen
|| s.isCommandPaletteOpen
|| s.isSessionSwitcherOpen
|| s.isAboutDialogOpen
|| s.isOpenCodeStatusDialogOpen
|| s.isSessionCreateDialogOpen
|| s.isModelSelectorOpen
|| s.isTimelineDialogOpen
|| s.isMultiRunLauncherOpen
|| s.isImagePreviewOpen
));
const isNavRailExpanded = useUIStore((s) => s.isNavRailExpanded);
const toggleNavRail = useUIStore((s) => s.toggleNavRail);
const shortcutOverrides = useUIStore((s) => s.shortcutOverrides);
@@ -473,6 +532,7 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
const updateStore = useUpdateStore();
const { available: updateAvailable, downloaded: updateDownloaded } = updateStore;
const [updateDialogOpen, setUpdateDialogOpen] = React.useState(false);
const navRailInteractionBlocked = isOverlayBlockingNavRailActions || updateDialogOpen;
const [editingProject, setEditingProject] = React.useState<{
id: string;
@@ -676,6 +736,7 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
className={cn(
'flex h-full shrink-0 flex-col bg-[var(--surface-background)] overflow-hidden',
showExpandedContent ? 'items-stretch' : 'items-center',
navRailInteractionBlocked && 'pointer-events-none',
className,
)}
style={{ width: expanded ? NAV_RAIL_EXPANDED_WIDTH : NAV_RAIL_WIDTH }}
@@ -724,6 +785,7 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
<div className={cn('flex flex-col pb-3', showExpandedContent ? 'items-stretch px-1' : 'items-center px-1')}>
<NavRailActionButton
onClick={handleAddProject}
disabled={navRailInteractionBlocked}
ariaLabel="Add project"
icon={<RiFolderAddLine className={navRailActionIconClass} />}
tooltipLabel="Add project"
@@ -742,6 +804,7 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
{(updateAvailable || updateDownloaded) && (
<NavRailActionButton
onClick={() => setUpdateDialogOpen(true)}
disabled={navRailInteractionBlocked}
ariaLabel="Update available"
icon={<RiDownloadLine className={navRailActionIconClass} />}
tooltipLabel="Update available"
@@ -754,6 +817,7 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
{!isDesktopApp && !(updateAvailable || updateDownloaded) && (
<NavRailActionButton
onClick={() => setAboutDialogOpen(true)}
disabled={navRailInteractionBlocked}
ariaLabel="About"
icon={<RiInformationLine className={navRailActionIconClass} />}
tooltipLabel="About OpenChamber"
@@ -766,6 +830,7 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
{!mobile && (
<NavRailActionButton
onClick={toggleHelpDialog}
disabled={navRailInteractionBlocked}
ariaLabel="Keyboard shortcuts"
icon={<RiQuestionLine className={navRailActionIconClass} />}
tooltipLabel="Shortcuts"
@@ -779,6 +844,7 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
<NavRailActionButton
onClick={() => setSettingsDialogOpen(true)}
disabled={navRailInteractionBlocked}
ariaLabel="Settings"
icon={<RiSettings3Line className={navRailActionIconClass} />}
tooltipLabel="Settings"
@@ -793,6 +859,7 @@ export const NavRail: React.FC<NavRailProps> = ({ className, mobile }) => {
{!mobile && (
<NavRailActionButton
onClick={toggleNavRail}
disabled={navRailInteractionBlocked}
ariaLabel={expanded ? 'Collapse sidebar' : 'Expand sidebar'}
icon={expanded
? <RiMenuFoldLine className={navRailActionIconClass} />
@@ -0,0 +1,805 @@
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 {
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;
};
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 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) => {
try {
const tauri = (window as unknown as {
__TAURI__?: {
shell?: {
open?: (target: string) => Promise<unknown>;
};
};
}).__TAURI__;
if (tauri?.shell?.open) {
await tauri.shell.open(url);
return;
}
} catch {
// noop
}
window.open(url, '_blank', 'noopener,noreferrer');
}, []);
const loadActions = React.useCallback(async () => {
if (!stableProjectRef) {
setActions([]);
setSelectedActionId(null);
return;
}
setIsLoading(true);
try {
const state = await getProjectActionsState(stableProjectRef);
const filtered = state.actions;
setActions(filtered);
setSelectedActionId(filtered[0]?.id ?? null);
} catch {
setActions([]);
setSelectedActionId(null);
} finally {
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}\n\u0004`);
} 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"
disabled={isLoading}
className={cn(
'app-region-no-drag inline-flex h-9 w-9 items-center justify-center rounded-md 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:opacity-50',
className
)}
aria-label="Add action"
onClick={openProjectActionsSettings}
>
<RiAddLine className="h-5 w-5" />
</button>
);
}
return (
<button
type="button"
disabled={isLoading}
className={cn(
'app-region-no-drag inline-flex h-7 items-center gap-2 self-center rounded-md border border-[var(--interactive-border)]',
'bg-[var(--surface-elevated)] pl-1.5 pr-2.5 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',
'disabled:opacity-50',
className
)}
onClick={openProjectActionsSettings}
>
<RiAddLine className="h-4 w-4 text-muted-foreground" />
<span className="header-open-label">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 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-md 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:opacity-50',
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 items-center self-center rounded-md border border-[var(--interactive-border)]',
'bg-[var(--surface-elevated)] shadow-none overflow-hidden',
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 pl-2 pr-3',
'transition-colors disabled:opacity-50'
)}
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">{resolvedSelected.name}</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'
)}
aria-label="Choose project action"
>
<RiArrowDownSLine className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" alignOffset={8} 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);
}}
>
<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>
);
};
@@ -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<ProjectActionsSectionProps> = ({ projectRef }) => {
const isDesktopShellApp = React.useMemo(() => isDesktopShell(), []);
const desktopSshInstances = useDesktopSshStore((state) => state.instances);
const loadDesktopSsh = useDesktopSshStore((state) => state.load);
const [actions, setActions] = React.useState<EditableProjectAction[]>([]);
const [isLoading, setIsLoading] = React.useState(false);
const [isSaving, setIsSaving] = React.useState(false);
const [initialSnapshot, setInitialSnapshot] = React.useState<string | null>(null);
const [expandedActions, setExpandedActions] = React.useState<Record<string, boolean>>({});
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 (
<div className="mb-8">
<div className="mb-1 flex items-start justify-between gap-2">
<div>
<h3 className="typography-ui-header font-medium text-foreground">Actions</h3>
<p className="typography-meta text-muted-foreground">Per-project commands shown in header next to project name.</p>
</div>
<ButtonSmall type="button" variant="outline" size="xs" className="!font-normal" onClick={handleAddAction}>
<RiAddLine className="h-3.5 w-3.5" />
Add action
</ButtonSmall>
</div>
<section className="pb-2 pt-0 space-y-2">
{isLoading ? (
<p className="typography-meta text-muted-foreground">Loading...</p>
) : actions.length === 0 ? (
<div className="py-2">
<p className="typography-meta text-muted-foreground">No actions configured yet.</p>
</div>
) : (
<div className="space-y-0 max-w-[30rem]">
{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 (
<Collapsible
key={action.id}
open={isOpen}
onOpenChange={(open) => {
setExpandedActions((prev) => ({
...prev,
[action.id]: open,
}));
}}
className={cn(
'py-1.5'
)}
>
<div className="flex items-start gap-2">
<CollapsibleTrigger className="group flex-1 justify-start gap-2 rounded-md px-0 pr-1 py-1 hover:bg-[var(--interactive-hover)] focus-visible:ring-2 focus-visible:ring-[var(--interactive-focus-ring)]">
{isOpen ? (
<RiArrowDownSLine className="h-4 w-4 text-muted-foreground" />
) : (
<RiArrowRightSLine className="h-4 w-4 text-muted-foreground" />
)}
<SelectedIcon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="typography-ui-label text-foreground truncate">{title}</span>
</div>
</div>
</CollapsibleTrigger>
<ButtonSmall
type="button"
variant="ghost"
size="xs"
className="!font-normal h-7 w-7 px-0 text-muted-foreground hover:text-[var(--status-error)]"
onClick={() => handleRemoveAction(action.id)}
>
<RiDeleteBinLine className="h-3.5 w-3.5" />
</ButtonSmall>
</div>
<CollapsibleContent className="pt-1.5">
<div className="space-y-2 pb-6 pl-3 pr-3">
<div className="flex items-center gap-2 py-1">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-[var(--interactive-border)] text-foreground hover:bg-[var(--interactive-hover)]"
aria-label="Select icon"
>
<SelectedIcon className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56 p-2">
<div className="grid grid-cols-6 gap-1">
{PROJECT_ACTION_ICONS.map((entry) => {
const Icon = entry.Icon;
const selected = (action.icon || 'play') === entry.key;
return (
<button
key={entry.key}
type="button"
onClick={() => updateAction(action.id, (current) => ({ ...current, icon: entry.key }))}
className={cn(
'inline-flex h-8 w-8 items-center justify-center rounded-md border border-transparent text-foreground hover:bg-[var(--interactive-hover)]',
selected && 'border-[var(--primary-base)] bg-[var(--primary-base)]/10 text-[var(--primary-base)]'
)}
aria-label={`Icon ${entry.label}`}
>
<Icon className="h-4 w-4" />
</button>
);
})}
</div>
</DropdownMenuContent>
</DropdownMenu>
<Input
value={action.name}
onChange={(event) => updateAction(action.id, (current) => ({ ...current, name: event.target.value }))}
placeholder="Action name"
className="h-7 max-w-[14rem]"
/>
</div>
<div className="py-1">
<p className="typography-meta mb-0.5 text-muted-foreground">Command</p>
<Textarea
value={action.command}
onChange={(event) => updateAction(action.id, (current) => ({ ...current, command: event.target.value }))}
placeholder="e.g. bun run lint"
className="min-h-[88px] max-w-[30rem] font-mono text-xs"
/>
</div>
<div className="py-1">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="typography-ui-label text-foreground">Auto-open URL</span>
<div
className="group flex cursor-pointer items-center gap-2"
role="button"
tabIndex={0}
aria-pressed={action.autoOpenUrl === true}
onClick={() => updateAction(action.id, (current) => ({
...current,
...(current.autoOpenUrl === true ? { autoOpenUrl: undefined } : { autoOpenUrl: true }),
}))}
onKeyDown={(event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
updateAction(action.id, (current) => ({
...current,
...(current.autoOpenUrl === true ? { autoOpenUrl: undefined } : { autoOpenUrl: true }),
}));
}
}}
>
<Checkbox
checked={action.autoOpenUrl === true}
onChange={(checked) => updateAction(action.id, (current) => ({
...current,
...(checked ? { autoOpenUrl: true } : { autoOpenUrl: undefined }),
}))}
ariaLabel={`Auto-open URL for ${title}`}
/>
<span className="typography-ui-label font-normal text-foreground/80">Open URL from output or custom URL below</span>
</div>
</div>
{action.autoOpenUrl === true ? (
<div className="mt-1">
<div className="flex items-center gap-2">
<Input
value={action.openUrl || ''}
onChange={(event) => updateAction(action.id, (current) => ({
...current,
openUrl: event.target.value,
}))}
placeholder="Override URL (optional)"
className="h-7 w-full max-w-[24rem]"
/>
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<RiInformationLine className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60 cursor-help" />
</TooltipTrigger>
<TooltipContent sideOffset={8} className="max-w-xs">
If this field is filled, custom URL is used. If empty, app opens best URL from output.
</TooltipContent>
</Tooltip>
</div>
{isDesktopShellApp ? (
<div className="mt-2">
<p className="typography-meta mb-0.5 text-muted-foreground">Desktop SSH forward</p>
{desktopForwardOptions.length > 0 ? (
<Select
value={
action.desktopOpenSshForward && desktopForwardOptions.some((entry) => entry.id === action.desktopOpenSshForward)
? action.desktopOpenSshForward
: '__none__'
}
onValueChange={(value) => {
updateAction(action.id, (current) => ({
...current,
...(value === '__none__' ? { desktopOpenSshForward: undefined } : { desktopOpenSshForward: value }),
}));
}}
>
<SelectTrigger className="h-7 w-full max-w-[30rem]">
<SelectValue placeholder="Use output/manual URL" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">Use output/manual URL</SelectItem>
{desktopForwardOptions.map((entry) => (
<SelectItem key={entry.id} value={entry.id}>{entry.label}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<p className="typography-meta text-muted-foreground">No enabled local SSH forwards available.</p>
)}
</div>
) : null}
</div>
) : null}
</div>
</div>
</CollapsibleContent>
</Collapsible>
);
})}
</div>
)}
<div className="pt-3">
{validationError ? (
<p className="typography-meta mb-2 text-[var(--status-warning)]">{validationError}</p>
) : null}
<ButtonSmall
type="button"
size="xs"
className="!font-normal"
onClick={handleSave}
disabled={!canSave}
>
{isSaving ? 'Saving...' : 'Save Actions'}
</ButtonSmall>
</div>
</section>
</div>
);
};
@@ -9,6 +9,7 @@ import { useUIStore } from '@/stores/useUIStore';
import { PROJECT_COLORS, PROJECT_ICONS, PROJECT_COLOR_MAP as COLOR_MAP, getProjectIconImageUrl } from '@/lib/projectMeta';
import { RiCloseLine } from '@remixicon/react';
import { WorktreeSectionContent } from '@/components/sections/openchamber/WorktreeSectionContent';
import { ProjectActionsSection } from '@/components/sections/projects/ProjectActionsSection';
export const ProjectsPage: React.FC = () => {
const projects = useProjectsStore((state) => state.projects);
@@ -45,6 +46,13 @@ export const ProjectsPage: React.FC = () => {
const [previewImageFailed, setPreviewImageFailed] = React.useState(false);
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
const selectedProjectRef = React.useMemo(() => {
if (!selectedProject) {
return null;
}
return { id: selectedProject.id, path: selectedProject.path };
}, [selectedProject]);
React.useEffect(() => {
if (!selectedProject) {
setName('');
@@ -149,7 +157,6 @@ export const ProjectsPage: React.FC = () => {
</ScrollableOverlay>
);
}
return (
<ScrollableOverlay keyboardAvoid outerClassName="h-full" className="w-full bg-background">
<div className="mx-auto w-full max-w-4xl p-3 sm:p-6 sm:pt-8">
@@ -370,6 +377,13 @@ export const ProjectsPage: React.FC = () => {
</div>
</div>
{/* Worktree Group */}
<div className="mb-8">
<section className="px-2 pb-2 pt-0">
{selectedProjectRef && <ProjectActionsSection projectRef={selectedProjectRef} />}
</section>
</div>
{/* Worktree Group */}
<div className="mb-8">
<div className="mb-1 px-1">
@@ -378,7 +392,7 @@ export const ProjectsPage: React.FC = () => {
</h3>
</div>
<section className="px-2 pb-2 pt-0">
<WorktreeSectionContent projectRef={{ id: selectedProject.id, path: selectedProject.path }} />
{selectedProjectRef && <WorktreeSectionContent projectRef={selectedProjectRef} />}
</section>
</div>
@@ -55,6 +55,16 @@ type TerminalController = {
fit: () => void;
};
type TerminalWithViewport = {
scrollToBottom?: () => void;
getViewportY?: () => number;
hasSelection?: () => boolean;
};
type FitAddonWithObserveResize = FitAddon & {
observeResize?: () => void;
};
interface TerminalViewportProps {
sessionKey: string;
chunks: TerminalChunk[];
@@ -97,6 +107,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
const writeScheduledRef = React.useRef<number | null>(null);
const isWritingRef = React.useRef(false);
const lastProcessedChunkIdRef = React.useRef<number | null>(null);
const followOutputRef = React.useRef(true);
const touchScrollCleanupRef = React.useRef<(() => void) | null>(null);
const viewportDiscoveryTimeoutRef = React.useRef<number | null>(null);
const viewportDiscoveryAttemptsRef = React.useRef(0);
@@ -313,7 +324,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
React.useEffect(() => {
const container = containerRef.current;
if (!useHiddenInputOverlay || !container) {
if (!useHiddenInputOverlay || !container || enableTouchScroll) {
return;
}
@@ -338,7 +349,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
return () => {
container.removeEventListener('focusin', handleContainerFocusIn, true);
};
}, [useHiddenInputOverlay, focusHiddenInput]);
}, [enableTouchScroll, useHiddenInputOverlay, focusHiddenInput]);
const getTerminalSelectionText = React.useCallback((): string => {
const terminal = terminalRef.current as unknown as {
@@ -834,10 +845,9 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
scrollByPixels(deltaPixels);
};
const handleTouchEnd = (event: TouchEvent) => {
const handleTouchEnd = (event: TouchEvent) => {
const wasTap = !state.didMove;
state.lastY = null;
state.lastTime = null;
@@ -918,6 +928,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
let localResizeObserver: ResizeObserver | null = null;
let localTextareaObserver: MutationObserver | null = null;
let localDisposables: Array<{ dispose: () => void }> = [];
let restorePatchedScrollToBottom: (() => void) | null = null;
const container = containerRef.current;
if (!container) {
@@ -956,9 +967,23 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
return;
}
const options = getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty, useHiddenInputOverlay);
const options = getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty, false);
const terminal = new GhosttyTerminal(options);
followOutputRef.current = true;
const terminalWithViewport = terminal as unknown as TerminalWithViewport;
if (typeof terminalWithViewport.scrollToBottom === 'function') {
const originalScrollToBottom = terminalWithViewport.scrollToBottom.bind(terminalWithViewport);
terminalWithViewport.scrollToBottom = () => {
if (followOutputRef.current) {
originalScrollToBottom();
}
};
restorePatchedScrollToBottom = () => {
terminalWithViewport.scrollToBottom = originalScrollToBottom;
};
}
const fitAddon = new FitAddon();
@@ -999,11 +1024,31 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
}
fitTerminal();
const fitAddonWithResize = fitAddon as FitAddonWithObserveResize;
if (typeof fitAddonWithResize.observeResize === 'function') {
fitAddonWithResize.observeResize();
}
setupTouchScroll();
localDisposables = [
terminal.onData((data: string) => {
inputHandlerRef.current(data);
}),
terminal.onScroll((viewportY: number) => {
if (typeof viewportY === 'number' && Number.isFinite(viewportY)) {
const hasSelection = typeof terminal.hasSelection === 'function' && terminal.hasSelection();
followOutputRef.current = !hasSelection && viewportY <= 0.5;
}
}),
terminal.onSelectionChange(() => {
const hasSelection = typeof terminal.hasSelection === 'function' && terminal.hasSelection();
if (hasSelection) {
followOutputRef.current = false;
return;
}
const viewportY = typeof terminal.getViewportY === 'function' ? terminal.getViewportY() : 0;
followOutputRef.current = viewportY <= 0.5;
}),
];
localResizeObserver = new ResizeObserver(() => {
@@ -1035,6 +1080,8 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
window.removeEventListener('blur', handleWindowBlur);
localDisposables.forEach((disposable) => disposable.dispose());
restorePatchedScrollToBottom?.();
restorePatchedScrollToBottom = null;
if (localTerminalTextarea) {
localTerminalTextarea.removeEventListener('focus', handleTerminalTextareaFocus);
localTerminalTextarea.removeEventListener('blur', handleTerminalTextareaBlur);
@@ -1421,25 +1468,34 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
className={cn('relative h-full w-full terminal-viewport-container', className)}
style={{ backgroundColor: theme.background }}
onTouchStart={(event) => {
if (useHiddenInputOverlay) {
if (!useHiddenInputOverlay || enableTouchScroll) {
return;
}
if (!hasCopyableSelectionInViewport()) {
const touch = event.touches?.[0];
focusHiddenInput(touch?.clientX, touch?.clientY);
}
}}
onClick={(event) => {
if (useHiddenInputOverlay) {
if (enableTouchScroll) {
return;
}
if (hasCopyableSelectionInViewport()) {
return;
}
focusHiddenInput(event.clientX, event.clientY);
} else {
terminalRef.current?.focus();
}
}}
onMouseUp={() => {
if (!enableTouchScroll) {
if (!enableTouchScroll && hasCopyableSelectionInViewport()) {
void copySelectionToClipboard();
}
}}
onTouchEnd={() => {
if (!enableTouchScroll) {
if (!enableTouchScroll && hasCopyableSelectionInViewport()) {
void copySelectionToClipboard();
}
}}
@@ -32,6 +32,7 @@ function DropdownMenuTrigger({
function DropdownMenuContent({
className,
sideOffset = 4,
style,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
@@ -42,6 +43,7 @@ function DropdownMenuContent({
style={{
backgroundColor: 'var(--surface-elevated)',
color: 'var(--surface-elevated-foreground)',
...style,
}}
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-visible rounded-xl border-2 border-border/60 p-1 shadow-none",
@@ -19,9 +19,22 @@ export const SettingsWindow: React.FC<SettingsWindowProps> = ({ open, onOpenChan
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay
className="fixed inset-0 z-50 bg-black/50 backdrop-blur-md"
onPointerDown={(event) => {
event.stopPropagation();
}}
onPointerUp={(event) => {
event.stopPropagation();
}}
onClick={(event) => {
event.stopPropagation();
onOpenChange(false);
}}
/>
<DialogPrimitive.Content
aria-describedby={descriptionId}
onPointerDownOutside={(event) => {
event.preventDefault();
}}
className={cn(
'fixed z-50 top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%]',
'w-[90vw] max-w-[960px] h-[85vh] max-h-[900px]',
+121 -64
View File
@@ -10,7 +10,6 @@ import { useFontPreferences } from '@/hooks/useFontPreferences';
import { CODE_FONT_OPTION_MAP, DEFAULT_MONO_FONT } from '@/lib/fontOptions';
import { convertThemeToXterm } from '@/lib/terminalTheme';
import { TerminalViewport, type TerminalController } from '@/components/terminal/TerminalViewport';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { cn } from '@/lib/utils';
import { useUIStore } from '@/stores/useUIStore';
import { Button } from '@/components/ui/button';
@@ -86,9 +85,11 @@ export const TerminalView: React.FC = () => {
const { currentTheme } = useThemeSystem();
const { monoFont } = useFontPreferences();
const terminalFontSize = useUIStore(state => state.terminalFontSize);
const bottomTerminalHeight = useUIStore((state) => state.bottomTerminalHeight);
const isBottomTerminalExpanded = useUIStore((state) => state.isBottomTerminalExpanded);
const { isMobile, hasTouchInput } = useDeviceInfo();
// Tabs are supported for web + desktop runtimes (not VSCode).
const enableTabs = !isMobile && runtime.platform !== 'vscode';
// Tabs are supported for web + desktop runtimes, including mobile (not VSCode).
const enableTabs = runtime.platform !== 'vscode';
const showTerminalQuickKeysOnDesktop = useUIStore((state) => state.showTerminalQuickKeysOnDesktop);
const showQuickKeys = isMobile || showTerminalQuickKeysOnDesktop;
@@ -137,6 +138,7 @@ export const TerminalView: React.FC = () => {
const [isFatalError, setIsFatalError] = React.useState(false);
const [activeModifier, setActiveModifier] = React.useState<Modifier | null>(null);
const [isRestarting, setIsRestarting] = React.useState(false);
const [viewportLayoutVersion, setViewportLayoutVersion] = React.useState(0);
const keyboardAvoidTargetId = React.useId();
const streamCleanupRef = React.useRef<(() => void) | null>(null);
@@ -151,6 +153,13 @@ export const TerminalView: React.FC = () => {
const rehydratedTerminalIdsRef = React.useRef<Set<string>>(new Set());
const rehydratedSnapshotTakenRef = React.useRef(false);
const focusTerminalWhenWindowActive = React.useCallback(() => {
if (typeof document !== 'undefined' && !document.hasFocus()) {
return;
}
terminalControllerRef.current?.focus();
}, []);
React.useEffect(() => {
if (!terminalHydrated) {
return;
@@ -263,7 +272,7 @@ export const TerminalView: React.FC = () => {
setConnecting(directory, tabId, false);
setConnectionError(null);
setIsFatalError(false);
terminalControllerRef.current?.focus();
focusTerminalWhenWindowActive();
// After a reload, buffer is empty and a reused PTY can look "stuck"
// until the first output arrives. Nudge with a newline once.
@@ -292,6 +301,10 @@ export const TerminalView: React.FC = () => {
const exitCode =
typeof event.exitCode === 'number' ? event.exitCode : null;
const signal = typeof event.signal === 'number' ? event.signal : null;
const currentTab = useTerminalStore.getState()
.getDirectoryState(directory)
?.tabs.find((t) => t.id === tabId);
const isActionTab = Boolean(currentTab?.label?.startsWith('Action:'));
appendToBuffer(
directory,
tabId,
@@ -301,7 +314,7 @@ export const TerminalView: React.FC = () => {
);
setTabSessionId(directory, tabId, null);
setConnecting(directory, tabId, false);
setConnectionError('Terminal session ended');
setConnectionError(isActionTab ? null : 'Terminal session ended');
setIsFatalError(false);
disconnectStream();
break;
@@ -335,7 +348,7 @@ export const TerminalView: React.FC = () => {
activeTerminalIdRef.current = null;
};
},
[appendToBuffer, disconnectStream, setConnecting, setTabSessionId, terminal]
[appendToBuffer, disconnectStream, focusTerminalWhenWindowActive, setConnecting, setTabSessionId, terminal]
);
React.useEffect(() => {
@@ -375,6 +388,8 @@ export const TerminalView: React.FC = () => {
const tab = state.tabs.find((t) => t.id === tabId) ?? state.tabs[0];
let terminalId = tab?.terminalSessionId ?? null;
const isActionTab = Boolean(tab?.label?.startsWith('Action:'));
const hasBufferedOutput = (tab?.bufferLength ?? 0) > 0 || (tab?.bufferChunks?.length ?? 0) > 0;
const shouldNudgeExisting =
Boolean(terminalId) &&
@@ -386,6 +401,11 @@ export const TerminalView: React.FC = () => {
Boolean(terminalId) && rehydratedTerminalIdsRef.current.has(terminalId as string);
if (!terminalId) {
if (isActionTab && hasBufferedOutput) {
setConnecting(directory, tabId, false);
return;
}
setConnectionError(null);
setIsFatalError(false);
setConnecting(directory, tabId, true);
@@ -473,27 +493,27 @@ export const TerminalView: React.FC = () => {
}
if (typeof window === 'undefined') {
terminalControllerRef.current?.focus();
focusTerminalWhenWindowActive();
return;
}
const rafId = window.requestAnimationFrame(() => {
terminalControllerRef.current?.focus();
focusTerminalWhenWindowActive();
});
return () => {
window.cancelAnimationFrame(rafId);
};
}, [activeTabId, isTerminalVisible]);
}, [activeTabId, focusTerminalWhenWindowActive, isTerminalVisible]);
const handleRestart = React.useCallback(async () => {
if (!effectiveDirectory) return;
if (isRestarting) return;
const state = useTerminalStore.getState().getDirectoryState(effectiveDirectory);
const tabId = isMobile
? (state?.tabs[0]?.id ?? null)
: (activeTabId ?? state?.activeTabId ?? state?.tabs[0]?.id ?? null);
const tabId = enableTabs
? (activeTabId ?? state?.activeTabId ?? state?.tabs[0]?.id ?? null)
: (state?.tabs[0]?.id ?? null);
if (!tabId) return;
setIsRestarting(true);
@@ -510,7 +530,7 @@ export const TerminalView: React.FC = () => {
} finally {
setIsRestarting(false);
}
}, [activeTabId, closeTab, disconnectStream, effectiveDirectory, isMobile, isRestarting]);
}, [activeTabId, closeTab, disconnectStream, effectiveDirectory, enableTabs, isRestarting]);
const handleHardRestart = React.useCallback(async () => {
// Keep semantics: “close tab -> new clean tab”.
@@ -735,7 +755,29 @@ export const TerminalView: React.FC = () => {
return `${directoryPart}::${tabPart}::${terminalPart}`;
}, [effectiveDirectory, activeTabId, terminalSessionId]);
const viewportSessionKey = terminalSessionId ?? terminalSessionKey;
const viewportSessionKey = React.useMemo(() => {
const base = terminalSessionId ?? terminalSessionKey;
return `${base}::layout-${viewportLayoutVersion}`;
}, [terminalSessionId, terminalSessionKey, viewportLayoutVersion]);
React.useEffect(() => {
if (isMobile || !isBottomTerminalOpen || !isTerminalVisible) {
return;
}
if (typeof window === 'undefined') {
setViewportLayoutVersion((value) => value + 1);
return;
}
const timeoutId = window.setTimeout(() => {
setViewportLayoutVersion((value) => value + 1);
}, 140);
return () => {
window.clearTimeout(timeoutId);
};
}, [bottomTerminalHeight, isBottomTerminalExpanded, isBottomTerminalOpen, isMobile, isTerminalVisible]);
React.useEffect(() => {
if (!isTerminalVisible) {
@@ -751,7 +793,7 @@ export const TerminalView: React.FC = () => {
if (typeof window !== 'undefined') {
const rafId = window.requestAnimationFrame(() => {
fitOnce();
controller.focus();
focusTerminalWhenWindowActive();
});
const timeoutIds = [220, 400].map((delay) => window.setTimeout(fitOnce, delay));
return () => {
@@ -760,7 +802,35 @@ export const TerminalView: React.FC = () => {
};
}
fitOnce();
}, [isTerminalVisible, terminalSessionKey, terminalSessionId]);
}, [focusTerminalWhenWindowActive, isTerminalVisible, terminalSessionKey, terminalSessionId]);
React.useEffect(() => {
if (isMobile || !isTerminalVisible || !isBottomTerminalOpen) {
return;
}
const controller = terminalControllerRef.current;
if (!controller) {
return;
}
const fitOnce = () => {
controller.fit();
};
if (typeof window !== 'undefined') {
const rafId = window.requestAnimationFrame(() => {
fitOnce();
});
const timeoutIds = [0, 80, 180, 320].map((delay) => window.setTimeout(fitOnce, delay));
return () => {
window.cancelAnimationFrame(rafId);
timeoutIds.forEach((id) => window.clearTimeout(id));
};
}
fitOnce();
}, [bottomTerminalHeight, isBottomTerminalExpanded, isBottomTerminalOpen, isMobile, isTerminalVisible]);
if (!hasActiveContext) {
return (
@@ -901,18 +971,19 @@ export const TerminalView: React.FC = () => {
return (
<div className="flex h-full flex-col overflow-hidden bg-[var(--surface-background)]">
<div className="sticky top-0 z-20 shrink-0 bg-[var(--surface-background)] px-5 py-2 text-xs">
<div className={cn('sticky top-0 z-20 shrink-0 bg-[var(--surface-background)] text-xs', isMobile ? 'px-4 py-1.5' : 'px-5 py-2')}>
{enableTabs && directoryTerminalState ? (
<div className="mt-2 pl-1 pr-1 flex items-center gap-2">
<div className="min-w-0 flex-1 overflow-x-auto pb-1">
<div className="flex w-max items-center gap-1 pr-1">
<div className={cn('pl-1 pr-1 flex items-center gap-2', isMobile ? 'mt-1' : 'mt-2')}>
<div className={cn('min-w-0 flex-1 overflow-x-auto', isMobile ? 'pb-0.5' : 'pb-1')}>
<div className={cn('flex w-max items-center pr-1', isMobile ? 'gap-1' : 'gap-1')}>
{directoryTerminalState.tabs.map((tab) => {
const isActive = tab.id === activeTabId;
return (
<div
key={tab.id}
className={cn(
'group flex items-center gap-1 rounded-md border px-2 py-1 text-xs whitespace-nowrap',
'group flex items-center rounded-md border whitespace-nowrap',
isMobile ? 'h-8 gap-0.5 pl-2 pr-1.5 text-sm leading-none' : 'gap-1 pl-2 pr-1 py-1 text-xs',
isActive
? 'bg-[var(--interactive-selection)] border-[var(--primary-muted)] text-[var(--interactive-selection-foreground)]'
: 'bg-transparent border-[var(--interactive-border)] text-[var(--surface-muted-foreground)] hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]'
@@ -921,7 +992,10 @@ export const TerminalView: React.FC = () => {
<button
type="button"
onClick={() => handleSelectTab(tab.id)}
className="max-w-[10rem] truncate text-left"
className={cn(
'truncate text-left',
isMobile ? '!min-h-0 !min-w-0 max-w-[9.5rem]' : 'max-w-[10rem]'
)}
title={tab.label}
>
{tab.label}
@@ -929,8 +1003,9 @@ export const TerminalView: React.FC = () => {
<button
type="button"
className={cn(
'rounded-sm p-0.5 text-[var(--surface-muted-foreground)] hover:text-[var(--surface-foreground)]',
!isActive && 'opacity-0 group-hover:opacity-100'
'flex items-center justify-center rounded-sm text-[var(--surface-muted-foreground)] hover:text-[var(--surface-foreground)]',
isMobile ? '!min-h-0 !min-w-0 h-3.5 w-3.5 p-0 leading-none' : 'h-4 w-4 p-0 leading-none',
!isMobile && !isActive && 'opacity-0 group-hover:opacity-100'
)}
onClick={(e) => {
e.stopPropagation();
@@ -938,7 +1013,7 @@ export const TerminalView: React.FC = () => {
}}
title="Close tab"
>
<RiCloseLine size={14} />
{isMobile ? <span aria-hidden>×</span> : <RiCloseLine size={12} />}
</button>
</div>
);
@@ -947,10 +1022,13 @@ export const TerminalView: React.FC = () => {
<button
type="button"
onClick={handleCreateTab}
className="ml-1 flex h-7 w-7 items-center justify-center rounded-md border border-[var(--interactive-border)] bg-transparent text-[var(--surface-muted-foreground)] hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]"
className={cn(
'ml-1 flex items-center justify-center rounded-md border border-[var(--interactive-border)] bg-transparent text-[var(--surface-muted-foreground)] hover:bg-[var(--interactive-hover)] hover:text-[var(--surface-foreground)]',
isMobile ? '!min-h-0 !min-w-0 h-8 w-8' : 'h-6.5 w-6.5'
)}
title="New tab"
>
<RiAddLine size={16} />
<RiAddLine size={isMobile ? 18 : 16} />
</button>
</div>
</div>
@@ -978,43 +1056,22 @@ export const TerminalView: React.FC = () => {
>
<div className="h-full w-full box-border pl-7 pr-5 pt-3 pb-4">
{shouldRenderViewport ? (
isMobile ? (
<TerminalViewport
key={viewportSessionKey}
ref={(controller) => {
terminalControllerRef.current = controller;
}}
sessionKey={viewportSessionKey}
chunks={bufferChunks}
onInput={handleViewportInput}
onResize={handleViewportResize}
theme={xtermTheme}
fontFamily={resolvedFontStack}
fontSize={terminalFontSize}
enableTouchScroll={hasTouchInput}
autoFocus={isTerminalVisible}
keyboardAvoidTargetId={keyboardAvoidTargetId}
/>
) : (
<ScrollableOverlay outerClassName="h-full" className="h-full w-full" disableHorizontal>
<TerminalViewport
key={viewportSessionKey}
ref={(controller) => {
terminalControllerRef.current = controller;
}}
sessionKey={viewportSessionKey}
chunks={bufferChunks}
onInput={handleViewportInput}
onResize={handleViewportResize}
theme={xtermTheme}
fontFamily={resolvedFontStack}
fontSize={terminalFontSize}
enableTouchScroll={hasTouchInput}
autoFocus={isTerminalVisible}
keyboardAvoidTargetId={keyboardAvoidTargetId}
/>
</ScrollableOverlay>
)
<TerminalViewport
key={viewportSessionKey}
ref={(controller) => {
terminalControllerRef.current = controller;
}}
sessionKey={viewportSessionKey}
chunks={bufferChunks}
onInput={handleViewportInput}
onResize={handleViewportResize}
theme={xtermTheme}
fontFamily={resolvedFontStack}
fontSize={terminalFontSize}
enableTouchScroll={hasTouchInput}
autoFocus={isTerminalVisible}
keyboardAvoidTargetId={keyboardAvoidTargetId}
/>
) : null}
</div>
{connectionError && (
+148
View File
@@ -59,6 +59,26 @@ export interface OpenChamberConfig {
'setup-worktree'?: string[];
projectNotes?: string;
projectTodos?: OpenChamberProjectTodoItem[];
projectActions?: OpenChamberProjectAction[];
projectActionsPrimaryId?: string;
}
export type OpenChamberProjectActionPlatform = 'macos' | 'linux' | 'windows';
export interface OpenChamberProjectAction {
id: string;
name: string;
command: string;
icon?: string | null;
platforms?: OpenChamberProjectActionPlatform[];
autoOpenUrl?: boolean;
openUrl?: string;
desktopOpenSshForward?: string;
}
export interface OpenChamberProjectActionsState {
actions: OpenChamberProjectAction[];
primaryActionId: string | null;
}
export interface OpenChamberProjectTodoItem {
@@ -75,6 +95,12 @@ export interface OpenChamberProjectNotesTodos {
export const OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH = 1000;
export const OPENCHAMBER_PROJECT_TODO_TEXT_MAX_LENGTH = 120;
export const OPENCHAMBER_PROJECT_ACTION_NAME_MAX_LENGTH = 80;
export const OPENCHAMBER_PROJECT_ACTION_COMMAND_MAX_LENGTH = 4000;
export const OPENCHAMBER_PROJECT_ACTION_OPEN_URL_MAX_LENGTH = 2000;
export const OPENCHAMBER_PROJECT_ACTION_DESKTOP_FORWARD_MAX_LENGTH = 300;
const OPENCHAMBER_ACTION_PLATFORM_SET = new Set<OpenChamberProjectActionPlatform>(['macos', 'linux', 'windows']);
const normalize = (value: string): string => {
if (!value) return '';
@@ -344,6 +370,105 @@ const sanitizeProjectTodoItems = (value: unknown): OpenChamberProjectTodoItem[]
return sanitized;
};
const sanitizeProjectActionPlatforms = (value: unknown): OpenChamberProjectActionPlatform[] => {
if (!Array.isArray(value)) {
return [];
}
const unique: OpenChamberProjectActionPlatform[] = [];
const seen = new Set<OpenChamberProjectActionPlatform>();
for (const entry of value) {
if (typeof entry !== 'string') {
continue;
}
const normalized = entry.trim().toLowerCase() as OpenChamberProjectActionPlatform;
if (!OPENCHAMBER_ACTION_PLATFORM_SET.has(normalized) || seen.has(normalized)) {
continue;
}
seen.add(normalized);
unique.push(normalized);
}
return unique;
};
const sanitizeProjectActions = (value: unknown): OpenChamberProjectAction[] => {
if (!Array.isArray(value)) {
return [];
}
const sanitized: OpenChamberProjectAction[] = [];
const seenIds = new Set<string>();
for (const entry of value) {
if (!entry || typeof entry !== 'object') {
continue;
}
const record = entry as {
id?: unknown;
name?: unknown;
command?: unknown;
icon?: unknown;
platforms?: unknown;
autoOpenUrl?: unknown;
openUrl?: unknown;
desktopOpenSshForward?: unknown;
};
const id = typeof record.id === 'string' ? record.id.trim() : '';
const name = trimToMaxLength(typeof record.name === 'string' ? record.name.trim() : '', OPENCHAMBER_PROJECT_ACTION_NAME_MAX_LENGTH);
const command = trimToMaxLength(typeof record.command === 'string' ? record.command.trim() : '', OPENCHAMBER_PROJECT_ACTION_COMMAND_MAX_LENGTH);
if (!id || !name || !command || seenIds.has(id)) {
continue;
}
seenIds.add(id);
const iconRaw = typeof record.icon === 'string' ? record.icon.trim() : '';
const platforms = sanitizeProjectActionPlatforms(record.platforms);
const autoOpenUrl = record.autoOpenUrl === true;
const openUrlRaw = typeof record.openUrl === 'string' ? record.openUrl.trim() : '';
const openUrl = trimToMaxLength(openUrlRaw, OPENCHAMBER_PROJECT_ACTION_OPEN_URL_MAX_LENGTH);
const desktopOpenSshForwardRaw = typeof record.desktopOpenSshForward === 'string'
? record.desktopOpenSshForward.trim()
: '';
const desktopOpenSshForward = trimToMaxLength(
desktopOpenSshForwardRaw,
OPENCHAMBER_PROJECT_ACTION_DESKTOP_FORWARD_MAX_LENGTH
);
sanitized.push({
id,
name,
command,
icon: iconRaw || null,
...(autoOpenUrl ? { autoOpenUrl: true } : {}),
...(openUrl ? { openUrl } : {}),
...(desktopOpenSshForward ? { desktopOpenSshForward } : {}),
...(platforms.length > 0 ? { platforms } : {}),
});
}
return sanitized;
};
const sanitizeProjectActionsState = (value: {
actions?: unknown;
primaryActionId?: unknown;
} | null | undefined): OpenChamberProjectActionsState => {
const actions = sanitizeProjectActions(value?.actions);
const primaryRaw = typeof value?.primaryActionId === 'string' ? value.primaryActionId.trim() : '';
const primaryActionId = primaryRaw && actions.some((entry) => entry.id === primaryRaw)
? primaryRaw
: null;
return {
actions,
primaryActionId,
};
};
const sanitizeProjectNotesAndTodos = (value: {
notes?: unknown;
todos?: unknown;
@@ -504,6 +629,29 @@ export async function saveProjectNotesAndTodos(
});
}
export async function getProjectActionsState(project: ProjectRef): Promise<OpenChamberProjectActionsState> {
const config = await readOpenChamberConfig(project);
return sanitizeProjectActionsState({
actions: config?.projectActions,
primaryActionId: config?.projectActionsPrimaryId,
});
}
export async function saveProjectActionsState(
project: ProjectRef,
value: OpenChamberProjectActionsState
): Promise<boolean> {
const sanitized = sanitizeProjectActionsState({
actions: value.actions,
primaryActionId: value.primaryActionId,
});
return updateOpenChamberConfig(project, {
projectActions: sanitized.actions,
projectActionsPrimaryId: sanitized.primaryActionId ?? undefined,
});
}
/**
* Substitute variables in a command string.
* Supported variables:
+199
View File
@@ -0,0 +1,199 @@
import {
RiBrainAi3Line,
RiCheckboxCircleLine,
RiBugLine,
RiCodeLine,
RiCommandLine,
RiFileTextLine,
RiFlaskLine,
RiGitBranchLine,
RiHammerLine,
RiPlayLine,
RiRocketLine,
RiRobot2Line,
RiSearchLine,
RiServerLine,
RiSettings3Line,
RiStackLine,
RiTerminalBoxLine,
RiToolsLine,
} from '@remixicon/react';
import type { ComponentType } from 'react';
import type {
OpenChamberProjectAction,
OpenChamberProjectActionPlatform,
} from '@/lib/openchamberConfig';
import type {
DesktopSshInstance,
DesktopSshPortForward,
} from '@/lib/desktopSsh';
export type ProjectActionIconKey =
| 'play'
| 'build'
| 'lint'
| 'terminal'
| 'tools'
| 'bug'
| 'flask'
| 'rocket'
| 'code'
| 'server'
| 'branch'
| 'search'
| 'settings'
| 'brain'
| 'stack'
| 'robot'
| 'command'
| 'file';
export const PROJECT_ACTION_ICONS: Array<{
key: ProjectActionIconKey;
label: string;
Icon: ComponentType<{ className?: string }>;
}> = [
{ key: 'play', label: 'Play', Icon: RiPlayLine },
{ key: 'build', label: 'Build', Icon: RiHammerLine },
{ key: 'lint', label: 'Lint', Icon: RiCheckboxCircleLine },
{ key: 'terminal', label: 'Terminal', Icon: RiTerminalBoxLine },
{ key: 'tools', label: 'Tools', Icon: RiToolsLine },
{ key: 'bug', label: 'Bug', Icon: RiBugLine },
{ key: 'flask', label: 'Flask', Icon: RiFlaskLine },
{ key: 'rocket', label: 'Rocket', Icon: RiRocketLine },
{ key: 'code', label: 'Code', Icon: RiCodeLine },
{ key: 'server', label: 'Server', Icon: RiServerLine },
{ key: 'branch', label: 'Branch', Icon: RiGitBranchLine },
{ key: 'search', label: 'Search', Icon: RiSearchLine },
{ key: 'settings', label: 'Settings', Icon: RiSettings3Line },
{ key: 'brain', label: 'Brain', Icon: RiBrainAi3Line },
{ key: 'stack', label: 'Stack', Icon: RiStackLine },
{ key: 'robot', label: 'Robot', Icon: RiRobot2Line },
{ key: 'command', label: 'Command', Icon: RiCommandLine },
{ key: 'file', label: 'File', Icon: RiFileTextLine },
];
export const PROJECT_ACTION_ICON_MAP = Object.fromEntries(
PROJECT_ACTION_ICONS.map((entry) => [entry.key, entry.Icon])
) as Record<ProjectActionIconKey, ComponentType<{ className?: string }>>;
export const PROJECT_ACTIONS_UPDATED_EVENT = 'openchamber:project-actions-updated';
export const normalizeProjectActionDirectory = (value: string): string => {
const trimmed = (value || '').trim().replace(/\\/g, '/');
if (!trimmed) {
return '';
}
if (trimmed === '/') {
return '/';
}
return trimmed.length > 1 ? trimmed.replace(/\/+$/, '') : trimmed;
};
export const getCurrentProjectActionPlatform = (): OpenChamberProjectActionPlatform => {
if (typeof navigator === 'undefined') {
return 'macos';
}
const ua = (navigator.userAgent || '').toLowerCase();
if (ua.includes('windows')) {
return 'windows';
}
if (ua.includes('linux')) {
return 'linux';
}
return 'macos';
};
export const isProjectActionEnabledOnPlatform = (
action: OpenChamberProjectAction,
platform: OpenChamberProjectActionPlatform
): boolean => {
if (!Array.isArray(action.platforms) || action.platforms.length === 0) {
return true;
}
return action.platforms.includes(platform);
};
export const toProjectActionRunKey = (directory: string, actionId: string): string => {
return `${normalizeProjectActionDirectory(directory)}::${actionId}`;
};
export type ProjectActionDesktopForwardOption = {
id: string;
label: string;
url: string;
};
const toBrowserHost = (host: string | undefined): string => {
const value = (host || '').trim();
if (!value || value === '0.0.0.0' || value === '::') {
return '127.0.0.1';
}
return value;
};
const normalizePort = (value: number | undefined): number | null => {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return null;
}
const rounded = Math.round(value);
if (rounded < 1 || rounded > 65535) {
return null;
}
return rounded;
};
const buildForwardOption = (instance: DesktopSshInstance, forward: DesktopSshPortForward): ProjectActionDesktopForwardOption | null => {
if (!forward.enabled || forward.type !== 'local') {
return null;
}
const localPort = normalizePort(forward.localPort);
const remotePort = normalizePort(forward.remotePort);
if (!localPort || !remotePort) {
return null;
}
const localHost = toBrowserHost(forward.localHost || instance.localForward.bindHost || '127.0.0.1');
const remoteHost = (forward.remoteHost || '127.0.0.1').trim();
const instanceLabel = (instance.nickname || instance.id || 'instance').trim();
return {
id: `${instance.id}::${forward.id}`,
label: `${instanceLabel} - ${localHost}:${localPort} -> ${remoteHost}:${remotePort}`,
url: `http://${localHost}:${localPort}`,
};
};
export const buildProjectActionDesktopForwardOptions = (
instances: DesktopSshInstance[]
): ProjectActionDesktopForwardOption[] => {
const options: ProjectActionDesktopForwardOption[] = [];
for (const instance of instances) {
if (!instance?.id || !Array.isArray(instance.portForwards)) {
continue;
}
for (const forward of instance.portForwards) {
const option = buildForwardOption(instance, forward);
if (option) {
options.push(option);
}
}
}
return options;
};
export const resolveProjectActionDesktopForwardUrl = (
selectionId: string | undefined,
instances: DesktopSshInstance[]
): string | null => {
const key = (selectionId || '').trim();
if (!key) {
return null;
}
const options = buildProjectActionDesktopForwardOptions(instances);
const matched = options.find((entry) => entry.id === key);
return matched?.url || null;
};
+39 -1
View File
@@ -36,6 +36,7 @@ interface TerminalStore {
createTab: (directory: string) => string;
setActiveTab: (directory: string, tabId: string) => void;
setTabLabel: (directory: string, tabId: string, label: string) => void;
closeTab: (directory: string, tabId: string) => Promise<void>;
setTabSessionId: (directory: string, tabId: string, sessionId: string | null) => void;
@@ -192,6 +193,43 @@ export const useTerminalStore = create<TerminalStore>()(
});
},
setTabLabel: (directory: string, tabId: string, label: string) => {
const key = normalizeDirectory(directory);
const normalizedLabel = label.trim();
if (!normalizedLabel) {
return;
}
set((state) => {
const newSessions = new Map(state.sessions);
const existing = newSessions.get(key);
if (!existing) {
return state;
}
const idx = findTabIndex(existing, tabId);
if (idx < 0) {
return state;
}
if (existing.tabs[idx]?.label === normalizedLabel) {
return state;
}
const nextTabs = [...existing.tabs];
nextTabs[idx] = {
...nextTabs[idx],
label: normalizedLabel,
};
newSessions.set(key, {
...existing,
tabs: nextTabs,
});
return { sessions: newSessions };
});
},
closeTab: async (directory: string, tabId: string) => {
const key = normalizeDirectory(directory);
const entry = get().sessions.get(key);
@@ -258,7 +296,7 @@ export const useTerminalStore = create<TerminalStore>()(
}
const tab = existing.tabs[idx];
const shouldResetBuffer = tab.terminalSessionId !== sessionId;
const shouldResetBuffer = sessionId !== null && tab.terminalSessionId !== sessionId;
const nextTab: TerminalTab = {
...tab,
+1
View File
@@ -126,6 +126,7 @@
user-select: text;
}
/* Allow system context menu only when explicitly requested via the "More" button */
:root.mobile-pointer:not(.desktop-runtime) .message-content-text.show-system-menu {
-webkit-touch-callout: default;
+9 -2
View File
@@ -11528,6 +11528,13 @@ async function main(options = {}) {
const terminalSessions = new Map();
const MAX_TERMINAL_SESSIONS = 20;
const TERMINAL_IDLE_TIMEOUT = 30 * 60 * 1000;
const sanitizeTerminalEnv = (env) => {
const next = { ...env };
delete next.BASH_XTRACEFD;
delete next.BASH_ENV;
delete next.ENV;
return next;
};
const terminalInputCapabilities = {
input: {
preferred: 'ws',
@@ -11751,7 +11758,7 @@ async function main(options = {}) {
Math.random().toString(36).substring(2, 15);
const envPath = buildAugmentedPath();
const resolvedEnv = { ...process.env, PATH: envPath };
const resolvedEnv = sanitizeTerminalEnv({ ...process.env, PATH: envPath });
const pty = await getPtyProvider();
const { ptyProcess, shell } = spawnTerminalPtyWithFallback(pty, {
@@ -11965,7 +11972,7 @@ async function main(options = {}) {
Math.random().toString(36).substring(2, 15);
const envPath = buildAugmentedPath();
const resolvedEnv = { ...process.env, PATH: envPath };
const resolvedEnv = sanitizeTerminalEnv({ ...process.env, PATH: envPath });
const pty = await getPtyProvider();
const { ptyProcess, shell } = spawnTerminalPtyWithFallback(pty, {
+11 -15
View File
@@ -13,20 +13,16 @@ declare global {
window.__OPENCHAMBER_RUNTIME_APIS__ = createWebAPIs();
registerSW({
onRegistered(registration: ServiceWorkerRegistration | undefined) {
if (!registration) {
return;
}
// Periodic update check (best-effort)
setInterval(() => {
void registration.update();
}, 60 * 60 * 1000);
},
onRegisterError(error: unknown) {
console.warn('[PWA] service worker registration failed:', error);
},
});
if (import.meta.env.PROD) {
registerSW({
onRegisterError(error: unknown) {
console.warn('[PWA] service worker registration failed:', error);
},
});
} else if ('serviceWorker' in navigator) {
void navigator.serviceWorker.getRegistrations()
.then((registrations) => Promise.all(registrations.map((registration) => registration.unregister())))
.catch(() => {});
}
import('@openchamber/ui/main');
+2 -1
View File
@@ -8,6 +8,7 @@ import { themeStoragePlugin } from '../../vite-theme-plugin';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const packageJson = JSON.parse(readFileSync(path.resolve(__dirname, 'package.json'), 'utf-8'));
const pwaDevEnabled = process.env.OPENCHAMBER_DISABLE_PWA_DEV !== '1';
const reactScanToggle = (process.env.VITE_ENABLE_REACT_SCAN ?? '').toLowerCase();
const enableReactScan = reactScanToggle === '1' || reactScanToggle === 'true' || reactScanToggle === 'on' || reactScanToggle === 'yes';
@@ -53,7 +54,7 @@ export default defineConfig({
injectionPoint: undefined,
},
devOptions: {
enabled: true,
enabled: pwaDevEnabled,
type: 'module',
},
}),
+214
View File
@@ -0,0 +1,214 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, '..');
const useDetachedChildren = process.platform === 'darwin';
function run(label, command, args, options = {}) {
const child = spawn(command, args, {
cwd: repoRoot,
stdio: ['inherit', 'pipe', 'pipe'],
env: { ...process.env },
detached: useDetachedChildren,
...options,
});
child.on('error', (error) => {
console.error(`[dev:web:full] Failed to start ${label}:`, error);
});
child.stdout?.on('data', (chunk) => {
process.stdout.write(chunk);
});
child.stderr?.on('data', (chunk) => {
process.stderr.write(chunk);
});
return child;
}
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 (useDetachedChildren && 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);
}
}
function waitForFirstBuildSuccess(buildChild) {
return new Promise((resolve, reject) => {
let done = false;
const settleResolve = () => {
if (done) return;
done = true;
cleanup();
resolve();
};
const settleReject = (error) => {
if (done) return;
done = true;
cleanup();
reject(error);
};
let stdoutBuffer = '';
let stderrBuffer = '';
const onOutput = (chunk, source) => {
const text = chunk.toString();
if (source === 'stdout') {
stdoutBuffer += text;
if (stdoutBuffer.length > 8000) {
stdoutBuffer = stdoutBuffer.slice(-4000);
}
} else {
stderrBuffer += text;
if (stderrBuffer.length > 8000) {
stderrBuffer = stderrBuffer.slice(-4000);
}
}
if (/\bbuilt in\b/i.test(text) || /watching for file changes/i.test(text)) {
settleResolve();
}
};
const onStdout = (chunk) => onOutput(chunk, 'stdout');
const onStderr = (chunk) => onOutput(chunk, 'stderr');
const onExit = (code, signal) => {
if (done) return;
const suffix = signal ? `signal=${signal}` : `code=${code ?? 'null'}`;
settleReject(new Error(`Build watcher exited before first successful build (${suffix}).`));
};
const onError = (error) => {
settleReject(error);
};
const cleanup = () => {
buildChild.stdout?.off('data', onStdout);
buildChild.stderr?.off('data', onStderr);
buildChild.off('exit', onExit);
buildChild.off('error', onError);
};
buildChild.stdout?.on('data', onStdout);
buildChild.stderr?.on('data', onStderr);
buildChild.on('exit', onExit);
buildChild.on('error', onError);
});
}
let shuttingDown = false;
let api = null;
const build = run('build', 'bun', ['run', '--cwd', 'packages/web', 'build:watch']);
async function shutdown(exitCode = 0) {
if (shuttingDown) return;
shuttingDown = true;
await Promise.all([stopChildTree(api), stopChildTree(build)]);
process.exit(exitCode);
}
function onChildExit(label) {
return (code, signal) => {
if (shuttingDown) {
return;
}
if (code !== 0 || signal) {
console.error(`[dev:web:full] ${label} exited unexpectedly (code=${code ?? 'null'} signal=${signal ?? 'none'})`);
shutdown(typeof code === 'number' ? code : 1).catch(() => process.exit(1));
return;
}
shutdown(0).catch(() => process.exit(1));
};
}
build.on('exit', onChildExit('build'));
waitForFirstBuildSuccess(build)
.then(() => {
if (shuttingDown || api) {
return;
}
console.log('[dev:web:full] Initial frontend build ready, starting API watcher...');
api = run('api', 'bun', ['run', '--cwd', 'packages/web', 'dev:server:watch']);
api.on('exit', onChildExit('api'));
})
.catch((error) => {
console.error('[dev:web:full] Failed waiting for initial frontend build:', error.message || error);
shutdown(1).catch(() => process.exit(1));
});
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));
});
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, '..');
const useDetachedChildren = process.platform === 'darwin';
function run(label, command, args, env = {}, options = {}) {
return spawn(command, args, {
cwd: options.cwd || repoRoot,
stdio: 'inherit',
env: { ...process.env, ...env },
detached: useDetachedChildren,
}).on('error', (error) => {
console.error(`[dev:web:hmr] Failed to start ${label}:`, error);
});
}
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 (useDetachedChildren && 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);
}
}
const uiPort = process.env.OPENCHAMBER_HMR_UI_PORT || '5180';
const backendPort = process.env.OPENCHAMBER_HMR_API_PORT || '3902';
const api = run('api', 'bun', ['run', '--cwd', 'packages/web', 'dev:server:watch'], {
OPENCHAMBER_PORT: backendPort,
});
const vite = run(
'vite',
'bun',
['x', 'vite', '--host', '127.0.0.1', '--port', uiPort, '--strictPort'],
{
OPENCHAMBER_PORT: backendPort,
OPENCHAMBER_DISABLE_PWA_DEV: '1',
},
{ cwd: path.join(repoRoot, 'packages/web') },
);
console.log(`[dev:web:hmr] UI with HMR: http://127.0.0.1:${uiPort}`);
console.log(`[dev:web:hmr] API: http://127.0.0.1:${backendPort}`);
console.log('[dev:web:hmr] IMPORTANT: open UI URL above for HMR; backend URL has no HMR');
let shuttingDown = false;
async function shutdown(exitCode = 0) {
if (shuttingDown) return;
shuttingDown = true;
await Promise.all([stopChildTree(api), stopChildTree(vite)]);
process.exit(exitCode);
}
function onChildExit(label) {
return (code, signal) => {
if (shuttingDown) return;
if (code !== 0 || signal) {
console.error(`[dev:web:hmr] ${label} exited unexpectedly (code=${code ?? 'null'} signal=${signal ?? 'none'})`);
shutdown(typeof code === 'number' ? code : 1).catch(() => process.exit(1));
return;
}
shutdown(0).catch(() => process.exit(1));
};
}
api.on('exit', onChildExit('api'));
vite.on('exit', onChildExit('vite'));
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));
});