diff --git a/docs/REVERSE_PROXY.md b/docs/REVERSE_PROXY.md index b0c368d8..e82c7a93 100644 --- a/docs/REVERSE_PROXY.md +++ b/docs/REVERSE_PROXY.md @@ -19,7 +19,6 @@ Use this guide when running OpenChamber behind Nginx, Nginx Proxy Manager, Caddy - `/api/global/event` - `/api/notifications/stream` - `/api/openchamber/events` - - `/api/terminal/:sessionId/stream` - Large request bodies for attachments and file operations - Long-lived read timeouts for live streams and terminal sessions @@ -104,19 +103,6 @@ location ~ ^/api/(event|global/event|notifications/stream|openchamber/events)$ { proxy_send_timeout 3600s; } -location ~ ^/api/terminal/.+/stream$ { - proxy_pass http://127.0.0.1:3000; - proxy_set_header Accept "text/event-stream"; - proxy_set_header Cache-Control "no-cache"; - proxy_buffering off; - proxy_cache off; - gzip off; - add_header X-Accel-Buffering "no" always; - add_header Cache-Control "no-cache, no-transform" always; - proxy_read_timeout 3600s; - proxy_send_timeout 3600s; -} - location /api { proxy_pass http://127.0.0.1:3000; proxy_read_timeout 3600s; @@ -239,20 +225,6 @@ location = /api/openchamber/events { proxy_connect_timeout 30s; } -location ~ ^/api/terminal/.+/stream$ { - proxy_pass http://127.0.0.1:3000; - proxy_set_header Accept "text/event-stream"; - proxy_set_header Cache-Control "no-cache"; - proxy_buffering off; - proxy_cache off; - gzip off; - add_header X-Accel-Buffering "no" always; - add_header Cache-Control "no-cache, no-transform" always; - proxy_read_timeout 3600s; - proxy_send_timeout 3600s; - proxy_connect_timeout 30s; -} - location /api { proxy_pass http://127.0.0.1:3000; proxy_read_timeout 3600s; diff --git a/package.json b/package.json index fb79a60a..72c1d0f4 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "scripts": { "dev": "node ./scripts/dev-web-hmr.mjs", "oc-dev": "node scripts/oc-dev.mjs", - "build": "bun run --filter '*' build", + "build": "bun run --sequential --filter '!@openchamber/mobile' build && bun run --cwd packages/mobile build:assets", "build:web": "bun run --cwd packages/web build", "build:ui": "bun run --cwd packages/ui build", "build:electron": "bun run --cwd packages/electron build", diff --git a/packages/mobile/README.md b/packages/mobile/README.md index 6f21cb5f..d56d3a5b 100644 --- a/packages/mobile/README.md +++ b/packages/mobile/README.md @@ -11,12 +11,14 @@ The mobile package reuses the web build, then rewrites `mobile.html` to `index.h - Connections are saved locally in the app and can be managed from the mobile overflow menu under `Instances`. - The connection screen and `Instances` menu item are Capacitor-only. Hosted `mobile.html` in a normal browser keeps the regular web behavior. - Password-protected OpenChamber servers can be unlocked from the mobile app. The app stores the issued client token with the saved connection. +- The Terminal workspace surface runs its PTY on the active OpenChamber server over the shared authenticated runtime transport; it never opens a local shell on the phone or tablet. Closing the surface detaches the renderer while the server session remains available for reattachment. On touch devices, dragging scrolls the buffer while long-pressing and dragging selects terminal text. ## Commands Run these from `packages/mobile`, or use the root `mobile:*` aliases. - `bun run build`: builds `packages/web` and prepares mobile web assets. +- `bun run build:assets`: prepares mobile assets from an existing `packages/web/dist` build; the root workspace build uses this to avoid rebuilding web. - `bun run sync`: prepares assets and runs `cap sync`. - `bun run add:ios`: creates the native iOS project. - `bun run add:android`: creates the native Android project. diff --git a/packages/mobile/package.json b/packages/mobile/package.json index 75596240..67890952 100644 --- a/packages/mobile/package.json +++ b/packages/mobile/package.json @@ -4,7 +4,8 @@ "private": true, "type": "module", "scripts": { - "build": "bun run --cwd ../web build && node scripts/prepare-web-assets.mjs", + "build": "bun run --cwd ../web build && bun run build:assets", + "build:assets": "node scripts/prepare-web-assets.mjs", "sync": "node scripts/with-mobile-env.mjs \"bun run build && cap sync\"", "add:ios": "cap add ios", "add:android": "cap add android", diff --git a/packages/ui/src/apps/MobileApp.tsx b/packages/ui/src/apps/MobileApp.tsx index 4e377934..c51d892a 100644 --- a/packages/ui/src/apps/MobileApp.tsx +++ b/packages/ui/src/apps/MobileApp.tsx @@ -13,6 +13,7 @@ import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo'; import { ProviderLogo } from '@/components/ui/ProviderLogo'; import { ChatView } from '@/components/views/ChatView'; import { SettingsView } from '@/components/views/SettingsView'; +import { TerminalView } from '@/components/views/TerminalView'; import { ErrorBoundary } from '@/components/ui/ErrorBoundary'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider'; @@ -658,7 +659,7 @@ const getProjectLabel = (path: string): string => { }; type OverflowItem = { - key: 'files' | 'changes' | 'mcp' | 'instances' | 'update' | 'settings'; + key: 'files' | 'changes' | 'terminal' | 'mcp' | 'instances' | 'update' | 'settings'; icon?: IconName; iconNode?: React.ReactNode; label: string; @@ -2064,6 +2065,7 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc const [sessionsSheetOpen, setSessionsSheetOpen] = React.useState(false); const [filesOpen, setFilesOpen] = React.useState(false); const [changesOpen, setChangesOpen] = React.useState(false); + const [terminalOpen, setTerminalOpen] = React.useState(false); const [mcpOpen, setMcpOpen] = React.useState(false); const [instancesOpen, setInstancesOpen] = React.useState(false); const [isMcpRefreshing, setIsMcpRefreshing] = React.useState(false); @@ -2340,6 +2342,12 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc }, ); } + items.push({ + key: 'terminal', + icon: 'terminal', + label: t('mobile.menu.terminal'), + onSelect: () => setTerminalOpen(true), + }); items.push({ key: 'mcp', iconNode: , @@ -2560,6 +2568,21 @@ const MobileShell: React.FC<{ onActiveConnectionDeleted: () => void }> = ({ onAc ) : null} + {terminalOpen ? ( + setTerminalOpen(false)} + ariaLabel={t('mobile.menu.terminal')} + title={t('mobile.menu.terminal')} + disableSwipeDismiss + disableEscapeDismiss + > + + + + + ) : null} + {mcpOpen ? ( void; /** If true, disable swipe-down-to-dismiss (e.g. when a nested view should keep gesture for itself). */ disableSwipeDismiss?: boolean; + /** If true, leave Escape available to nested content instead of dismissing the surface. */ + disableEscapeDismiss?: boolean; /** If true, render only the drag handle and let the child render its own header. */ headerless?: boolean; ariaLabel?: string; @@ -53,6 +55,7 @@ export const MobileSurfaceShell: React.FC = ({ trailing, onBack, disableSwipeDismiss = false, + disableEscapeDismiss = false, headerless = false, ariaLabel, children, @@ -120,7 +123,7 @@ export const MobileSurfaceShell: React.FC = ({ }; const focusTimer = window.setTimeout(focusFirstElement, ENTER_DELAY_MS); const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { + if (event.key === 'Escape' && !disableEscapeDismiss) { onCloseRef.current(); return; } @@ -154,7 +157,7 @@ export const MobileSurfaceShell: React.FC = ({ previousFocusRef.current?.focus?.({ preventScroll: true }); previousFocusRef.current = null; }; - }, [open]); + }, [disableEscapeDismiss, open]); const handleDragStart = (event: React.TouchEvent) => { if (disableSwipeDismiss) return; @@ -259,9 +262,13 @@ export const MobileSurfaceShell: React.FC = ({ onTouchEnd={handleDragEnd} onTouchCancel={handleDragEnd} > -
- -
+ {disableSwipeDismiss ? ( +
+ ) : ( +
+ +
+ )} {!headerless ? (
{leading} diff --git a/packages/ui/src/apps/runtimeEndpointReset.ts b/packages/ui/src/apps/runtimeEndpointReset.ts index a6a8cb00..9a9ebe86 100644 --- a/packages/ui/src/apps/runtimeEndpointReset.ts +++ b/packages/ui/src/apps/runtimeEndpointReset.ts @@ -7,8 +7,10 @@ import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; import { useAutoReviewStore } from '@/stores/useAutoReviewStore'; import { useUIStore } from '@/stores/useUIStore'; import { usePermissionStore } from '@/stores/permissionStore'; +import { useTerminalStore } from '@/stores/useTerminalStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { resetStreamingState } from '@/sync/streaming'; +import { syncDesktopSettings } from '@/lib/persistence'; // Same-device transport switch (LAN⇄relay for one paired device): rebind the SDK // to the new transport WITHOUT tearing down connection/session state or remounting @@ -31,6 +33,7 @@ export const resetAppForRuntimeEndpointChange = (detail: RuntimeEndpointChangedD useAutoReviewStore.getState().stopRunningRunsForRuntime(detail.previousRuntimeKey); } disposeTerminalInputTransport(); + useTerminalStore.getState().clearAll(); opencodeClient.reconnectToRuntimeBaseUrl(); useConfigStore.setState({ providers: [], @@ -48,4 +51,5 @@ export const resetAppForRuntimeEndpointChange = (detail: RuntimeEndpointChangedD useSessionUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey); useUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey); resetStreamingState(); + queueMicrotask(() => void syncDesktopSettings()); }; diff --git a/packages/ui/src/components/auth/SessionAuthGate.tsx b/packages/ui/src/components/auth/SessionAuthGate.tsx index 826b30f2..b867fad2 100644 --- a/packages/ui/src/components/auth/SessionAuthGate.tsx +++ b/packages/ui/src/components/auth/SessionAuthGate.tsx @@ -534,8 +534,8 @@ export const SessionAuthGate: React.FC = ({ if (state === 'authenticated' && !hasResyncedRef.current) { hasResyncedRef.current = true; void (async () => { - await syncDesktopSettings(); await initializeAppearancePreferences(); + await syncDesktopSettings(); await applyPersistedDirectoryPreferences(); })(); } diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 13a9ac65..683ef53b 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -1520,12 +1520,14 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo let previewConsole = 0; let previewAnnotation = 0; let review = 0; + let terminal = 0; for (const draft of drafts) { if (draft.source === 'preview-console') previewConsole += 1; else if (draft.source === 'preview-annotation') previewAnnotation += 1; + else if (draft.source === 'terminal') terminal += 1; else review += 1; } - return `${previewConsole}:${previewAnnotation}:${review}`; + return `${previewConsole}:${previewAnnotation}:${review}:${terminal}`; }, [currentSessionId, newSessionDraftOpen] ) @@ -1533,7 +1535,10 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const consumeDrafts = useInlineCommentDraftStore((state) => state.consumeDrafts); const removeInlineCommentDraft = useInlineCommentDraftStore((state) => state.removeDraft); const hasDrafts = draftCount > 0; - const [previewConsoleCount, previewAnnotationCount, reviewCount] = draftSourceKey.split(':').map((entry) => Number(entry) || 0); + const [previewConsoleCount, previewAnnotationCount, reviewCount, terminalContextCount] = draftSourceKey.split(':').map((entry) => Number(entry) || 0); + const terminalContextDrafts = terminalContextCount > 0 + ? (useInlineCommentDraftStore.getState().drafts[currentSessionId ?? (newSessionDraftOpen ? 'draft' : '')] ?? []).filter((draft) => draft.source === 'terminal') + : []; const removePreviewDrafts = React.useCallback((source: 'preview-console' | 'preview-annotation') => { const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : ''); if (!sessionKey) return; @@ -1550,7 +1555,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo if (!sessionKey) return; const drafts = useInlineCommentDraftStore.getState().drafts[sessionKey] ?? []; for (const draft of drafts) { - if (draft.source !== 'preview-console' && draft.source !== 'preview-annotation') { + if (draft.source !== 'preview-console' && draft.source !== 'preview-annotation' && draft.source !== 'terminal') { removeInlineCommentDraft(sessionKey, draft.id); } } @@ -2327,6 +2332,11 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo inputMode, sendMessageOptions, ); + const restoreConsumedDrafts = () => { + if (sessionKey && drafts.length > 0) { + useInlineCommentDraftStore.getState().restoreDrafts(sessionKey, drafts); + } + }; if (typeof window === 'undefined') { scrollToBottom?.(); @@ -2354,6 +2364,7 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo const normalized = rawMessage.toLowerCase(); console.error('Message send failed:', rawMessage || error); + restoreConsumedDrafts(); const currentInput = textareaRef.current?.value ?? messageRef.current; if (newSessionDraftOpen && inputSnapshot.message && (!currentInput || currentInput === inputSnapshot.message)) { @@ -4650,6 +4661,17 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo {hasDrafts && (
+ {terminalContextDrafts.map((draft) => ( +
+ + + {t('chat.chatInput.terminalContext', { terminal: draft.fileLabel, start: draft.startLine, end: draft.endLine })} + + +
+ ))} {reviewCount > 0 ? (
= ({ part, messageId, agentMention }) => { const partWithText = part as PartWithText; const rawText = partWithText.text; - const textContent = typeof rawText === 'string' ? rawText : partWithText.content || partWithText.value || ''; + const serializedText = typeof rawText === 'string' ? rawText : partWithText.content || partWithText.value || ''; + const terminalContextState = React.useMemo(() => extractTerminalContexts(serializedText), [serializedText]); + const textContent = terminalContextState.visibleText; const [isExpanded, setIsExpanded] = React.useState(false); const [isTruncated, setIsTruncated] = React.useState(false); @@ -190,7 +193,7 @@ const UserTextPart: React.FC = ({ part, messageId, agentMenti }); }, [agentMention, openSkill, skillByName, textContent]); - if (!textContent || textContent.trim().length === 0) { + if ((!textContent || textContent.trim().length === 0) && terminalContextState.contexts.length === 0) { return null; } @@ -243,6 +246,18 @@ const UserTextPart: React.FC = ({ part, messageId, agentMenti plainTextContent )}
+ {terminalContextState.contexts.length > 0 ? ( +
+ {terminalContextState.contexts.map((context, index) => ( +
+ + {t('chat.message.terminalContext', { terminal: context.terminalLabel, start: context.startLine, end: context.endLine })} + +
{context.text}
+
+ ))} +
+ ) : null}
); }; diff --git a/packages/ui/src/components/layout/ContextPanel.tsx b/packages/ui/src/components/layout/ContextPanel.tsx index 23086158..e195ba37 100644 --- a/packages/ui/src/components/layout/ContextPanel.tsx +++ b/packages/ui/src/components/layout/ContextPanel.tsx @@ -25,6 +25,7 @@ import { runtimeFetch } from '@/lib/runtime-fetch'; import { refreshRuntimeUrlAuthToken } from '@/lib/runtime-auth'; import { getRuntimeUrlResolver } from '@/lib/runtime-url'; import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch'; +import { getPreviewTargetRecoveryAction } from '@/lib/preview/proxy-response'; import { Icon } from "@/components/icon/Icon"; import { OpenChamberLogo } from "@/components/ui/OpenChamberLogo"; import { invokeDesktopCommand } from '@/lib/desktopNative'; @@ -951,27 +952,37 @@ const PreviewPane: React.FC = ({ rawUrl, onNavigate }) => { // Out-of-band upstream probe: iframes don't expose HTTP status to the parent, // so when the proxy returns a 502 (upstream dev server is offline) the iframe - // would just render the raw JSON error body. Probe the proxy URL with a HEAD + // would just render the raw JSON error body. Probe the proxy URL with a GET // request and surface a friendly overlay when the upstream is unreachable. type UpstreamState = 'unknown' | 'starting' | 'reachable' | 'unreachable'; const [upstreamState, setUpstreamState] = React.useState('unknown'); const upstreamProbeStartedAtRef = React.useRef(0); const upstreamProbeAttemptRef = React.useRef(0); + const upstreamProbeKeyRef = React.useRef(''); + const proxyRecoveryAttemptedKeyRef = React.useRef(''); const PREVIEW_STARTUP_GRACE_MS = 15_000; React.useEffect(() => { if (!proxySrc) { setUpstreamState('unknown'); + upstreamProbeKeyRef.current = ''; upstreamProbeStartedAtRef.current = 0; upstreamProbeAttemptRef.current = 0; return; } let cancelled = false; - if (!upstreamProbeStartedAtRef.current) { + let retryTimeout: ReturnType | null = null; + if (upstreamProbeKeyRef.current !== proxyCacheKey) { + upstreamProbeKeyRef.current = proxyCacheKey; upstreamProbeStartedAtRef.current = Date.now(); upstreamProbeAttemptRef.current = 0; } + const scheduleRetry = (delay: number) => { + retryTimeout = setTimeout(() => { + if (!cancelled) bumpReload(); + }, delay); + }; setUpstreamState('unknown'); void (async () => { @@ -993,21 +1004,36 @@ const PreviewPane: React.FC = ({ rawUrl, onNavigate }) => { if (cancelled) return; if (!response) { - // Network-level failure (e.g. server itself is down) — treat as unreachable. setUpstreamState('unreachable'); + scheduleRetry(5000); return; } - if (response.status === 403 || response.status === 404) { + const recoveryAction = getPreviewTargetRecoveryAction( + response.headers, + proxyRecoveryAttemptedKeyRef.current === proxyCacheKey, + ); + if (recoveryAction !== 'none') { previewProxyTargetCache.delete(proxyCacheKey); - setProxyState({ status: 'loading' }); - bumpProxyRegistration(); + if (recoveryAction === 'retry-registration') { + proxyRecoveryAttemptedKeyRef.current = proxyCacheKey; + setProxyState({ status: 'loading' }); + bumpProxyRegistration(); + } else { + const errorBody = await response.json().catch(() => ({})); + if (cancelled) return; + const message = typeof errorBody?.error === 'string' + ? errorBody.error + : `HTTP ${response.status}`; + setProxyState({ status: 'error', message }); + } return; } // The proxy emits 502 when the upstream is unreachable. Anything else // (including 4xx from the upstream) means the upstream answered. if (response.status !== 502) { + proxyRecoveryAttemptedKeyRef.current = ''; setUpstreamState('reachable'); return; } @@ -1021,19 +1047,17 @@ const PreviewPane: React.FC = ({ rawUrl, onNavigate }) => { upstreamProbeAttemptRef.current += 1; const attempt = upstreamProbeAttemptRef.current; const delay = Math.min(2000, 250 * Math.pow(2, Math.min(4, attempt))); - setTimeout(() => { - if (!cancelled) { - bumpReload(); - } - }, delay).unref?.(); + scheduleRetry(delay); return; } setUpstreamState('unreachable'); + scheduleRetry(5000); })(); return () => { cancelled = true; + if (retryTimeout) clearTimeout(retryTimeout); }; }, [proxyCacheKey, proxySrc, reloadNonce]); diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 4c42a4ad..276aee87 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -64,7 +64,6 @@ 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 { forceKillTerminal } from '@/lib/terminalApi'; import { useTerminalStore } from '@/stores/useTerminalStore'; import { ProjectActionsButton } from '@/components/layout/ProjectActionsButton'; import { SessionSwitcherDropdown } from '@/components/session/SessionSwitcherDropdown'; @@ -1776,7 +1775,8 @@ export const Header: React.FC = ({ }, [shortcutOverrides]); useEffect(() => { - if (!isMobile && (activeMainTab === 'git' || activeMainTab === 'terminal' || activeMainTab === 'diff' || activeMainTab === 'files' || activeMainTab === 'context')) { + // Project actions may intentionally promote the terminal to the desktop main view. + if (!isMobile && (activeMainTab === 'git' || activeMainTab === 'diff' || activeMainTab === 'files' || activeMainTab === 'context')) { setActiveMainTab('chat'); } }, [activeMainTab, isMobile, setActiveMainTab]); @@ -1831,7 +1831,7 @@ export const Header: React.FC = ({ try { // Ensure preview/dev terminals don't linger. - await forceKillTerminal({}); + await runtimeApis.terminal.forceKill?.({}); } catch { // ignore } @@ -1856,7 +1856,7 @@ export const Header: React.FC = ({ setIsDevShutdownInFlight(false); } } - }, [isDevShutdownInFlight, setIsDesktopServicesOpen]); + }, [isDevShutdownInFlight, runtimeApis.terminal, setIsDesktopServicesOpen]); const quotaDisplayTabs = React.useMemo(() => { return [ diff --git a/packages/ui/src/components/layout/MainLayout.tsx b/packages/ui/src/components/layout/MainLayout.tsx index 891e16fd..0efd43b4 100644 --- a/packages/ui/src/components/layout/MainLayout.tsx +++ b/packages/ui/src/components/layout/MainLayout.tsx @@ -16,6 +16,7 @@ import { SessionSidebar } from '@/components/session/SessionSidebar'; import { SessionDialogs } from '@/components/session/SessionDialogs'; import { DiffWorkerProvider } from '@/contexts/DiffWorkerProvider'; import { MultiRunLauncher } from '@/components/multirun'; +import { TerminalView } from '@/components/views/TerminalView'; import { DrawerProvider } from '@/contexts/DrawerContext'; import { useUIStore } from '@/stores/useUIStore'; @@ -31,8 +32,9 @@ import { FilesView } from '@/components/views/FilesView'; import { GitView } from '@/components/views/GitView'; import { PlanView } from '@/components/views/PlanView'; -// Heavy views loaded on-demand to reduce initial bundle parse time. -const TerminalView = lazyWithChunkRecovery(() => import('@/components/views/TerminalView').then(m => ({ default: m.TerminalView }))); +// Keep TerminalView eager: the bottom dock reserves its height immediately, so +// suspending here leaves a large blank panel on slower machines. +// Other heavy views stay on-demand to reduce initial bundle parse time. const DiagramView = lazyWithChunkRecovery(() => import('@/components/views/DiagramView').then(m => ({ default: m.DiagramView }))); const SettingsView = lazyWithChunkRecovery(() => import('@/components/views/SettingsView').then(m => ({ default: m.SettingsView }))); const SettingsWindow = lazyWithChunkRecovery(() => import('@/components/views/SettingsWindow').then(m => ({ default: m.SettingsWindow }))); @@ -365,7 +367,7 @@ export const MainLayout: React.FC = () => { case 'diff': return ; case 'terminal': - return ; + return ; case 'files': return ; case 'context': @@ -539,12 +541,10 @@ export const MainLayout: React.FC = () => {
- - {isBottomTerminalOpen ? ( + + {isBottomTerminalOpen && activeMainTab !== 'terminal' ? ( - - - + ) : null} diff --git a/packages/ui/src/components/layout/ProjectActionsButton.tsx b/packages/ui/src/components/layout/ProjectActionsButton.tsx index 5821babb..9fd823e2 100644 --- a/packages/ui/src/components/layout/ProjectActionsButton.tsx +++ b/packages/ui/src/components/layout/ProjectActionsButton.tsx @@ -15,6 +15,7 @@ import { useDeviceInfo } from '@/lib/device'; import { isDesktopShell } from '@/lib/desktop'; import { useUIStore } from '@/stores/useUIStore'; import { useTerminalStore } from '@/stores/useTerminalStore'; +import { useThemeSystem } from '@/contexts/useThemeSystem'; import { useDesktopSshStore } from '@/stores/useDesktopSshStore'; import { openExternalUrl } from '@/lib/url'; import { useI18n } from '@/lib/i18n'; @@ -31,7 +32,7 @@ import { toProjectActionRunKey, } from '@/lib/projectActions'; import { detectDevServerCommand, readPackageJsonScripts } from '@/lib/detectDevServer'; -import { connectTerminalStream } from '@/lib/terminalApi'; +import { waitForTerminalExit } from '@/lib/projectActionTerminal'; type UrlWatchEntry = { lastSeenChunkId: number | null; @@ -40,12 +41,6 @@ type UrlWatchEntry = { openInPreview: boolean; }; -const sleep = (ms: number): Promise => { - return new Promise((resolve) => { - window.setTimeout(resolve, ms); - }); -}; - interface ProjectActionsButtonProps { projectRef: ProjectRef | null; directory: string; @@ -154,6 +149,7 @@ export const ProjectActionsButton = ({ allowMobile = false, }: ProjectActionsButtonProps) => { const { t } = useI18n(); + const { currentTheme } = useThemeSystem(); const { terminal, runtime } = useRuntimeAPIs(); const { isMobile } = useDeviceInfo(); const isDesktopShellApp = React.useMemo(() => isDesktopShell(), []); @@ -161,13 +157,14 @@ export const ProjectActionsButton = ({ const loadDesktopSsh = useDesktopSshStore((state) => state.load); const setBottomTerminalOpen = useUIStore((state) => state.setBottomTerminalOpen); + const terminalShell = useUIStore((state) => state.terminalShell); + const terminalLoginShell = useUIStore((state) => state.terminalLoginShells.includes(state.terminalShell)); 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 openContextPreview = useUIStore((state) => state.openContextPreview); - const terminalSessions = useTerminalStore((state) => state.sessions); const ensureDirectory = useTerminalStore((state) => state.ensureDirectory); const setTabLabel = useTerminalStore((state) => state.setTabLabel); const setTabIconKey = useTerminalStore((state) => state.setTabIconKey); @@ -187,6 +184,7 @@ export const ProjectActionsButton = ({ const urlWatchByRunKeyRef = React.useRef>({}); const streamCleanupByRunKeyRef = React.useRef void>>({}); const previewWaitTimeoutByRunKeyRef = React.useRef>({}); + const startingRunKeysRef = React.useRef>(new Set()); const loadRequestIdRef = React.useRef(0); const projectId = projectRef?.id ?? null; @@ -311,79 +309,66 @@ export const ProjectActionsButton = ({ }, [actions, canUseAutoDiscover, selectedActionId]); React.useEffect(() => { - for (const [key, entry] of Object.entries(projectActionRuns)) { - const directoryState = terminalSessions.get(entry.directory); - const tab = directoryState?.tabs.find((item) => item.id === entry.tabId); - if (!tab || tab.terminalSessionId !== entry.sessionId) { - removeProjectActionRun(key); - } - } - }, [projectActionRuns, removeProjectActionRun, terminalSessions]); - - React.useEffect(() => { - for (const [runKey, entry] of Object.entries(projectActionRuns)) { - const watch = urlWatchByRunKeyRef.current[runKey] ?? { lastSeenChunkId: null, openedUrl: false, tail: '', openInPreview: false }; - urlWatchByRunKeyRef.current[runKey] = watch; - const action = displayActions.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; + const monitorRuns = () => { + const terminalSessions = useTerminalStore.getState().sessions; + const currentRuns = useTerminalStore.getState().projectActionRuns; + for (const [runKey, entry] of Object.entries(currentRuns)) { + const directoryState = terminalSessions.get(entry.directory); + const tab = directoryState?.tabs.find((item) => item.id === entry.tabId); + if (!tab || tab.terminalSessionId !== entry.sessionId) { + removeProjectActionRun(runKey); + continue; } - return chunk.id > watch.lastSeenChunkId; - }); - if (nextChunks.length === 0) { - continue; - } + const watch = urlWatchByRunKeyRef.current[runKey] ?? { lastSeenChunkId: null, openedUrl: false, tail: '', openInPreview: false }; + urlWatchByRunKeyRef.current[runKey] = watch; + const action = displayActions.find((item) => item.id === entry.actionId); + if (!action || !Array.isArray(tab.bufferChunks) || tab.bufferChunks.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; + const nextChunks = tab.bufferChunks.filter((chunk) => watch.lastSeenChunkId === null || chunk.id > watch.lastSeenChunkId); + if (nextChunks.length === 0) continue; - watch.lastSeenChunkId = lastChunkId; - watch.tail = textForScan.slice(-512); + 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; - if (maybeUrl) { - watch.openedUrl = true; - if (watch.openInPreview) { - const run = projectActionRuns[runKey]; - if (run) { - setTabPreviewUrl(run.directory, run.tabId, maybeUrl, { locked: false, autoOpened: false }); - if (run.status === 'waiting-for-preview') { - updateProjectActionRunStatus(runKey, 'running'); + watch.lastSeenChunkId = lastChunkId; + watch.tail = textForScan.slice(-512); + + if (maybeUrl) { + watch.openedUrl = true; + if (watch.openInPreview) { + const run = currentRuns[runKey]; + if (run) { + setTabPreviewUrl(run.directory, run.tabId, maybeUrl, { locked: false, autoOpened: false }); + if (run.status === 'waiting-for-preview') updateProjectActionRunStatus(runKey, 'running'); + window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[runKey]); + delete previewWaitTimeoutByRunKeyRef.current[runKey]; + openContextPreview(run.directory, maybeUrl); } - window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[runKey]); - delete previewWaitTimeoutByRunKeyRef.current[runKey]; - openContextPreview(run.directory, maybeUrl); + } else { + void openExternal(maybeUrl); + toast.success(t('projectActions.toast.openedUrlFromOutput')); } - } else { - void openExternal(maybeUrl); - toast.success(t('projectActions.toast.openedUrlFromOutput')); + } + urlWatchByRunKeyRef.current[runKey] = watch; + } + + for (const runKey of Object.keys(urlWatchByRunKeyRef.current)) { + if (!currentRuns[runKey]) { + delete urlWatchByRunKeyRef.current[runKey]; + window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[runKey]); + delete previewWaitTimeoutByRunKeyRef.current[runKey]; } } - urlWatchByRunKeyRef.current[runKey] = watch; - } + }; - for (const runKey of Object.keys(urlWatchByRunKeyRef.current)) { - if (!projectActionRuns[runKey]) { - delete urlWatchByRunKeyRef.current[runKey]; - window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[runKey]); - delete previewWaitTimeoutByRunKeyRef.current[runKey]; - } - } - - }, [displayActions, openContextPreview, openExternal, projectActionRuns, setTabPreviewUrl, t, terminalSessions, updateProjectActionRunStatus]); + monitorRuns(); + return useTerminalStore.subscribe((state, previousState) => { + if (state.sessions !== previousState.sessions) monitorRuns(); + }); + }, [displayActions, openContextPreview, openExternal, projectActionRuns, removeProjectActionRun, setTabPreviewUrl, t, updateProjectActionRunStatus]); const getOrCreateActionTab = React.useCallback(async (action: OpenChamberProjectAction, options: { revealTerminal?: boolean } = {}) => { if (!normalizedDirectory) { @@ -408,8 +393,8 @@ export const ProjectActionsButton = ({ setTabLabel(normalizedDirectory, tabId, `Action: ${action.name}`); setTabIconKey(normalizedDirectory, tabId, action.icon || 'play'); + setActiveTab(normalizedDirectory, tabId); if (options.revealTerminal !== false) { - setActiveTab(normalizedDirectory, tabId); setBottomTerminalOpen(true); setActiveMainTab('terminal'); } @@ -447,6 +432,8 @@ export const ProjectActionsButton = ({ if (existingRun && existingRun.status === 'running') { return; } + if (startingRunKeysRef.current.has(runKey)) return; + startingRunKeysRef.current.add(runKey); try { const discovered = action.id === AUTO_DISCOVER_ACTION_ID @@ -471,16 +458,23 @@ export const ProjectActionsButton = ({ : action; const hasCustomOpenUrl = discovered.autoOpenUrl === true && (discovered.openUrl || '').trim().length > 0; - const { key, tabId, sessionId } = await getOrCreateActionTab(discovered, { revealTerminal: !hasCustomOpenUrl && action.id !== AUTO_DISCOVER_ACTION_ID }); + const revealTerminal = !hasCustomOpenUrl && action.id !== AUTO_DISCOVER_ACTION_ID; + const { key, tabId, sessionId } = await getOrCreateActionTab(discovered, { revealTerminal }); let activeSessionId = sessionId; - let createdSession = false; if (!activeSessionId) { setConnecting(normalizedDirectory, tabId, true); try { - const created = await terminal.createSession({ cwd: normalizedDirectory }); + const created = await terminal.createSession({ + cwd: normalizedDirectory, + sessionId: tabId, + shell: terminalShell, + loginShell: terminalLoginShell, + themeMode: currentTheme.metadata.variant === 'light' ? 'light' : 'dark', + terminalBackground: currentTheme.colors.surface.background, + terminalForeground: currentTheme.colors.syntax.base.foreground, + }); activeSessionId = created.sessionId; - createdSession = true; setTabSessionId(normalizedDirectory, tabId, activeSessionId); } finally { setConnecting(normalizedDirectory, tabId, false); @@ -491,18 +485,17 @@ export const ProjectActionsButton = ({ throw new Error(t('projectActions.error.failedToCreateTerminalSession')); } - if (createdSession) { - await sleep(350); - } - - if (discovered.id === AUTO_DISCOVER_ACTION_ID) { - streamCleanupByRunKeyRef.current[key]?.(); - setConnecting(normalizedDirectory, tabId, true); - streamCleanupByRunKeyRef.current[key] = connectTerminalStream( + streamCleanupByRunKeyRef.current[key]?.(); + setConnecting(normalizedDirectory, tabId, true); + const subscription = terminal.connect( activeSessionId, - (event) => { + { onEvent: (event) => { + if (event.type === 'snapshot') { + useTerminalStore.getState().replaceBuffer(normalizedDirectory, tabId, event.data ?? '', event.sequence ?? 0); + useTerminalStore.getState().setConnecting(normalizedDirectory, tabId, false); + } if (event.type === 'data' && typeof event.data === 'string' && event.data.length > 0) { - useTerminalStore.getState().appendToBuffer(normalizedDirectory, tabId, event.data); + useTerminalStore.getState().appendToBuffer(normalizedDirectory, tabId, event.data, event.sequence, event.replayData); } if (event.type === 'exit') { useTerminalStore.getState().setTabLifecycle(normalizedDirectory, tabId, 'exited'); @@ -514,13 +507,16 @@ export const ProjectActionsButton = ({ window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[key]); delete previewWaitTimeoutByRunKeyRef.current[key]; } - }, - () => { + }, onError: (_error, fatal) => { useTerminalStore.getState().setConnecting(normalizedDirectory, tabId, false); - }, - { maxRetries: 60, initialRetryDelay: 250, maxRetryDelay: 2000, connectionTimeout: 5000 }, + if (fatal) { + useTerminalStore.getState().setTabLifecycle(normalizedDirectory, tabId, 'exited'); + useTerminalStore.getState().setTabSessionId(normalizedDirectory, tabId, null); + useTerminalStore.getState().removeProjectActionRun(key); + } + } }, ); - } + streamCleanupByRunKeyRef.current[key] = subscription.close; const hasDesktopForwardSelection = discovered.autoOpenUrl === true && isDesktopShellApp @@ -542,11 +538,27 @@ export const ProjectActionsButton = ({ delete previewWaitTimeoutByRunKeyRef.current[key]; if (discovered.id === AUTO_DISCOVER_ACTION_ID && !manualOpenUrl) { previewWaitTimeoutByRunKeyRef.current[key] = window.setTimeout(() => { - useTerminalStore.getState().updateProjectActionRunStatus(key, 'running'); + const store = useTerminalStore.getState(); + const run = store.projectActionRuns[key]; + store.updateProjectActionRunStatus(key, 'running'); + if (run) { + store.setActiveTab(run.directory, run.tabId); + useUIStore.getState().setBottomTerminalOpen(true); + } delete previewWaitTimeoutByRunKeyRef.current[key]; }, AUTO_DISCOVER_PREVIEW_WAIT_TIMEOUT_MS); } + urlWatchByRunKeyRef.current[key] = { + lastSeenChunkId: null, + openedUrl: Boolean(desktopForwardUrl) || Boolean(manualOpenUrl) || hasCustomOpenUrl, + tail: '', + openInPreview: discovered.id === AUTO_DISCOVER_ACTION_ID, + }; + + const normalizedCommand = stripControlChars(discovered.command.trim().replace(/\r\n|\r/g, '\n')); + await terminal.sendInput(activeSessionId, `${normalizedCommand}\r`); + if (desktopForwardUrl) { setTabPreviewUrl(normalizedDirectory, tabId, null, { locked: true }); void openExternal(desktopForwardUrl); @@ -565,15 +577,6 @@ export const ProjectActionsButton = ({ setTabPreviewUrl(normalizedDirectory, tabId, null, { locked: false, autoOpened: false }); } - urlWatchByRunKeyRef.current[key] = { - lastSeenChunkId: null, - openedUrl: Boolean(desktopForwardUrl) || Boolean(manualOpenUrl) || hasCustomOpenUrl, - tail: '', - openInPreview: discovered.id === AUTO_DISCOVER_ACTION_ID, - }; - - const normalizedCommand = stripControlChars(discovered.command.trim().replace(/\r\n|\r/g, '\n')); - await terminal.sendInput(activeSessionId, `${normalizedCommand}\r`); } catch (error) { removeProjectActionRun(runKey); delete urlWatchByRunKeyRef.current[runKey]; @@ -582,14 +585,21 @@ export const ProjectActionsButton = ({ window.clearTimeout(previewWaitTimeoutByRunKeyRef.current[runKey]); delete previewWaitTimeoutByRunKeyRef.current[runKey]; toast.error(error instanceof Error ? error.message : t('projectActions.error.failedToRunAction')); + } finally { + startingRunKeysRef.current.delete(runKey); } }, [ + currentTheme.colors.surface.background, + currentTheme.colors.syntax.base.foreground, + currentTheme.metadata.variant, desktopSshInstances, getOrCreateActionTab, allowMobile, isMobile, isDesktopShellApp, normalizedDirectory, + terminalLoginShell, + terminalShell, openExternal, openContextPreview, projectActionRuns, @@ -613,22 +623,22 @@ export const ProjectActionsButton = ({ updateProjectActionRunStatus(runKey, 'stopping'); + const exitPromise = waitForTerminalExit(terminal, activeRun.sessionId, 1000); + try { await terminal.sendInput(activeRun.sessionId, '\x03'); } catch { // noop } - await new Promise((resolve) => { - window.setTimeout(resolve, 1000); - }); + const exitObserved = await exitPromise; const afterTab = useTerminalStore.getState().getDirectoryState(activeRun.directory)?.tabs .find((entry) => entry.id === activeRun.tabId); const sessionStillSame = afterTab?.terminalSessionId === activeRun.sessionId; - if (sessionStillSame) { + if (sessionStillSame && !exitObserved) { if (typeof terminal.forceKill === 'function') { try { await terminal.forceKill({ sessionId: activeRun.sessionId }); @@ -699,6 +709,13 @@ export const ProjectActionsButton = ({ setSettingsDialogOpen(true); }, [setSettingsDialogOpen, setSettingsPage, setSettingsProjectsSelectedId, stableProjectRef?.id]); + const previewAction = selectedAction ?? displayActions[0] ?? null; + const previewRun = previewAction ? projectActionRuns[toProjectActionRunKey(normalizedDirectory, previewAction.id)] : null; + const selectedRunPreviewUrl = useTerminalStore((state) => { + if (!previewRun) return null; + return state.sessions.get(previewRun.directory)?.tabs.find((tab) => tab.id === previewRun.tabId)?.previewUrl ?? null; + }); + if (runtime.isVSCode || (!allowMobile && isMobile) || !stableProjectRef || !normalizedDirectory) { return null; } @@ -716,9 +733,6 @@ export const ProjectActionsButton = ({ const selectedRunning = projectActionRuns[selectedRunKey]; const isStoppingSelected = selectedRunning?.status === 'stopping'; const isWaitingForSelectedPreview = selectedRunning?.status === 'waiting-for-preview'; - const selectedRunPreviewUrl = selectedRunning - ? terminalSessions.get(selectedRunning.directory)?.tabs.find((tab) => tab.id === selectedRunning.tabId)?.previewUrl ?? null - : null; const showSelectedPreviewButton = Boolean(selectedRunning && selectedRunPreviewUrl); const handleOpenSelectedPreview = () => { if (!selectedRunning || !selectedRunPreviewUrl) { diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx index 0027e549..603fef9e 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx @@ -139,6 +139,8 @@ const VisualSectionContent: React.FC = () => { 'inputBarOffset', 'expandedEditorToolbar', ...(!isVSCode ? ['terminalQuickKeys' as const] : []), + ...(!isVSCode ? ['terminalShell' as const] : []), + ...(!isVSCode ? ['terminalLoginShell' as const] : []), 'reportUsage', ]} />; }; diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx index 61390212..9e99e849 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx @@ -33,6 +33,10 @@ import { setDirectoryShowHidden, useDirectoryShowHidden, } from '@/lib/directoryShowHidden'; +import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import type { TerminalShellOption } from '@/lib/api/types'; +import { isTerminalShell } from '@/lib/terminalShell'; +import { subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch'; interface Option { id: T; @@ -245,7 +249,7 @@ const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain' return mode === 'markdown' ? 'markdown' : 'plain'; }; -type VisibleSetting = 'sessionAssist' | 'sessionGoal' | 'theme' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'editorFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'promptNavigatorEnabled' | 'wideChatLayout' | 'codeBlockLineWrap' | 'splitAssistantMessageActions' | 'subagentReadOnlyBanner' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage' | 'expandedEditorToolbar'; +type VisibleSetting = 'sessionAssist' | 'sessionGoal' | 'theme' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'terminalShell' | 'terminalLoginShell' | 'editorFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'promptNavigatorEnabled' | 'wideChatLayout' | 'codeBlockLineWrap' | 'splitAssistantMessageActions' | 'subagentReadOnlyBanner' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage' | 'expandedEditorToolbar'; interface OpenChamberVisualSettingsProps { /** Which settings to show. If undefined, shows all. */ @@ -256,6 +260,7 @@ export const OpenChamberVisualSettings: React.FC const { locale, locales, setLocale, label, t } = useI18n(); const tUnsafe = React.useCallback((key: string) => t(key as Parameters[0]), [t]); const { isMobile } = useDeviceInfo(); + const { terminal } = useRuntimeAPIs(); const { browserTab } = usePwaDetection(); const directoryShowHidden = useDirectoryShowHidden(); const showReasoningTraces = useUIStore(state => state.showReasoningTraces); @@ -297,6 +302,10 @@ export const OpenChamberVisualSettings: React.FC const setFontSize = useUIStore(state => state.setFontSize); const terminalFontSize = useUIStore(state => state.terminalFontSize); const setTerminalFontSize = useUIStore(state => state.setTerminalFontSize); + const terminalShell = useUIStore(state => state.terminalShell); + const setTerminalShell = useUIStore(state => state.setTerminalShell); + const terminalLoginShells = useUIStore(state => state.terminalLoginShells); + const setTerminalLoginShells = useUIStore(state => state.setTerminalLoginShells); const editorFontSize = useUIStore(state => state.editorFontSize); const setEditorFontSize = useUIStore(state => state.setEditorFontSize); const uiFont = useUIStore(state => state.uiFont); @@ -571,7 +580,7 @@ export const OpenChamberVisualSettings: React.FC ? hasLocalizationSettings : (shouldShow('theme') || showMobileLayoutSetting || shouldShow('pwaInstallName') || shouldShow('pwaOrientation') || shouldShow('timeFormat') || shouldShow('weekStart')); const hasLayoutSettings = shouldShow('fontSize') || shouldShow('terminalFontSize') || shouldShow('editorFontSize') || shouldShow('spacing') || shouldShow('inputBarOffset'); - const hasNavigationSettings = (shouldShow('terminalQuickKeys') && !isMobile) || shouldShow('fileEditorKeymap') || shouldShow('expandedEditorToolbar'); + const hasNavigationSettings = (shouldShow('terminalQuickKeys') && !isMobile) || ((shouldShow('terminalShell') || shouldShow('terminalLoginShell')) && !isVSCode) || shouldShow('fileEditorKeymap') || shouldShow('expandedEditorToolbar'); const hasBehaviorSettings = shouldShow('mermaidRendering') || shouldShow('userMessageRendering') || shouldShow('chatRenderMode') @@ -597,6 +606,41 @@ export const OpenChamberVisualSettings: React.FC const showPwaInstallNameSetting = shouldShow('pwaInstallName') && isWebRuntime() && browserTab && !isDesktopShell() && !isVSCode; const showPwaOrientationSetting = shouldShow('pwaOrientation') && isWebRuntime() && !isDesktopShell() && !isVSCode; const showMobileKeyboardModeSetting = shouldShow('mobileKeyboardMode') && isWebRuntime() && !isDesktopShell() && !isVSCode && supportsMobileKeyboardResizeContent(); + const showTerminalShellSetting = (shouldShow('terminalShell') || shouldShow('terminalLoginShell')) && !isVSCode; + const [availableTerminalShells, setAvailableTerminalShells] = React.useState([]); + const [terminalShellRuntimeEpoch, setTerminalShellRuntimeEpoch] = React.useState(0); + React.useEffect(() => subscribeRuntimeEndpointChanged(() => { + setAvailableTerminalShells([]); + setTerminalShellRuntimeEpoch((epoch) => epoch + 1); + }), []); + React.useEffect(() => { + let cancelled = false; + if (!showTerminalShellSetting || !terminal.listShells) return; + void terminal.listShells() + .then((shells) => { + if (!cancelled) setAvailableTerminalShells(shells); + }) + .catch(() => { + if (!cancelled) setAvailableTerminalShells([]); + }); + return () => { + cancelled = true; + }; + }, [showTerminalShellSetting, terminal, terminalShellRuntimeEpoch]); + const terminalShellOptions = React.useMemo(() => { + const explicitShells = availableTerminalShells.filter((shell) => shell.id !== 'auto'); + if (terminalShell === 'auto' || explicitShells.some((shell) => shell.id === terminalShell)) { + return explicitShells; + } + return [{ id: terminalShell, name: terminalShell, supportsLogin: false }, ...explicitShells]; + }, [availableTerminalShells, terminalShell]); + const terminalShellSupportsLogin = availableTerminalShells.find((shell) => shell.id === terminalShell)?.supportsLogin === true; + const terminalLoginShellEnabled = terminalLoginShells.includes(terminalShell); + const setTerminalLoginShellEnabled = (enabled: boolean) => { + setTerminalLoginShells(enabled + ? [...terminalLoginShells.filter((shell) => shell !== terminalShell), terminalShell] + : terminalLoginShells.filter((shell) => shell !== terminalShell)); + }; const [mobileLayoutPreference, setMobileLayoutPreference] = React.useState(() => getStoredMobileLayoutPreference()); const [pwaInstallName, setPwaInstallName] = React.useState(''); const [pwaOrientation, setPwaOrientation] = React.useState<'system' | 'portrait' | 'landscape'>('system'); @@ -1260,8 +1304,8 @@ export const OpenChamberVisualSettings: React.FC