refactor(desktop): make Tauri thin shell running web sidecar (#273)

## What / Why
This PR finishes the desktop refactor: the Tauri app is now a thin shell that launches the web server as a sidecar and loads the UI from `http://127.0.0.1:<port>`. All real backend logic lives in `packages/web/server/index.js`; desktop Rust keeps only native integrations (menu/dialog/notifications/updater/deep-link + window chrome).
This unblocks:
- consistent behavior across web/desktop/vscode (single backend)
- simpler desktop maintenance (no duplicated Rust backend)
- host switching between Local + remote instances in desktop
- reliable cold-start behavior on slow machines (VSCode + desktop)
## Key changes
- Desktop sidecar runtime
  - build pipeline to bundle web dist + `openchamber-server` sidecar (`packages/desktop/scripts/build-sidecar.mjs`)
  - robust local port selection (prefer saved/default, fallback to random; persisted in `~/.config/openchamber/settings.json`)
  - improved PATH handling so the sidecar can locate `opencode` CLI (incl `~/.opencode/bin`, overrides, common bins)
  - disable native right-click context menu in production builds (dev keeps it)
- Desktop instance switcher (Tauri-only)
  - header button + modal to add/edit/delete remote hosts, set default, probe status/ping, switch back to Local escape hatch
  - auth gate includes host switcher so you can recover when a remote host is broken/auth-required
  - host list stored desktop-locally (not tied to the currently selected remote server)
- Notifications
  - decision logic moved server-side; desktop notifications emitted via sidecar stdout and shown natively by Tauri
  - prevent double-notifications on desktop Local origin (UI ignores SSE notification when native path is active)
  - restore macOS notification sound
- Updates
  - Tauri updater used only when viewing Local instance in desktop shell (avoid “remote web update” triggering desktop restart)
- Settings persistence & UX polish
  - persist model favorites/recents via `/api/config/settings` (works for web + desktop; not origin-dependent)
  - persist per-project sidebar collapse state in `projects[].sidebarCollapsed` via `/api/config/settings` (with debounce on toggles)
  - macOS header sizing/traffic-lights offsets fixed (marketing macOS major injected from desktop; MultiRun header aligned)
  - VSCode cold-start: keep retrying provider/agent loads after connection to avoid empty UI on slow machines
  - misc lint/type fixes + bun.lock sync
- Desktop bootstrap / resiliency
  - show onboarding screen when OpenCode CLI is missing (desktop Local origin), with retry hook to restart OpenCode after install
## Testing notes
- Desktop (macOS): switch Local <-> remote, set default host, verify auth gate recovery, native notifications (with sound), updater gated to Local
- Web: favorites/recents + per-project collapsed state persist across reload/restart
- VSCode: slow startup no longer results in missing providers/agents/models
This commit is contained in:
Bohdan Triapitsyn
2026-02-05 01:59:49 +02:00
committed by GitHub
parent b733f26aed
commit 83ffb1af34
130 changed files with 4230 additions and 23488 deletions
+47 -12
View File
@@ -16,6 +16,8 @@ import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
import { GitPollingProvider } from '@/hooks/useGitPolling';
import { useConfigStore } from '@/stores/useConfigStore';
import { hasModifier } from '@/lib/utils';
import { isDesktopLocalOriginActive, isDesktopShell, isTauriShell } from '@/lib/desktop';
import { OnboardingScreen } from '@/components/onboarding/OnboardingScreen';
import { useSessionStore } from '@/stores/useSessionStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { opencodeClient } from '@/lib/opencode/client';
@@ -25,8 +27,6 @@ import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay';
import { AboutDialog } from '@/components/ui/AboutDialog';
import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider';
import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { OnboardingScreen } from '@/components/onboarding/OnboardingScreen';
import { isCliAvailable } from '@/lib/desktop';
import { useUIStore } from '@/stores/useUIStore';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import type { RuntimeAPIs } from '@/lib/api/types';
@@ -53,17 +53,12 @@ function App({ apis }: AppProps) {
const [showMemoryDebug, setShowMemoryDebug] = React.useState(false);
const { uiFont, monoFont } = useFontPreferences();
const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus);
const [isDesktopRuntime, setIsDesktopRuntime] = React.useState<boolean>(() => apis.runtime.isDesktop);
const [isVSCodeRuntime, setIsVSCodeRuntime] = React.useState<boolean>(() => apis.runtime.isVSCode);
const [cliAvailable, setCliAvailable] = React.useState<boolean>(() => {
if (!apis.runtime.isDesktop) return true;
return isCliAvailable();
});
const [showCliOnboarding, setShowCliOnboarding] = React.useState(false);
React.useEffect(() => {
setIsDesktopRuntime(apis.runtime.isDesktop);
setIsVSCodeRuntime(apis.runtime.isVSCode);
}, [apis.runtime.isDesktop, apis.runtime.isVSCode]);
}, [apis.runtime.isVSCode]);
React.useEffect(() => {
registerRuntimeAPIs(apis);
@@ -175,6 +170,19 @@ function App({ apis }: AppProps) {
useMenuActions(handleToggleMemoryDebug);
const settingsAutoCreateWorktree = useConfigStore((state) => state.settingsAutoCreateWorktree);
React.useEffect(() => {
if (!isTauriShell()) {
return;
}
const tauri = (window as unknown as { __TAURI__?: { core?: { invoke?: (cmd: string, args?: Record<string, unknown>) => Promise<unknown> } } }).__TAURI__;
if (typeof tauri?.core?.invoke !== 'function') {
return;
}
void tauri.core.invoke('desktop_set_auto_worktree_menu', { enabled: settingsAutoCreateWorktree });
}, [settingsAutoCreateWorktree]);
useSessionStatusBootstrap();
@@ -199,15 +207,42 @@ function App({ apis }: AppProps) {
}
}, [error, clearError]);
React.useEffect(() => {
if (!isDesktopShell() || !isDesktopLocalOriginActive()) {
return;
}
let cancelled = false;
const run = async () => {
try {
const res = await fetch('/health', { method: 'GET' });
if (!res.ok) return;
const data = (await res.json().catch(() => null)) as null | { openCodeRunning?: unknown; lastOpenCodeError?: unknown };
if (!data || cancelled) return;
const openCodeRunning = data.openCodeRunning === true;
const err = typeof data.lastOpenCodeError === 'string' ? data.lastOpenCodeError : '';
const cliMissing = !openCodeRunning && /ENOENT|spawn\s+opencode|opencode(\.exe)?\s+not\s+found|not\s+found/i.test(err);
setShowCliOnboarding(cliMissing);
} catch {
// ignore
}
};
void run();
return () => {
cancelled = true;
};
}, []);
const handleCliAvailable = React.useCallback(() => {
setCliAvailable(true);
setShowCliOnboarding(false);
window.location.reload();
}, []);
if (isDesktopRuntime && !cliAvailable) {
if (showCliOnboarding) {
return (
<ErrorBoundary>
<div className={`h-full text-foreground bg-transparent`}>
<div className="h-full text-foreground bg-transparent">
<OnboardingScreen onCliAvailable={handleCliAvailable} />
</div>
</ErrorBoundary>
@@ -2,9 +2,10 @@ import React from 'react';
import { RiLockLine, RiLockUnlockLine, RiLoader4Line } from '@remixicon/react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { isDesktopRuntime, isVSCodeRuntime } from '@/lib/desktop';
import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
import { syncDesktopSettings, initializeAppearancePreferences } from '@/lib/persistence';
import { applyPersistedDirectoryPreferences } from '@/lib/directoryPersistence';
import { DesktopHostSwitcherInline } from '@/components/desktop/DesktopHostSwitcher';
const STATUS_CHECK_ENDPOINT = '/auth/session';
@@ -119,9 +120,9 @@ const clearTokenFromUrl = () => {
};
export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) => {
const desktopRuntime = React.useMemo(() => isDesktopRuntime(), []);
const vscodeRuntime = React.useMemo(() => isVSCodeRuntime(), []);
const skipAuth = desktopRuntime || vscodeRuntime;
const skipAuth = vscodeRuntime;
const showHostSwitcher = React.useMemo(() => isDesktopShell() && !vscodeRuntime, [vscodeRuntime]);
const [state, setState] = React.useState<GateState>(() => (skipAuth ? 'authenticated' : 'pending'));
const [password, setPassword] = React.useState('');
const [isSubmitting, setIsSubmitting] = React.useState(false);
@@ -349,6 +350,15 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({ children }) =>
</p>
)}
</form>
{showHostSwitcher && (
<div className="w-full">
<DesktopHostSwitcherInline />
<p className="mt-1 text-center typography-micro text-muted-foreground">
Use Local if remote is unreachable.
</p>
</div>
)}
</div>
</AuthShell>
);
@@ -30,7 +30,7 @@ export const ChatContainer: React.FC = () => {
isSyncing,
messageStreamStates,
trimToViewportWindow,
sessionActivityPhase,
sessionStatus,
newSessionDraft,
} = useSessionStore();
@@ -161,8 +161,8 @@ export const ChatContainer: React.FC = () => {
try {
await loadMessages(currentSessionId);
} finally {
const currentPhase = sessionActivityPhase?.get(currentSessionId) ?? 'idle';
const isActivePhase = currentPhase === 'busy' || currentPhase === 'cooldown';
const statusType = sessionStatus?.get(currentSessionId)?.type ?? 'idle';
const isActivePhase = statusType === 'busy' || statusType === 'retry';
// When pinned and active, scroll is already maintained automatically
const shouldSkipScroll = isActivePhase && isPinned;
@@ -179,7 +179,7 @@ export const ChatContainer: React.FC = () => {
};
void load();
}, [currentSessionId, isPinned, loadMessages, messages, scrollToBottom, sessionActivityPhase]);
}, [currentSessionId, isPinned, loadMessages, messages, scrollToBottom, sessionStatus]);
if (!currentSessionId && !draftOpen) {
return (
@@ -266,14 +266,7 @@ export const ChatContainer: React.FC = () => {
<ScrollShadow
className="absolute inset-0 overflow-y-auto overflow-x-hidden z-0 chat-scroll overlay-scrollbar-target"
ref={scrollRef}
style={{
contain: 'strict',
['--scroll-shadow-size' as string]: '48px',
// GPU acceleration hints for smoother scrolling
transform: 'translateZ(0)',
willChange: 'scroll-position',
backfaceVisibility: 'hidden',
}}
observeMutations={false}
data-scroll-shadow="true"
data-scrollbar="chat"
>
@@ -232,6 +232,9 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
const canAbort = working.isWorking;
// Keep a ref to handleSubmit so callbacks don't depend on it.
const handleSubmitRef = React.useRef<(e?: React.FormEvent) => Promise<void>>(async () => {});
// Add message to queue instead of sending
const handleQueueMessage = React.useCallback(() => {
if (!hasContent || !currentSessionId) return;
@@ -448,23 +451,21 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
}
};
handleSubmitRef.current = handleSubmit;
// Primary action for send button - respects queue mode setting
const handlePrimaryAction = React.useCallback(() => {
const canQueue = hasContent && currentSessionId && sessionPhase !== 'idle';
if (queueModeEnabled && canQueue) {
handleQueueMessage();
} else {
void handleSubmit();
void handleSubmitRef.current();
}
}, [hasContent, currentSessionId, sessionPhase, queueModeEnabled, handleQueueMessage, handleSubmit]);
// Keep a ref to handleSubmit for auto-send effect
const handleSubmitRef = React.useRef(handleSubmit);
handleSubmitRef.current = handleSubmit;
}, [hasContent, currentSessionId, sessionPhase, queueModeEnabled, handleQueueMessage]);
// Auto-send queued messages when session becomes idle (but not after abort)
React.useEffect(() => {
const wasWorking = prevSessionPhaseRef.current === 'busy' || prevSessionPhaseRef.current === 'cooldown';
const wasWorking = prevSessionPhaseRef.current === 'busy' || prevSessionPhaseRef.current === 'retry';
const isNowIdle = sessionPhase === 'idle';
// Check if session was recently aborted (within last 2 seconds)
@@ -1483,8 +1484,6 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
isWaitingForPermission={working.isWaitingForPermission}
wasAborted={working.wasAborted}
abortActive={working.abortActive}
completionId={working.lastCompletionId}
isComplete={working.isComplete}
showAbortStatus={showAbortStatus}
/>
</div>
@@ -38,7 +38,8 @@ import { ProviderLogo } from '@/components/ui/ProviderLogo';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { Switch } from '@/components/ui/switch';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useIsDesktopRuntime, useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs';
import { useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs';
import { isDesktopShell } from '@/lib/desktop';
import { getAgentColor } from '@/lib/agentColors';
import { useDeviceInfo } from '@/lib/device';
import { getEditModeColors } from '@/lib/permissions/editModeColors';
@@ -346,7 +347,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
const { favoriteModelsList, recentModelsList } = useModelLists();
const { isMobile } = useDeviceInfo();
const isDesktopRuntime = useIsDesktopRuntime();
const isDesktop = React.useMemo(() => isDesktopShell(), []);
const isVSCodeRuntime = useIsVSCodeRuntime();
// Only use mobile panels on actual mobile devices, VSCode uses desktop dropdowns
const isCompact = isMobile;
@@ -2405,7 +2406,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
'model-controls__variant-label',
controlTextSize,
'font-medium min-w-0 truncate',
isDesktopRuntime ? 'max-w-[180px]' : undefined,
isDesktop ? 'max-w-[180px]' : undefined,
colorClass,
)}
>
@@ -2473,7 +2474,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
'model-controls__agent-label',
controlTextSize,
'font-medium min-w-0 truncate',
isDesktopRuntime ? 'max-w-[220px]' : undefined
isDesktop ? 'max-w-[220px]' : undefined
)}
style={uiAgentName ? { color: `var(${getAgentColor(uiAgentName).var})` } : undefined}
>
+9 -17
View File
@@ -53,8 +53,6 @@ interface StatusRowProps {
isWaitingForPermission?: boolean;
wasAborted?: boolean;
abortActive?: boolean;
completionId?: string | null;
isComplete?: boolean;
// Abort state (for mobile/vscode)
showAbort?: boolean;
onAbort?: () => void;
@@ -69,8 +67,6 @@ export const StatusRow: React.FC<StatusRowProps> = ({
isWaitingForPermission,
wasAborted,
abortActive,
completionId,
isComplete,
showAbort,
onAbort,
showAbortStatus,
@@ -124,17 +120,16 @@ export const StatusRow: React.FC<StatusRowProps> = ({
const hasActiveTodos = visibleTodos.some((t) => t.status === "in_progress" || t.status === "pending");
// Original logic from ChatInput
const shouldRenderPlaceholder = !showAbortStatus && (wasAborted || !abortActive);
// Track if placeholder is showing result (done/aborted) to keep StatusRow mounted
const [placeholderShowingResult, setPlaceholderShowingResult] = React.useState(false);
// Keep StatusRow rendered while:
// - isWorking (active session)
// - isComplete (showing "Done" result)
// - wasAborted (showing "Aborted" result)
// - placeholderShowingResult (placeholder still displaying result)
// - hasActiveTodos or showAbortStatus
const hasContent = isWorking || isComplete || wasAborted || placeholderShowingResult || hasActiveTodos || showAbortStatus;
// - wasAborted / showAbortStatus
// - hasActiveTodos
const hasContent =
isWorking ||
Boolean(wasAborted) ||
Boolean(showAbortStatus) ||
hasActiveTodos;
// Close popover when clicking outside
const popoverRef = React.useRef<HTMLDivElement>(null);
@@ -212,13 +207,10 @@ export const StatusRow: React.FC<StatusRowProps> = ({
) : shouldRenderPlaceholder ? (
<WorkingPlaceholder
key={currentSessionId ?? "no-session"}
isWorking={isWorking}
statusText={statusText}
isGenericStatus={isGenericStatus}
isWaitingForPermission={isWaitingForPermission}
wasAborted={wasAborted}
completionId={completionId ?? null}
isComplete={isComplete}
onResultVisibilityChange={setPlaceholderShowingResult}
/>
) : null}
</div>
@@ -1,254 +1,132 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import React from 'react';
import { Text } from '@/components/ui/text';
interface WorkingPlaceholderProps {
statusText: string | null;
isGenericStatus?: boolean;
isWaitingForPermission?: boolean;
wasAborted?: boolean;
completionId?: string | null;
isComplete?: boolean;
onResultVisibilityChange?: (isShowingResult: boolean) => void;
isWorking: boolean;
statusText: string | null;
isGenericStatus?: boolean;
isWaitingForPermission?: boolean;
}
const STATUS_DISPLAY_TIME = 1500; // Minimum time to show each status
const DONE_DISPLAY_TIME = 2000; // Time to show Done/Aborted status
type PlaceholderState = 'idle' | 'showing' | 'done' | 'aborted';
const STATUS_DISPLAY_TIME_MS = 1200;
export function WorkingPlaceholder({
isWorking,
statusText,
isGenericStatus,
isWaitingForPermission,
}: WorkingPlaceholderProps) {
const [displayedText, setDisplayedText] = React.useState<string | null>(null);
const [displayedPermission, setDisplayedPermission] = React.useState<boolean>(false);
const statusShownAtRef = React.useRef<number>(0);
const queuedStatusRef = React.useRef<{ text: string; permission: boolean } | null>(null);
const processQueueTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const clearTimers = React.useCallback(() => {
if (processQueueTimerRef.current) {
clearTimeout(processQueueTimerRef.current);
processQueueTimerRef.current = null;
}
}, []);
const showStatus = React.useCallback((text: string, permission: boolean) => {
clearTimers();
queuedStatusRef.current = null;
setDisplayedText(text);
setDisplayedPermission(permission);
statusShownAtRef.current = Date.now();
}, [clearTimers]);
const scheduleQueueProcess = React.useCallback(() => {
if (processQueueTimerRef.current) return;
const elapsed = Date.now() - statusShownAtRef.current;
const remaining = Math.max(0, STATUS_DISPLAY_TIME_MS - elapsed);
processQueueTimerRef.current = setTimeout(() => {
processQueueTimerRef.current = null;
const queued = queuedStatusRef.current;
if (queued) {
showStatus(queued.text, queued.permission);
}
}, remaining);
}, [showStatus]);
React.useEffect(() => {
if (!isWorking) {
clearTimers();
queuedStatusRef.current = null;
setDisplayedText(null);
setDisplayedPermission(false);
return;
}
const incomingText = isWaitingForPermission ? 'waiting for permission' : statusText;
const incomingPermission = Boolean(isWaitingForPermission);
const incomingGeneric = Boolean(isGenericStatus) && !incomingPermission;
if (!incomingText) {
return;
}
if (!displayedText) {
showStatus(incomingText, incomingPermission);
return;
}
if (incomingText === displayedText && incomingPermission === displayedPermission) {
return;
}
// Ignore generic churn.
if (incomingGeneric) {
return;
}
const elapsed = Date.now() - statusShownAtRef.current;
if (elapsed >= STATUS_DISPLAY_TIME_MS) {
showStatus(incomingText, incomingPermission);
return;
}
queuedStatusRef.current = { text: incomingText, permission: incomingPermission };
scheduleQueueProcess();
}, [
isWorking,
statusText,
isGenericStatus,
isWaitingForPermission,
wasAborted,
completionId,
isComplete,
onResultVisibilityChange,
}: WorkingPlaceholderProps) {
// Internal state machine
const [state, setState] = useState<PlaceholderState>('idle');
const [displayedText, setDisplayedText] = useState<string | null>(null);
const [displayedPermission, setDisplayedPermission] = useState<boolean>(false);
displayedText,
displayedPermission,
clearTimers,
showStatus,
scheduleQueueProcess,
]);
// Refs for timing
const statusShownAtRef = useRef<number>(0);
const queuedStatusRef = useRef<{ text: string; permission: boolean } | null>(null);
const processQueueTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const doneTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastCompletionIdRef = useRef<string | null>(null);
// Track if we've ever shown activity in this turn
const hasShownActivityRef = useRef<boolean>(false);
// Track the previous isComplete value to detect edges
const prevIsCompleteRef = useRef<boolean>(false);
const prevWasAbortedRef = useRef<boolean>(false);
React.useEffect(() => () => clearTimers(), [clearTimers]);
// Clear all timers
const clearTimers = useCallback(() => {
if (processQueueTimerRef.current) {
clearTimeout(processQueueTimerRef.current);
processQueueTimerRef.current = null;
}
if (doneTimerRef.current) {
clearTimeout(doneTimerRef.current);
doneTimerRef.current = null;
}
}, []);
if (!isWorking || !displayedText) {
return null;
}
// Show a status immediately
const showStatus = useCallback((text: string, permission: boolean) => {
clearTimers();
queuedStatusRef.current = null;
setDisplayedText(text);
setDisplayedPermission(permission);
setState('showing');
statusShownAtRef.current = Date.now();
hasShownActivityRef.current = true;
}, [clearTimers]);
const label = displayedText.charAt(0).toUpperCase() + displayedText.slice(1);
const displayText = `${label}...`;
// Schedule processing of queued status
const scheduleQueueProcess = useCallback(() => {
if (processQueueTimerRef.current) return; // Already scheduled
const elapsed = Date.now() - statusShownAtRef.current;
const remaining = Math.max(0, STATUS_DISPLAY_TIME - elapsed);
processQueueTimerRef.current = setTimeout(() => {
processQueueTimerRef.current = null;
const queued = queuedStatusRef.current;
if (queued) {
showStatus(queued.text, queued.permission);
}
// If nothing queued, keep showing current status
}, remaining);
}, [showStatus]);
// Show done/aborted result
const showResult = useCallback((result: 'done' | 'aborted') => {
clearTimers();
queuedStatusRef.current = null;
// Only show result if we had activity
if (!hasShownActivityRef.current) {
setState('idle');
setDisplayedText(null);
onResultVisibilityChange?.(false);
return;
}
// Skip duplicate completion for same completionId
if (result === 'done' && completionId && lastCompletionIdRef.current === completionId) {
setState('idle');
setDisplayedText(null);
hasShownActivityRef.current = false;
onResultVisibilityChange?.(false);
return;
}
if (result === 'done' && completionId) {
lastCompletionIdRef.current = completionId;
}
setState(result);
setDisplayedText(null);
onResultVisibilityChange?.(true);
// Auto-hide after DONE_DISPLAY_TIME
doneTimerRef.current = setTimeout(() => {
doneTimerRef.current = null;
setState('idle');
hasShownActivityRef.current = false;
onResultVisibilityChange?.(false);
}, DONE_DISPLAY_TIME);
}, [clearTimers, completionId, onResultVisibilityChange]);
// Main effect: handle prop changes
useEffect(() => {
// Detect abort edge (false -> true)
if (wasAborted && !prevWasAbortedRef.current) {
prevWasAbortedRef.current = true;
showResult('aborted');
return;
}
prevWasAbortedRef.current = !!wasAborted;
// Detect completion edge (false -> true)
if (isComplete && !prevIsCompleteRef.current) {
prevIsCompleteRef.current = true;
showResult('done');
return;
}
// Reset edge detection when isComplete goes back to false
if (!isComplete && prevIsCompleteRef.current) {
prevIsCompleteRef.current = false;
}
// If we're showing done/aborted, don't process new status
if (state === 'done' || state === 'aborted') {
return;
}
// Handle new status text
if (statusText) {
const now = Date.now();
const elapsed = now - statusShownAtRef.current;
if (state === 'idle' || !displayedText) {
// Not showing anything - show immediately (generic OK at turn start)
showStatus(statusText, !!isWaitingForPermission);
} else if (statusText !== displayedText || !!isWaitingForPermission !== displayedPermission) {
// Already showing something - ignore generic statuses
if (isGenericStatus) {
return;
}
// Different specific status
if (elapsed >= STATUS_DISPLAY_TIME) {
// Minimum time passed - show immediately
showStatus(statusText, !!isWaitingForPermission);
} else {
// Queue the latest (overwrites previous queued)
queuedStatusRef.current = { text: statusText, permission: !!isWaitingForPermission };
scheduleQueueProcess();
}
}
// Same status - keep showing
}
// IMPORTANT: When statusText becomes null, we do NOT clear the display
// Only done/abort signals clear the display
}, [statusText, isWaitingForPermission, wasAborted, isComplete, state, displayedText, displayedPermission, showStatus, showResult, scheduleQueueProcess, isGenericStatus]);
// Cleanup on unmount
useEffect(() => {
return () => clearTimers();
}, [clearTimers]);
// Handle tab visibility changes
useEffect(() => {
const handleVisibilityChange = () => {
if (typeof document === 'undefined') return;
if (document.visibilityState !== 'visible') return;
// If showing done/aborted when user returns, hide it
if (state === 'done' || state === 'aborted') {
clearTimers();
setState('idle');
setDisplayedText(null);
hasShownActivityRef.current = false;
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
}, [state, clearTimers]);
// Render nothing if idle with no text
if (state === 'idle' && !displayedText) {
return null;
}
// Determine what to show
let label: string;
let showEllipsis = true;
if (state === 'done') {
label = 'Done';
showEllipsis = false;
} else if (state === 'aborted') {
label = 'Aborted';
showEllipsis = false;
} else if (displayedText) {
label = displayedText.charAt(0).toUpperCase() + displayedText.slice(1);
} else {
label = 'Working';
}
const displayText = showEllipsis ? `${label}...` : label;
const isVisible = state !== 'idle';
return (
<div
className={`flex h-full items-center text-muted-foreground pl-[2ch] transition-opacity duration-200 ${isVisible ? 'opacity-100' : 'opacity-0'}`}
role="status"
aria-live={displayedPermission ? 'assertive' : 'polite'}
aria-label={label}
data-waiting={displayedPermission ? 'true' : undefined}
>
<span className="flex items-center gap-1.5">
{state === 'done' ? (
<Text variant="hover-enter" className="typography-ui-header">
Done
</Text>
) : state === 'aborted' ? (
<Text variant="hover-enter" className="typography-ui-header text-status-error">
Aborted
</Text>
) : (
<Text variant="shine" className="typography-ui-header">
{displayText}
</Text>
)}
</span>
</div>
);
return (
<div
className={
'flex h-full items-center text-muted-foreground pl-[2ch]'
}
role="status"
aria-live={displayedPermission ? 'assertive' : 'polite'}
aria-label={label}
data-waiting={displayedPermission ? 'true' : undefined}
>
<span className="flex items-center gap-1.5">
<Text variant="shine" className="typography-ui-header">
{displayText}
</Text>
</span>
</div>
);
}
@@ -0,0 +1,704 @@
import * as React from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
RiCheckLine,
RiCloudOffLine,
RiEarthLine,
RiLoader4Line,
RiMore2Line,
RiPencilLine,
RiRefreshLine,
RiServerLine,
RiShieldKeyholeLine,
RiStarFill,
RiStarLine,
RiDeleteBinLine,
} from '@remixicon/react';
import { cn } from '@/lib/utils';
import { isTauriShell, isDesktopShell } from '@/lib/desktop';
import {
desktopHostProbe,
desktopHostsGet,
desktopHostsSet,
type DesktopHost,
type HostProbeResult,
} from '@/lib/desktopHosts';
const LOCAL_HOST_ID = 'local';
type HostStatus = {
status: HostProbeResult['status'];
latencyMs: number;
};
const normalizeHostUrl = (raw: string): string | null => {
const trimmed = raw.trim();
if (!trimmed) return null;
try {
const url = new URL(trimmed);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return null;
}
return url.origin;
} catch {
// Tauri/WebKit edge: accept origin without trailing slash.
try {
const url = new URL(trimmed.endsWith('/') ? trimmed : `${trimmed}/`);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return null;
}
return url.origin;
} catch {
return null;
}
}
};
const toNavigationUrl = (origin: string): string => {
const trimmed = origin.trim();
if (!trimmed) return trimmed;
return trimmed.endsWith('/') ? trimmed : `${trimmed}/`;
};
const getLocalOrigin = (): string => {
if (typeof window === 'undefined') return '';
return window.__OPENCHAMBER_LOCAL_ORIGIN__ || window.location.origin;
};
const makeId = (): string => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `host-${Date.now()}-${Math.random().toString(16).slice(2)}`;
};
const statusDotClass = (status: HostProbeResult['status'] | null): string => {
if (status === 'ok') return 'bg-status-success';
if (status === 'auth') return 'bg-status-warning';
if (status === 'unreachable') return 'bg-status-error';
return 'bg-muted-foreground/40';
};
const statusLabel = (status: HostProbeResult['status'] | null): string => {
if (status === 'ok') return 'Connected';
if (status === 'auth') return 'Auth required';
if (status === 'unreachable') return 'Unreachable';
return 'Unknown';
};
const statusIcon = (status: HostProbeResult['status'] | null) => {
if (status === 'ok') return <RiCheckLine className="h-4 w-4" />;
if (status === 'auth') return <RiShieldKeyholeLine className="h-4 w-4" />;
if (status === 'unreachable') return <RiCloudOffLine className="h-4 w-4" />;
return <RiEarthLine className="h-4 w-4" />;
};
const buildLocalHost = (): DesktopHost => ({
id: LOCAL_HOST_ID,
label: 'Local',
url: getLocalOrigin(),
});
const resolveCurrentHost = (hosts: DesktopHost[]) => {
const currentOrigin = typeof window === 'undefined' ? '' : window.location.origin;
const localOrigin = getLocalOrigin();
const normalizedCurrent = normalizeHostUrl(currentOrigin) || currentOrigin;
const normalizedLocal = normalizeHostUrl(localOrigin) || localOrigin;
if (normalizedCurrent && normalizedLocal && normalizedCurrent === normalizedLocal) {
return { id: LOCAL_HOST_ID, label: 'Local', url: normalizedLocal };
}
const match = hosts.find((h) => {
const normalized = normalizeHostUrl(h.url);
return normalized && normalized === normalizedCurrent;
});
if (match) {
return { id: match.id, label: match.label, url: normalizeHostUrl(match.url) || match.url };
}
return {
id: 'custom',
label: normalizedCurrent || 'Instance',
url: normalizedCurrent,
};
};
type DesktopHostSwitcherDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
};
export function DesktopHostSwitcherDialog({ open, onOpenChange }: DesktopHostSwitcherDialogProps) {
const [configHosts, setConfigHosts] = React.useState<DesktopHost[]>([]);
const [defaultHostId, setDefaultHostId] = React.useState<string | null>(null);
const [statusById, setStatusById] = React.useState<Record<string, HostStatus>>({});
const [isLoading, setIsLoading] = React.useState(false);
const [isProbing, setIsProbing] = React.useState(false);
const [isSaving, setIsSaving] = React.useState(false);
const [error, setError] = React.useState<string>('');
const [editingId, setEditingId] = React.useState<string | null>(null);
const [editLabel, setEditLabel] = React.useState('');
const [editUrl, setEditUrl] = React.useState('');
const [newLabel, setNewLabel] = React.useState('');
const [newUrl, setNewUrl] = React.useState('');
const allHosts = React.useMemo(() => {
const local = buildLocalHost();
const normalizedRemote = configHosts.map((h) => ({
...h,
url: normalizeHostUrl(h.url) || h.url,
}));
return [local, ...normalizedRemote];
}, [configHosts]);
const current = React.useMemo(() => resolveCurrentHost(allHosts), [allHosts]);
const currentDefaultLabel = React.useMemo(() => {
const id = defaultHostId || LOCAL_HOST_ID;
return allHosts.find((h) => h.id === id)?.label || 'Local';
}, [allHosts, defaultHostId]);
const persist = React.useCallback(async (nextHosts: DesktopHost[], nextDefaultHostId: string | null) => {
if (!isTauriShell()) return;
setIsSaving(true);
setError('');
try {
// Persist only remote hosts; Local is derived.
const remote = nextHosts.filter((h) => h.id !== LOCAL_HOST_ID);
await desktopHostsSet({ hosts: remote, defaultHostId: nextDefaultHostId });
setConfigHosts(remote);
setDefaultHostId(nextDefaultHostId);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save');
} finally {
setIsSaving(false);
}
}, []);
const refresh = React.useCallback(async () => {
if (!isTauriShell()) return;
setIsLoading(true);
setError('');
try {
const cfg = await desktopHostsGet();
setConfigHosts(cfg.hosts || []);
setDefaultHostId(cfg.defaultHostId ?? null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load');
setConfigHosts([]);
setDefaultHostId(null);
} finally {
setIsLoading(false);
}
}, []);
const probeAll = React.useCallback(async (hosts: DesktopHost[]) => {
if (!isTauriShell()) return;
setIsProbing(true);
try {
const results = await Promise.all(
hosts.map(async (h) => {
const url = normalizeHostUrl(h.url);
if (!url) {
return [h.id, { status: 'unreachable' as const, latencyMs: 0 } satisfies HostStatus] as const;
}
const res = await desktopHostProbe(url).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
return [h.id, { status: res.status, latencyMs: res.latencyMs } satisfies HostStatus] as const;
})
);
const next: Record<string, HostStatus> = {};
for (const [id, val] of results) {
next[id] = val;
}
setStatusById(next);
} finally {
setIsProbing(false);
}
}, []);
React.useEffect(() => {
if (!open) {
setEditingId(null);
setEditLabel('');
setEditUrl('');
setNewLabel('');
setNewUrl('');
setError('');
return;
}
void refresh();
}, [open, refresh]);
React.useEffect(() => {
if (!open) return;
void probeAll(allHosts);
}, [open, allHosts, probeAll]);
const handleSwitch = React.useCallback((host: DesktopHost) => {
const origin = host.id === LOCAL_HOST_ID ? getLocalOrigin() : (normalizeHostUrl(host.url) || '');
if (!origin) return;
const target = toNavigationUrl(origin);
try {
window.location.assign(target);
} catch {
window.location.href = target;
}
}, []);
const beginEdit = React.useCallback((host: DesktopHost) => {
setEditingId(host.id);
setEditLabel(host.label);
setEditUrl(host.url);
setError('');
}, []);
const cancelEdit = React.useCallback(() => {
setEditingId(null);
setEditLabel('');
setEditUrl('');
}, []);
const commitEdit = React.useCallback(async () => {
if (!editingId) return;
if (editingId === LOCAL_HOST_ID) {
cancelEdit();
return;
}
const url = normalizeHostUrl(editUrl);
if (!url) {
setError('Invalid URL (must be http/https)');
return;
}
const label = (editLabel || url).trim();
const nextHosts = configHosts.map((h) => (h.id === editingId ? { ...h, label, url } : h));
await persist(nextHosts, defaultHostId);
cancelEdit();
}, [cancelEdit, configHosts, defaultHostId, editLabel, editUrl, editingId, persist]);
const addHost = React.useCallback(async () => {
const url = normalizeHostUrl(newUrl);
if (!url) {
setError('Invalid URL (must be http/https)');
return;
}
const label = (newLabel || url).trim();
const id = makeId();
const nextHosts = [{ id, label, url }, ...configHosts];
await persist(nextHosts, defaultHostId);
setNewLabel('');
setNewUrl('');
}, [configHosts, defaultHostId, newLabel, newUrl, persist]);
const deleteHost = React.useCallback(async (id: string) => {
if (id === LOCAL_HOST_ID) return;
const nextHosts = configHosts.filter((h) => h.id !== id);
const nextDefault = defaultHostId === id ? LOCAL_HOST_ID : defaultHostId;
await persist(nextHosts, nextDefault);
}, [configHosts, defaultHostId, persist]);
const setDefault = React.useCallback(async (id: string) => {
const next = id === LOCAL_HOST_ID ? LOCAL_HOST_ID : id;
await persist(configHosts, next);
}, [configHosts, persist]);
if (!isDesktopShell()) {
return null;
}
const tauriAvailable = isTauriShell();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[min(42rem,calc(100vw-2rem))] max-w-none max-h-[70vh] flex flex-col overflow-hidden gap-3">
<DialogHeader className="flex-shrink-0">
<DialogTitle className="flex items-center gap-2">
<RiServerLine className="h-5 w-5" />
Instance
</DialogTitle>
<DialogDescription>
Switch between Local and remote OpenChamber servers
</DialogDescription>
</DialogHeader>
<div className="flex items-center justify-between gap-2 flex-shrink-0">
<div className="flex items-center gap-2 min-w-0">
<span className="typography-meta text-muted-foreground">Current:</span>
<span className="typography-ui-label text-foreground truncate">{current.label}</span>
<span className="typography-meta text-muted-foreground">Current default:</span>
<span className="typography-ui-label text-foreground truncate">{currentDefaultLabel}</span>
</div>
<div className="flex items-center gap-1">
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => void probeAll(allHosts)}
disabled={!tauriAvailable || isLoading || isProbing}
>
<RiRefreshLine className={cn('h-4 w-4', isProbing && 'animate-spin')} />
Refresh
</Button>
</div>
</div>
{!tauriAvailable && (
<div className="flex-shrink-0 rounded-lg border border-border/50 bg-muted/20 p-3">
<div className="typography-meta text-muted-foreground">
Instance switcher is limited on this page. Use Local to recover.
</div>
</div>
)}
<div className="flex-1 min-h-0 overflow-y-auto">
<div className="space-y-1">
{isLoading ? (
<div className="px-2 py-2 text-muted-foreground text-sm">Loading</div>
) : (
allHosts.map((host) => {
const isLocal = host.id === LOCAL_HOST_ID;
const isActive = host.id === current.id;
const isDefault = (defaultHostId || LOCAL_HOST_ID) === host.id;
const status = statusById[host.id] || null;
const isEditing = editingId === host.id;
const effectiveUrl = isLocal ? getLocalOrigin() : (normalizeHostUrl(host.url) || host.url);
return (
<div
key={host.id}
className={cn(
'group flex items-center gap-2 px-2.5 py-2 rounded-md overflow-hidden',
isEditing ? 'bg-interactive-hover/20' : 'hover:bg-interactive-hover/30'
)}
>
<button
type="button"
className={cn(
'flex items-center gap-2 flex-1 min-w-0 text-left',
isEditing && 'pointer-events-none opacity-70'
)}
onClick={() => handleSwitch(host)}
aria-label={`Switch to ${host.label}`}
>
<span className={cn('h-2 w-2 rounded-full flex-shrink-0', statusDotClass(status?.status ?? null))} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 min-w-0">
<span className={cn('typography-ui-label truncate', isActive ? 'text-foreground' : 'text-foreground')}>
{host.label}
</span>
{isActive && (
<span className="typography-micro text-muted-foreground">Current</span>
)}
<span className="inline-flex items-center gap-1 typography-micro text-muted-foreground">
{statusIcon(status?.status ?? null)}
<span>
{statusLabel(status?.status ?? null)}
{status?.status === 'ok' && typeof status.latencyMs === 'number' ? ` · ${Math.max(0, Math.round(status.latencyMs))}ms ping` : ''}
</span>
</span>
</div>
<div className="typography-micro text-muted-foreground truncate font-mono">
{effectiveUrl}
</div>
</div>
</button>
<div className="flex items-center gap-2 flex-shrink-0">
<Tooltip delayDuration={700}>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
'h-8 w-8 rounded-md inline-flex items-center justify-center hover:bg-interactive-hover transition-colors',
isDefault
? 'text-primary hover:text-primary/80'
: 'text-muted-foreground/60 hover:text-primary/80',
)}
onClick={() => void setDefault(host.id)}
aria-label={isDefault ? 'Default instance' : 'Set as default'}
disabled={isSaving}
>
{isDefault ? <RiStarFill className="h-4 w-4" /> : <RiStarLine className="h-4 w-4" />}
</button>
</TooltipTrigger>
<TooltipContent sideOffset={6}>
{isDefault ? 'Default' : 'Set as default'}
</TooltipContent>
</Tooltip>
{!isLocal && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="h-8 w-8 rounded-md inline-flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-interactive-hover transition-colors"
aria-label="Instance actions"
disabled={isSaving}
onClick={(e) => e.stopPropagation()}
>
<RiMore2Line className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-fit min-w-28">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
beginEdit(host);
}}
disabled={isSaving}
>
<RiPencilLine className="h-4 w-4 mr-1" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
void deleteHost(host.id);
}}
className="text-destructive focus:text-destructive"
disabled={isSaving}
>
<RiDeleteBinLine className="h-4 w-4 mr-1" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
{isLocal && (
<div
className="h-8 w-8 opacity-0 pointer-events-none"
aria-hidden="true"
/>
)}
</div>
</div>
);
})
)}
</div>
</div>
{tauriAvailable && editingId && editingId !== LOCAL_HOST_ID && (
<div className="flex-shrink-0 rounded-lg border border-border/50 bg-muted/20 p-3">
<div className="flex items-center justify-between gap-2">
<div className="typography-ui-label font-medium text-foreground">Edit instance</div>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" onClick={cancelEdit} disabled={isSaving}>
Cancel
</Button>
<Button type="button" size="sm" onClick={() => void commitEdit()} disabled={isSaving}>
{isSaving ? <RiLoader4Line className="h-4 w-4 animate-spin" /> : null}
Save
</Button>
</div>
</div>
<div className="mt-2 grid grid-cols-1 gap-2 sm:grid-cols-2">
<Input
value={editLabel}
onChange={(e) => setEditLabel(e.target.value)}
placeholder="Label"
disabled={isSaving}
/>
<Input
value={editUrl}
onChange={(e) => setEditUrl(e.target.value)}
placeholder="https://host:port"
disabled={isSaving}
/>
</div>
</div>
)}
<div className="flex-shrink-0 rounded-lg border border-border/50 bg-muted/20 p-3">
<div className="flex items-center justify-between gap-2">
<div className="typography-ui-label font-medium text-foreground">Add instance</div>
<Button
type="button"
size="sm"
onClick={() => void addHost()}
disabled={!tauriAvailable || isSaving || !newUrl.trim()}
>
{isSaving ? <RiLoader4Line className="h-4 w-4 animate-spin" /> : null}
Add
</Button>
</div>
<div className="mt-2 grid grid-cols-1 gap-2 sm:grid-cols-2">
<Input
value={newLabel}
onChange={(e) => setNewLabel(e.target.value)}
placeholder="Label (optional)"
disabled={!tauriAvailable || isSaving}
/>
<Input
value={newUrl}
onChange={(e) => setNewUrl(e.target.value)}
placeholder="https://host:port"
disabled={!tauriAvailable || isSaving}
/>
</div>
</div>
{error && (
<div className="flex-shrink-0 typography-meta text-status-error">{error}</div>
)}
</DialogContent>
</Dialog>
);
}
type DesktopHostSwitcherButtonProps = {
headerIconButtonClass: string;
};
export function DesktopHostSwitcherButton({ headerIconButtonClass }: DesktopHostSwitcherButtonProps) {
const [open, setOpen] = React.useState(false);
const [label, setLabel] = React.useState('Local');
const [status, setStatus] = React.useState<HostProbeResult['status'] | null>(null);
React.useEffect(() => {
if (!isTauriShell()) return;
let cancelled = false;
const run = async () => {
try {
const cfg = await desktopHostsGet();
const local = buildLocalHost();
const all = [local, ...(cfg.hosts || [])];
const current = resolveCurrentHost(all);
if (cancelled) return;
setLabel(current.label || 'Instance');
const normalized = normalizeHostUrl(current.url);
if (!normalized) {
setStatus(null);
return;
}
const res = await desktopHostProbe(normalized).catch((): HostProbeResult => ({ status: 'unreachable', latencyMs: 0 }));
if (cancelled) return;
setStatus(res.status);
} catch {
if (!cancelled) {
setLabel('Instance');
setStatus(null);
}
}
};
void run();
const interval = window.setInterval(() => {
void run();
}, 10_000);
return () => {
cancelled = true;
window.clearInterval(interval);
};
}, []);
if (!isDesktopShell()) {
return null;
}
const isCurrentlyLocal = (() => {
try {
const current = normalizeHostUrl(window.location.origin);
const local = normalizeHostUrl(getLocalOrigin());
return Boolean(current && local && current === local);
} catch {
return false;
}
})();
// Fallback label when Tauri IPC is temporarily unavailable.
const fallbackLabel = (() => {
try {
const host = typeof window !== 'undefined' ? window.location.hostname : '';
return host ? host : 'Instance';
} catch {
return 'Instance';
}
})();
const effectiveLabel = isCurrentlyLocal
? 'Local'
: label === 'Local'
? fallbackLabel
: label;
return (
<>
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setOpen(true)}
aria-label="Switch instance"
data-oc-host-switcher
className={cn(headerIconButtonClass, 'relative w-auto px-3')}
>
<RiServerLine className="h-5 w-5" />
<span className="hidden sm:inline typography-ui-label font-medium text-muted-foreground truncate max-w-[11rem]">
{effectiveLabel}
</span>
<span
className={cn(
'pointer-events-none absolute top-1.5 right-1.5 h-1.5 w-1.5 rounded-full',
statusDotClass(status)
)}
aria-label="Instance status"
/>
</button>
</TooltipTrigger>
<TooltipContent>
<p>Instance</p>
</TooltipContent>
</Tooltip>
<DesktopHostSwitcherDialog open={open} onOpenChange={setOpen} />
</>
);
}
export function DesktopHostSwitcherInline() {
const [open, setOpen] = React.useState(false);
if (!isDesktopShell()) {
return null;
}
return (
<>
<Button
type="button"
variant="ghost"
size="sm"
data-oc-host-switcher
className="w-full justify-center"
onClick={() => setOpen(true)}
>
<RiServerLine className="h-4 w-4" />
Switch instance
</Button>
<DesktopHostSwitcherDialog open={open} onOpenChange={setOpen} />
</>
);
}
+12 -8
View File
@@ -34,6 +34,8 @@ import { UsageProgressBar } from '@/components/sections/usage/UsageProgressBar';
import { updateDesktopSettings } from '@/lib/persistence';
import type { UsageWindow } from '@/types';
import type { GitHubAuthStatus } from '@/lib/api/types';
import { DesktopHostSwitcherButton } from '@/components/desktop/DesktopHostSwitcher';
import { isDesktopShell } from '@/lib/desktop';
const formatTime = (timestamp: number | null) => {
if (!timestamp) return '-';
@@ -124,7 +126,7 @@ export const Header: React.FC = () => {
if (typeof window === 'undefined') {
return false;
}
return typeof (window as typeof window & { opencodeDesktop?: unknown }).opencodeDesktop !== 'undefined';
return isDesktopShell();
});
const isMacPlatform = React.useMemo(() => {
@@ -138,11 +140,12 @@ export const Header: React.FC = () => {
if (typeof window === 'undefined') {
return null;
}
// Use Tauri-provided version if available (accurate), otherwise fall back to UA parsing
const desktopApi = (window as typeof window & { opencodeDesktop?: { macosMajorVersion?: number | null } }).opencodeDesktop;
if (desktopApi?.macosMajorVersion != null) {
return desktopApi.macosMajorVersion;
const injected = (window as unknown as { __OPENCHAMBER_MACOS_MAJOR__?: unknown }).__OPENCHAMBER_MACOS_MAJOR__;
if (typeof injected === 'number' && Number.isFinite(injected) && injected > 0) {
return injected;
}
// Fallback: WebKit reports "Mac OS X 10_15_7" format where 10 is legacy prefix
if (typeof navigator === 'undefined') {
return null;
@@ -163,8 +166,7 @@ export const Header: React.FC = () => {
if (typeof window === 'undefined') {
return;
}
const detected = typeof (window as typeof window & { opencodeDesktop?: unknown }).opencodeDesktop !== 'undefined';
setIsDesktopApp(detected);
setIsDesktopApp(isDesktopShell());
}, []);
const currentModel = getCurrentModel();
@@ -625,6 +627,9 @@ export const Header: React.FC = () => {
<div className="flex-1" />
<div className="flex items-center gap-1 pr-3">
{isDesktopApp && (
<DesktopHostSwitcherButton headerIconButtonClass={headerIconButtonClass} />
)}
<Tooltip delayDuration={500}>
<TooltipTrigger asChild>
<button
@@ -770,7 +775,6 @@ export const Header: React.FC = () => {
))}
</DropdownMenuContent>
</DropdownMenu>
<McpDropdown headerIconButtonClass={headerIconButtonClass} />
<Tooltip delayDuration={500}>
@@ -4,6 +4,7 @@ import { Sidebar } from './Sidebar';
import { ErrorBoundary } from '../ui/ErrorBoundary';
import { CommandPalette } from '../ui/CommandPalette';
import { HelpDialog } from '../ui/HelpDialog';
import { OpenCodeStatusDialog } from '../ui/OpenCodeStatusDialog';
import { SessionSidebar } from '@/components/session/SessionSidebar';
import { SessionDialogs } from '@/components/session/SessionDialogs';
import { DiffWorkerProvider } from '@/contexts/DiffWorkerProvider';
@@ -313,6 +314,7 @@ export const MainLayout: React.FC = () => {
>
<CommandPalette />
<HelpDialog />
<OpenCodeStatusDialog />
<SessionDialogs />
{isMobile ? (
@@ -36,7 +36,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children })
if (typeof window === 'undefined') {
return false;
}
return typeof (window as typeof window & { opencodeDesktop?: unknown }).opencodeDesktop !== 'undefined';
return Boolean((window as unknown as { __TAURI__?: unknown }).__TAURI__);
});
@@ -45,8 +45,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children })
if (typeof window === 'undefined') {
return;
}
const detected = typeof (window as typeof window & { opencodeDesktop?: unknown }).opencodeDesktop !== 'undefined';
setIsDesktopApp(detected);
setIsDesktopApp(Boolean((window as unknown as { __TAURI__?: unknown }).__TAURI__));
}, []);
React.useEffect(() => {
@@ -55,9 +54,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ isOpen, isMobile, children })
}
const handleMenuUpdateCheck = () => {
const hasDesktopApi =
typeof (window as typeof window & { opencodeDesktop?: unknown }).opencodeDesktop !== 'undefined';
if (!hasDesktopApi) {
if (!(window as unknown as { __TAURI__?: unknown }).__TAURI__) {
return;
}
pendingMenuUpdateCheckRef.current = true;
@@ -169,6 +169,18 @@ export const VSCodeLayout: React.FC = () => {
if (!configInitialized) {
await initializeConfig();
}
const configStore = useConfigStore.getState();
// Keep trying to fetch core datasets on cold starts.
if (configStore.isConnected) {
if (configStore.providers.length === 0) {
await configStore.loadProviders();
}
if (configStore.agents.length === 0) {
await configStore.loadAgents();
}
}
const configState = useConfigStore.getState();
// If OpenCode is still warming up, the initial provider/agent loads can fail and be swallowed by retries.
// Only mark bootstrap complete when core datasets are present so we keep retrying on cold starts.
@@ -17,6 +17,7 @@ import type { CreateMultiRunParams, MultiRunModelSelection } from '@/types/multi
import { ModelMultiSelect, generateInstanceId, type ModelSelectionWithId } from './ModelMultiSelect';
import { BranchSelector, useBranchOptions } from './BranchSelector';
import { AgentSelector } from './AgentSelector';
import { isDesktopShell } from '@/lib/desktop';
/** Max file size in bytes (10MB) */
const MAX_FILE_SIZE = 10 * 1024 * 1024;
@@ -95,7 +96,7 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
if (typeof window === 'undefined') {
return false;
}
return typeof (window as typeof window & { opencodeDesktop?: unknown }).opencodeDesktop !== 'undefined';
return isDesktopShell();
});
const isMacPlatform = React.useMemo(() => {
@@ -109,8 +110,33 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
if (typeof window === 'undefined') {
return;
}
const detected = typeof (window as typeof window & { opencodeDesktop?: unknown }).opencodeDesktop !== 'undefined';
setIsDesktopApp(detected);
setIsDesktopApp(isDesktopShell());
}, []);
const macosMajorVersion = React.useMemo(() => {
if (typeof window === 'undefined') {
return null;
}
const injected = (window as unknown as { __OPENCHAMBER_MACOS_MAJOR__?: unknown }).__OPENCHAMBER_MACOS_MAJOR__;
if (typeof injected === 'number' && Number.isFinite(injected) && injected > 0) {
return injected;
}
// Fallback: WebKit reports "Mac OS X 10_15_7" format where 10 is legacy prefix
if (typeof navigator === 'undefined') {
return null;
}
const match = (navigator.userAgent || '').match(/Mac OS X (\d+)[._](\d+)/);
if (!match) {
return null;
}
const first = Number.parseInt(match[1], 10);
const second = Number.parseInt(match[2], 10);
if (Number.isNaN(first)) {
return null;
}
return first === 10 ? second : first;
}, []);
const desktopHeaderPaddingClass = React.useMemo(() => {
@@ -121,6 +147,19 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
return 'pl-3';
}, [isDesktopApp, isMacPlatform]);
const macosHeaderSizeClass = React.useMemo(() => {
if (!isDesktopApp || !isMacPlatform || macosMajorVersion === null) {
return '';
}
if (macosMajorVersion >= 26) {
return 'h-12';
}
if (macosMajorVersion <= 15) {
return 'h-14';
}
return '';
}, [isDesktopApp, isMacPlatform, macosMajorVersion]);
const handleDragStart = React.useCallback(async (e: React.MouseEvent) => {
if ((e.target as HTMLElement).closest('button, a, input, select, textarea')) {
return;
@@ -321,7 +360,8 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
onMouseDown={handleDragStart}
className={cn(
'relative flex h-12 items-center justify-center border-b app-region-drag select-none',
desktopHeaderPaddingClass
desktopHeaderPaddingClass,
macosHeaderSizeClass,
)}
style={{ borderColor: 'var(--interactive-border)' }}
>
@@ -1,5 +1,6 @@
import React from 'react';
import { RiFileCopyLine, RiCheckLine, RiExternalLinkLine } from '@remixicon/react';
import { isDesktopShell, isTauriShell } from '@/lib/desktop';
const INSTALL_COMMAND = 'curl -fsSL https://opencode.ai/install | bash';
const POLL_INTERVAL_MS = 3000;
@@ -35,6 +36,7 @@ export function OnboardingScreen({ onCliAvailable }: OnboardingScreenProps) {
const [copied, setCopied] = React.useState(false);
const [showHint, setShowHint] = React.useState(false);
const [isDesktopApp, setIsDesktopApp] = React.useState(false);
const [isRetrying, setIsRetrying] = React.useState(false);
React.useEffect(() => {
const timer = setTimeout(() => setShowHint(true), HINT_DELAY_MS);
@@ -42,7 +44,7 @@ export function OnboardingScreen({ onCliAvailable }: OnboardingScreenProps) {
}, []);
React.useEffect(() => {
setIsDesktopApp(typeof (window as typeof window & { opencodeDesktop?: unknown }).opencodeDesktop !== 'undefined');
setIsDesktopApp(isDesktopShell());
}, []);
const handleDragStart = React.useCallback(async (e: React.MouseEvent) => {
@@ -50,7 +52,7 @@ export function OnboardingScreen({ onCliAvailable }: OnboardingScreenProps) {
return;
}
if (e.button !== 0) return;
if (isDesktopApp) {
if (isDesktopApp && isTauriShell()) {
try {
const { getCurrentWindow } = await import('@tauri-apps/api/window');
const window = getCurrentWindow();
@@ -66,12 +68,21 @@ export function OnboardingScreen({ onCliAvailable }: OnboardingScreenProps) {
const response = await fetch('/health');
if (!response.ok) return false;
const data = await response.json();
return data.cliAvailable === true;
return data.openCodeRunning === true || data.isOpenCodeReady === true;
} catch {
return false;
}
}, []);
const handleRetry = React.useCallback(async () => {
setIsRetrying(true);
try {
await fetch('/api/config/reload', { method: 'POST' });
} finally {
setTimeout(() => setIsRetrying(false), 1000);
}
}, []);
const handleCopy = React.useCallback(async () => {
try {
await navigator.clipboard.writeText(INSTALL_COMMAND);
@@ -146,6 +157,17 @@ export function OnboardingScreen({ onCliAvailable }: OnboardingScreenProps) {
<p className="text-sm text-muted-foreground animate-pulse">
Waiting for OpenCode installation...
</p>
<div className="flex justify-center">
<button
type="button"
onClick={handleRetry}
disabled={isRetrying}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{isRetrying ? 'Retrying…' : 'Retry'}
</button>
</div>
</div>
{showHint && (
@@ -116,27 +116,13 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
const { setSidebarOpen } = useUIStore();
const { isMobile } = useDeviceInfo();
const [isDesktopRuntime, setIsDesktopRuntime] = React.useState<boolean>(() => {
if (typeof window === 'undefined') return false;
return typeof window.opencodeDesktop !== 'undefined';
});
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
React.useEffect(() => {
if (typeof window === 'undefined') return;
setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined');
}, []);
React.useEffect(() => {
loadAgents();
}, [loadAgents]);
const bgClass = isDesktopRuntime
? 'bg-transparent'
: isVSCode
? 'bg-background'
: 'bg-sidebar';
const bgClass = isVSCode ? 'bg-background' : 'bg-sidebar';
const handleCreateNew = () => {
// Generate unique name
@@ -46,27 +46,13 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
const { setSidebarOpen } = useUIStore();
const { isMobile } = useDeviceInfo();
const [isDesktopRuntime, setIsDesktopRuntime] = React.useState<boolean>(() => {
if (typeof window === 'undefined') return false;
return typeof window.opencodeDesktop !== 'undefined';
});
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
React.useEffect(() => {
if (typeof window === 'undefined') return;
setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined');
}, []);
React.useEffect(() => {
loadCommands();
}, [loadCommands]);
const bgClass = isDesktopRuntime
? 'bg-transparent'
: isVSCode
? 'bg-background'
: 'bg-sidebar';
const bgClass = isVSCode ? 'bg-background' : 'bg-sidebar';
const handleCreateNew = () => {
// Generate unique name
@@ -67,18 +67,8 @@ export const GitIdentitiesSidebar: React.FC<GitIdentitiesSidebarProps> = ({ onIt
const { setSidebarOpen } = useUIStore();
const { isMobile } = useDeviceInfo();
const [isDesktopRuntime, setIsDesktopRuntime] = React.useState<boolean>(() => {
if (typeof window === 'undefined') return false;
return typeof window.opencodeDesktop !== 'undefined';
});
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
React.useEffect(() => {
if (typeof window === 'undefined') return;
setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined');
}, []);
const unimportedCredentials = getUnimportedCredentials();
React.useEffect(() => {
@@ -98,11 +88,7 @@ export const GitIdentitiesSidebar: React.FC<GitIdentitiesSidebarProps> = ({ onIt
}
};
const bgClass = isDesktopRuntime
? 'bg-transparent'
: isVSCode
? 'bg-background'
: 'bg-sidebar';
const bgClass = isVSCode ? 'bg-background' : 'bg-sidebar';
const handleCreateProfile = () => {
setSelectedProfile('new');
@@ -6,7 +6,7 @@ import { AgentSelector } from '@/components/sections/commands/AgentSelector';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { updateDesktopSettings } from '@/lib/persistence';
import { getDesktopSettings, isDesktopRuntime, isVSCodeRuntime } from '@/lib/desktop';
import { isVSCodeRuntime } from '@/lib/desktop';
import { useConfigStore } from '@/stores/useConfigStore';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { getModifierLabel } from '@/lib/utils';
@@ -67,11 +67,8 @@ export const DefaultsSettings: React.FC = () => {
try {
let data: { defaultModel?: string; defaultVariant?: string; defaultAgent?: string } | null = null;
// 1. Desktop runtime (Tauri)
if (isDesktopRuntime()) {
data = await getDesktopSettings();
} else {
// 2. Runtime settings API (VSCode)
// 1. Runtime settings API (VSCode)
if (!data) {
const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
if (runtimeSettings) {
try {
@@ -85,19 +82,19 @@ export const DefaultsSettings: React.FC = () => {
};
}
} catch {
// Fall through to fetch
// fall through
}
}
}
// 3. Fetch API (Web)
if (!data) {
const response = await fetch('/api/config/settings', {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (response.ok) {
data = await response.json();
}
// 2. Fetch API (Web/server)
if (!data) {
const response = await fetch('/api/config/settings', {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (response.ok) {
data = await response.json();
}
}
@@ -153,12 +150,12 @@ export const DefaultsSettings: React.FC = () => {
defaultVariant: '',
});
if (!isDesktopRuntime()) {
const response = await fetch('/api/config/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ defaultModel: newValue }),
});
{
const response = await fetch('/api/config/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ defaultModel: newValue }),
});
if (!response.ok) {
console.warn('Failed to save default model to server:', response.status, response.statusText);
}
@@ -41,13 +41,12 @@ export const GitHubSettings: React.FC = () => {
return;
}
const desktop = (window as typeof window & { opencodeDesktop?: { openExternal?: (url: string) => Promise<unknown> } }).opencodeDesktop;
if (desktop?.openExternal) {
type TauriShell = { shell?: { open?: (url: string) => Promise<unknown> } };
const tauri = (window as unknown as { __TAURI__?: TauriShell }).__TAURI__;
if (tauri?.shell?.open) {
try {
const result = await desktop.openExternal(url);
if (result && typeof result === 'object' && 'success' in result && (result as { success?: boolean }).success === true) {
return;
}
await tauri.shell.open(url);
return;
} catch {
// fall through
}
@@ -3,7 +3,6 @@ import { RiInformationLine } from '@remixicon/react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Checkbox } from '@/components/ui/checkbox';
import { updateDesktopSettings } from '@/lib/persistence';
import { isDesktopRuntime, getDesktopSettings } from '@/lib/desktop';
import { useConfigStore } from '@/stores/useConfigStore';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { setFilesViewShowGitignored, useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
@@ -22,11 +21,8 @@ export const GitSettings: React.FC = () => {
try {
let data: { gitmojiEnabled?: boolean } | null = null;
// 1. Desktop runtime (Tauri)
if (isDesktopRuntime()) {
data = await getDesktopSettings();
} else {
// 2. Runtime settings API (VSCode)
// 1. Runtime settings API (VSCode)
if (!data) {
const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
if (runtimeSettings) {
try {
@@ -40,19 +36,19 @@ export const GitSettings: React.FC = () => {
};
}
} catch {
// Fall through to fetch
// fall through
}
}
}
// 3. Fetch API (Web)
if (!data) {
const response = await fetch('/api/config/settings', {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (response.ok) {
data = await response.json();
}
// 2. Fetch API (Web/server)
if (!data) {
const response = await fetch('/api/config/settings', {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (response.ok) {
data = await response.json();
}
}
@@ -5,7 +5,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { useDeviceInfo } from '@/lib/device';
import { useUIStore } from '@/stores/useUIStore';
import { updateDesktopSettings } from '@/lib/persistence';
import { getDesktopSettings, isDesktopRuntime } from '@/lib/desktop';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { DEFAULT_MEMORY_LIMITS, DEFAULT_ACTIVE_SESSION_WINDOW } from '@/stores/types/sessionTypes';
@@ -34,11 +33,8 @@ export const MemoryLimitsSettings: React.FC = () => {
try {
let data: { memoryLimitHistorical?: number; memoryLimitViewport?: number; memoryLimitActiveSession?: number } | null = null;
// 1. Desktop runtime (Tauri)
if (isDesktopRuntime()) {
data = await getDesktopSettings();
} else {
// 2. Runtime settings API (VSCode)
// 1. Runtime settings API (VSCode)
if (!data) {
const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
if (runtimeSettings) {
try {
@@ -52,19 +48,19 @@ export const MemoryLimitsSettings: React.FC = () => {
};
}
} catch {
// Fall through to fetch
// fall through
}
}
}
// 3. Fetch API (Web)
if (!data) {
const response = await fetch('/api/config/settings', {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (response.ok) {
data = await response.json();
}
// 2. Fetch API (Web/server)
if (!data) {
const response = await fetch('/api/config/settings', {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (response.ok) {
data = await response.json();
}
}
@@ -91,17 +87,6 @@ export const MemoryLimitsSettings: React.FC = () => {
const persistSetting = React.useCallback(async (key: string, value: number) => {
try {
await updateDesktopSettings({ [key]: value });
if (!isDesktopRuntime()) {
const response = await fetch('/api/config/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [key]: value }),
});
if (!response.ok) {
console.warn(`Failed to save ${key} to server:`, response.status, response.statusText);
}
}
} catch (error) {
console.warn(`Failed to save ${key}:`, error);
}
@@ -1,6 +1,6 @@
import React from 'react';
import { useUIStore } from '@/stores/useUIStore';
import { isWebRuntime } from '@/lib/desktop';
import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop';
import { Switch } from '@/components/ui/switch';
import { toast } from '@/components/ui';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
@@ -8,7 +8,9 @@ import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { GridLoader } from '@/components/ui/grid-loader';
export const NotificationSettings: React.FC = () => {
const isWeb = isWebRuntime();
const isDesktop = React.useMemo(() => isDesktopShell(), []);
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
const isBrowser = !isDesktop && !isVSCode;
const nativeNotificationsEnabled = useUIStore(state => state.nativeNotificationsEnabled);
const setNativeNotificationsEnabled = useUIStore(state => state.setNativeNotificationsEnabled);
const notificationMode = useUIStore(state => state.notificationMode);
@@ -22,7 +24,7 @@ export const NotificationSettings: React.FC = () => {
const [pushBusy, setPushBusy] = React.useState(false);
React.useEffect(() => {
if (!isWeb) {
if (!isBrowser) {
setPushSupported(false);
setPushSubscribed(false);
return;
@@ -58,10 +60,16 @@ export const NotificationSettings: React.FC = () => {
};
void refresh();
}, [isWeb]);
}, [isBrowser]);
const handleToggleChange = async (checked: boolean) => {
if (!isWeb) {
if (isDesktop) {
setNativeNotificationsEnabled(checked);
return;
}
if (!isBrowser) {
setNativeNotificationsEnabled(checked);
return;
}
if (checked && typeof Notification !== 'undefined' && Notification.permission === 'default') {
@@ -86,7 +94,7 @@ export const NotificationSettings: React.FC = () => {
}
};
const canShowNotifications = isWeb && typeof Notification !== 'undefined' && Notification.permission === 'granted';
const canShowNotifications = isDesktop || (isBrowser && typeof Notification !== 'undefined' && Notification.permission === 'granted');
const base64UrlToUint8Array = (base64Url: string): Uint8Array<ArrayBuffer> => {
const padding = '='.repeat((4 - (base64Url.length % 4)) % 4);
@@ -365,92 +373,93 @@ export const NotificationSettings: React.FC = () => {
return (
<div className="space-y-6">
{/* General Notification Settings */}
<div className="space-y-1">
<div className="space-y-1 pt-2">
<h3 className="typography-ui-header font-semibold text-foreground">
Notification Preferences
When to notify
</h3>
<p className="typography-ui text-muted-foreground">
Configure how and when you receive notifications.
Customize when notifications show up.
</p>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<span className="typography-ui text-foreground">
Notify for subtasks
Enable notifications
</span>
<p className="typography-micro text-muted-foreground">
When off, no notifications for child sessions created during multi-run.
Turns notifications on or off.
</p>
</div>
<Switch
checked={notifyOnSubtasks}
onCheckedChange={(checked) => setNotifyOnSubtasks(checked)}
checked={nativeNotificationsEnabled && canShowNotifications}
onCheckedChange={handleToggleChange}
className="data-[state=checked]:bg-status-info"
/>
</div>
{isWeb && (
<>
{/* Foreground Notifications */}
<div className="space-y-1 pt-4">
<h3 className="typography-ui-header font-semibold text-foreground">
Foreground Notifications
</h3>
<p className="typography-ui text-muted-foreground">
Uses the browser Notification API while OpenChamber is open.
{isBrowser && (
<p className="typography-micro text-muted-foreground">
Your browser may ask for permission the first time.
</p>
)}
{nativeNotificationsEnabled && canShowNotifications && (
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<span className="typography-ui text-foreground">
Include subagent results
</span>
<p className="typography-micro text-muted-foreground">
Also notify for child sessions started by the main one.
</p>
</div>
<Switch
checked={notifyOnSubtasks}
onCheckedChange={(checked) => setNotifyOnSubtasks(checked)}
className="data-[state=checked]:bg-status-info"
/>
</div>
)}
<div className="flex items-center justify-between">
{nativeNotificationsEnabled && canShowNotifications && (
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<span className="typography-ui text-foreground">
Enable foreground notifications
Notify while app is focused
</span>
<Switch
checked={nativeNotificationsEnabled && canShowNotifications}
onCheckedChange={handleToggleChange}
className="data-[state=checked]:bg-status-info"
/>
<p className="typography-micro text-muted-foreground">
When off, only notify when you are not looking at OpenChamber.
</p>
</div>
<Switch
checked={notificationMode === 'always'}
onCheckedChange={(checked) => setNotificationMode(checked ? 'always' : 'hidden-only')}
className="data-[state=checked]:bg-status-info"
/>
</div>
)}
{nativeNotificationsEnabled && canShowNotifications && (
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<span className="typography-ui text-foreground">
Notify even when visible
</span>
<p className="typography-micro text-muted-foreground">
When off, only notifies when the tab is hidden or the window is not focused.
</p>
</div>
<Switch
checked={notificationMode === 'always'}
onCheckedChange={(checked) => setNotificationMode(checked ? 'always' : 'hidden-only')}
className="data-[state=checked]:bg-status-info"
/>
</div>
)}
{isBrowser && (
<>
{notificationPermission === 'denied' && (
<p className="typography-micro text-destructive">
Notification permission denied. Enable notifications in your browser settings.
Notification permission denied. Enable it in your browser settings.
</p>
)}
{notificationPermission === 'granted' && !nativeNotificationsEnabled && (
<p className="typography-micro text-muted-foreground">
Permission granted, but foreground notifications are disabled.
Permission granted, but notifications are disabled.
</p>
)}
{/* Background Notifications */}
<div className="space-y-1 pt-4">
<h3 className="typography-ui-header font-semibold text-foreground">
Background Notifications (Push)
Background (Push)
</h3>
<p className="typography-ui text-muted-foreground">
Uses push notifications; works when OpenChamber is closed.
Get notified even if this page is closed.
</p>
</div>
@@ -460,7 +469,7 @@ export const NotificationSettings: React.FC = () => {
</p>
) : (
<p className="typography-micro text-muted-foreground">
Desktop Chrome/Edge and Android support push in the browser. iOS requires an installed PWA.
Desktop Chrome/Edge and Android support push. iOS requires an installed PWA.
</p>
)}
@@ -468,10 +477,10 @@ export const NotificationSettings: React.FC = () => {
<div className="flex items-center justify-between gap-3">
<div className="space-y-0.5">
<span className="typography-ui text-foreground">
Enable background notifications
Enable push notifications
</span>
<p className="typography-micro text-muted-foreground">
Opens chat with /?session=&lt;id&gt; deep link.
Clicking a notification opens the relevant session.
</p>
</div>
@@ -499,6 +508,17 @@ export const NotificationSettings: React.FC = () => {
)}
</>
)}
{isVSCode && (
<div className="space-y-1 pt-4">
<h3 className="typography-ui-header font-semibold text-foreground">
Delivery
</h3>
<p className="typography-ui text-muted-foreground">
VS Code runtime handles notifications separately.
</p>
</div>
)}
</div>
);
};
@@ -62,19 +62,9 @@ export const OpenChamberSidebar: React.FC<OpenChamberSidebarProps> = ({
const { isMobile } = useDeviceInfo();
const showAbout = isMobile && isWebRuntime();
const [isDesktopRuntime, setIsDesktopRuntime] = React.useState<boolean>(() => {
if (typeof window === 'undefined') return false;
return typeof window.opencodeDesktop !== 'undefined';
});
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
const isWeb = React.useMemo(() => isWebRuntime(), []);
React.useEffect(() => {
if (typeof window === 'undefined') return;
setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined');
}, []);
const visibleSections = React.useMemo(() => {
return OPENCHAMBER_SECTION_GROUPS.filter((group) => {
if (group.webOnly && !isWeb) return false;
@@ -86,11 +76,7 @@ export const OpenChamberSidebar: React.FC<OpenChamberSidebarProps> = ({
// Desktop app: transparent for blur effect
// VS Code: bg-background (same as page content)
// Web/mobile: bg-sidebar
const bgClass = isDesktopRuntime
? 'bg-transparent'
: isVSCode
? 'bg-background'
: 'bg-sidebar';
const bgClass = isVSCode ? 'bg-background' : 'bg-sidebar';
return (
<div className={cn('flex h-full flex-col', bgClass)}>
@@ -20,23 +20,9 @@ export const ProvidersSidebar: React.FC<ProvidersSidebarProps> = ({ onItemSelect
const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider);
const { isMobile } = useDeviceInfo();
const [isDesktopRuntime, setIsDesktopRuntime] = React.useState<boolean>(() => {
if (typeof window === 'undefined') return false;
return typeof window.opencodeDesktop !== 'undefined';
});
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
React.useEffect(() => {
if (typeof window === 'undefined') return;
setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined');
}, []);
const bgClass = isDesktopRuntime
? 'bg-transparent'
: isVSCode
? 'bg-background'
: 'bg-sidebar';
const bgClass = isVSCode ? 'bg-background' : 'bg-sidebar';
return (
<div className={cn('flex h-full flex-col', bgClass)}>
@@ -33,26 +33,12 @@ export const SettingsSidebarLayout: React.FC<SettingsSidebarLayoutProps> = ({
children,
className,
}) => {
const [isDesktopRuntime, setIsDesktopRuntime] = React.useState<boolean>(() => {
if (typeof window === 'undefined') return false;
return typeof window.opencodeDesktop !== 'undefined';
});
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
React.useEffect(() => {
if (typeof window === 'undefined') return;
setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined');
}, []);
// Desktop app: transparent for blur effect
// VS Code: bg-background (same as page content)
// Web/mobile: bg-sidebar
const bgClass = isDesktopRuntime
? 'bg-transparent'
: isVSCode
? 'bg-background'
: 'bg-sidebar';
const bgClass = isVSCode ? 'bg-background' : 'bg-sidebar';
return (
<div
@@ -47,27 +47,13 @@ export const SkillsSidebar: React.FC<SkillsSidebarProps> = ({ onItemSelect }) =>
const { setSidebarOpen } = useUIStore();
const { isMobile } = useDeviceInfo();
const [isDesktopRuntime, setIsDesktopRuntime] = React.useState<boolean>(() => {
if (typeof window === 'undefined') return false;
return typeof window.opencodeDesktop !== 'undefined';
});
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
React.useEffect(() => {
if (typeof window === 'undefined') return;
setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined');
}, []);
React.useEffect(() => {
loadSkills();
}, [loadSkills]);
const bgClass = isDesktopRuntime
? 'bg-transparent'
: isVSCode
? 'bg-background'
: 'bg-sidebar';
const bgClass = isVSCode ? 'bg-background' : 'bg-sidebar';
const handleCreateNew = () => {
// Generate unique name
@@ -21,7 +21,7 @@ import {
import { RiGitRepositoryLine } from '@remixicon/react';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { getDesktopSettings, isDesktopRuntime, isVSCodeRuntime } from '@/lib/desktop';
import { isVSCodeRuntime } from '@/lib/desktop';
import { updateDesktopSettings } from '@/lib/persistence';
import type { DesktopSettings, SkillCatalogConfig } from '@/lib/desktop';
import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
@@ -50,10 +50,6 @@ type IdentityOption = { id: string; name: string };
const loadSettings = async (): Promise<DesktopSettings | null> => {
try {
if (isDesktopRuntime()) {
return await getDesktopSettings();
}
const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
if (runtimeSettings) {
const result = await runtimeSettings.load();
@@ -17,7 +17,6 @@ import { useSkillsCatalogStore } from '@/stores/useSkillsCatalogStore';
import type { SkillsCatalogItem } from '@/lib/api/types';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { getDesktopSettings, isDesktopRuntime } from '@/lib/desktop';
import { updateDesktopSettings } from '@/lib/persistence';
import type { DesktopSettings, SkillCatalogConfig } from '@/lib/desktop';
@@ -33,10 +32,6 @@ interface SkillsCatalogPageProps {
const loadSettings = async (): Promise<DesktopSettings | null> => {
try {
if (isDesktopRuntime()) {
return await getDesktopSettings();
}
const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
if (runtimeSettings) {
const result = await runtimeSettings.load();
@@ -43,18 +43,8 @@ export const UsageSidebar: React.FC<UsageSidebarProps> = ({ onItemSelect }) => {
const loadUsageSettings = useQuotaStore((state) => state.loadSettings);
const { isMobile } = useDeviceInfo();
const [isDesktopRuntime, setIsDesktopRuntime] = React.useState<boolean>(() => {
if (typeof window === 'undefined') return false;
return typeof window.opencodeDesktop !== 'undefined';
});
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
React.useEffect(() => {
if (typeof window === 'undefined') return;
setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined');
}, []);
React.useEffect(() => {
void loadUsageSettings();
}, [loadUsageSettings]);
@@ -89,14 +79,7 @@ export const UsageSidebar: React.FC<UsageSidebarProps> = ({ onItemSelect }) => {
void persistUsageSettings({ usageDisplayMode: value });
}, [persistUsageSettings, setUsageDisplayMode]);
const bgClass = isDesktopRuntime
? 'bg-transparent'
: isVSCode
? 'bg-background'
: 'bg-sidebar';
const bgClass = isVSCode ? 'bg-background' : 'bg-sidebar';
return (
<div className={cn('flex h-full flex-col', bgClass)}>
@@ -12,7 +12,6 @@ import { RiAddLine, RiArrowDownSLine, RiArrowRightSLine, RiCheckLine, RiCloseLin
import { cn, formatPathForDisplay } from '@/lib/utils';
import { opencodeClient } from '@/lib/opencode/client';
import { useDeviceInfo } from '@/lib/device';
import { isDesktopRuntime, getDesktopSettings } from '@/lib/desktop';
import type { DesktopSettings } from '@/lib/desktop';
import { updateDesktopSettings } from '@/lib/persistence';
import { useFileSystemAccess } from '@/hooks/useFileSystemAccess';
@@ -54,7 +53,6 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
isRootReady,
alwaysShowActions = false,
}) => {
const desktopRuntime = React.useMemo(() => isDesktopRuntime(), []);
const { isMobile } = useDeviceInfo();
const [directories, setDirectories] = React.useState<DirectoryItem[]>([]);
const [expandedPaths, setExpandedPaths] = React.useState<Set<string>>(new Set());
@@ -243,22 +241,17 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
}
};
const loadPinnedDirectories = async () => {
try {
let pinned: string[] = [];
const loadPinnedDirectories = async () => {
try {
let pinned: string[] = [];
if (desktopRuntime) {
const settings = await getDesktopSettings();
pinned = Array.isArray(settings?.pinnedDirectories) ? settings.pinnedDirectories : [];
} else {
const response = await fetch('/api/config/settings', {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (response.ok) {
const data = await response.json();
pinned = Array.isArray(data?.pinnedDirectories) ? data.pinnedDirectories : [];
}
const response = await fetch('/api/config/settings', {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (response.ok) {
const data = await response.json();
pinned = Array.isArray(data?.pinnedDirectories) ? data.pinnedDirectories : [];
}
if (cancelled) {
@@ -287,7 +280,7 @@ export const DirectoryTree: React.FC<DirectoryTreeProps> = ({
cancelled = true;
window.removeEventListener('openchamber:settings-synced', handleSettingsSynced);
};
}, [desktopRuntime, stripTrailingSlashes]);
}, [stripTrailingSlashes]);
const isInitialPinnedSync = React.useRef(true);
@@ -24,7 +24,7 @@ import { useSessionStore } from '@/stores/useSessionStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useFileSystemAccess } from '@/hooks/useFileSystemAccess';
import { isDesktopRuntime } from '@/lib/desktop';
import { isTauriShell } from '@/lib/desktop';
import { useDeviceInfo } from '@/lib/device';
import { sessionEvents } from '@/lib/sessionEvents';
@@ -136,7 +136,7 @@ export const SessionDialogs: React.FC = () => {
setHasShownInitialDirectoryPrompt(true);
if (isDesktopRuntime()) {
if (isTauriShell()) {
requestAccess('')
.then(async (result) => {
if (!result.success || !result.path) {
@@ -1,6 +1,7 @@
import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2';
import { toast } from '@/components/ui';
import { isDesktopShell, isTauriShell } from '@/lib/desktop';
import {
DndContext,
DragOverlay,
@@ -64,6 +65,7 @@ import { checkIsGitRepository } from '@/lib/gitApi';
import { getSafeStorage } from '@/stores/utils/safeStorage';
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
import { isVSCodeRuntime } from '@/lib/desktop';
import { updateDesktopSettings } from '@/lib/persistence';
import { BranchPickerDialog } from './BranchPickerDialog';
import { GitHubIssuePickerDialog } from './GitHubIssuePickerDialog';
import { GitHubPullRequestPickerDialog } from './GitHubPullRequestPickerDialog';
@@ -135,7 +137,7 @@ interface SortableProjectItemProps {
isActiveProject: boolean;
isRepo: boolean;
isHovered: boolean;
isDesktopRuntime: boolean;
isDesktopShell: boolean;
isStuck: boolean;
hideDirectoryControls: boolean;
mobileVariant: boolean;
@@ -161,7 +163,7 @@ const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
isActiveProject,
isRepo,
isHovered,
isDesktopRuntime,
isDesktopShell,
isStuck,
hideDirectoryControls,
mobileVariant,
@@ -190,7 +192,7 @@ const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
return (
<div ref={setNodeRef} className={cn('relative', isDragging && 'opacity-40')}>
{/* Sentinel for sticky detection */}
{isDesktopRuntime && (
{isDesktopShell && (
<div
ref={sentinelRef}
data-project-id={id}
@@ -203,10 +205,10 @@ const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
<div
className={cn(
'sticky top-0 z-10 pt-2 pb-1.5 w-full text-left cursor-pointer group/project border-b select-none',
!isDesktopRuntime && 'bg-sidebar',
!isDesktopShell && 'bg-sidebar',
)}
style={{
backgroundColor: isDesktopRuntime
backgroundColor: isDesktopShell
? isStuck ? 'var(--sidebar-stuck-bg)' : 'transparent'
: undefined,
borderColor: isHovered
@@ -428,6 +430,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const [openMenuSessionId, setOpenMenuSessionId] = React.useState<string | null>(null);
const projectHeaderSentinelRefs = React.useRef<Map<string, HTMLDivElement | null>>(new Map());
const ignoreIntersectionUntil = React.useRef<number>(0);
const persistCollapsedProjectsTimer = React.useRef<number | null>(null);
const pendingCollapsedProjects = React.useRef<Set<string> | null>(null);
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
@@ -455,22 +459,64 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const shareSession = useSessionStore((state) => state.shareSession);
const unshareSession = useSessionStore((state) => state.unshareSession);
const sessionMemoryState = useSessionStore((state) => state.sessionMemoryState);
const sessionActivityPhase = useSessionStore((state) => state.sessionActivityPhase);
const sessionStatus = useSessionStore((state) => state.sessionStatus);
const permissions = useSessionStore((state) => state.permissions);
const worktreeMetadata = useSessionStore((state) => state.worktreeMetadata);
const availableWorktreesByProject = useSessionStore((state) => state.availableWorktreesByProject);
const getSessionsByDirectory = useSessionStore((state) => state.getSessionsByDirectory);
const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft);
const [isDesktopRuntime, setIsDesktopRuntime] = React.useState<boolean>(() => {
if (typeof window === 'undefined') {
return false;
}
return typeof window.opencodeDesktop !== 'undefined';
});
const tauriIpcAvailable = React.useMemo(() => isTauriShell(), []);
const isDesktopShellRuntime = React.useMemo(() => isDesktopShell(), []);
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
const flushCollapsedProjectsPersist = React.useCallback(() => {
if (isVSCode) {
return;
}
const collapsed = pendingCollapsedProjects.current;
pendingCollapsedProjects.current = null;
persistCollapsedProjectsTimer.current = null;
if (!collapsed) {
return;
}
const { projects } = useProjectsStore.getState();
const updatedProjects = projects.map((project) => ({
...project,
sidebarCollapsed: collapsed.has(project.id),
}));
void updateDesktopSettings({ projects: updatedProjects }).catch(() => {});
}, [isVSCode]);
const scheduleCollapsedProjectsPersist = React.useCallback((collapsed: Set<string>) => {
if (typeof window === 'undefined') {
return;
}
if (isVSCode) {
return;
}
pendingCollapsedProjects.current = collapsed;
if (persistCollapsedProjectsTimer.current !== null) {
window.clearTimeout(persistCollapsedProjectsTimer.current);
}
persistCollapsedProjectsTimer.current = window.setTimeout(() => {
flushCollapsedProjectsPersist();
}, 700);
}, [flushCollapsedProjectsPersist, isVSCode]);
React.useEffect(() => {
return () => {
if (typeof window !== 'undefined' && persistCollapsedProjectsTimer.current !== null) {
window.clearTimeout(persistCollapsedProjectsTimer.current);
}
persistCollapsedProjectsTimer.current = null;
pendingCollapsedProjects.current = null;
};
}, []);
React.useEffect(() => {
try {
const storedParents = safeStorage.getItem(SESSION_EXPANDED_STORAGE_KEY);
@@ -490,13 +536,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
} catch { /* ignored */ }
}, [safeStorage]);
React.useEffect(() => {
if (typeof window === 'undefined') {
return;
}
setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined');
}, []);
const sortedSessions = React.useMemo(() => {
return [...sessions].sort((a, b) => (b.time?.updated || 0) - (a.time?.updated || 0));
}, [sessions]);
@@ -863,31 +902,32 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
);
const handleOpenDirectoryDialog = React.useCallback(() => {
if (isDesktopRuntime && window.opencodeDesktop?.requestDirectoryAccess) {
window.opencodeDesktop
.requestDirectoryAccess('')
.then((result) => {
if (result.success && result.path) {
const added = addProject(result.path, { id: result.projectId });
if (!added) {
toast.error('Failed to add project', {
description: 'Please select a valid directory.',
});
}
} else if (result.error && result.error !== 'Directory selection cancelled') {
toast.error('Failed to select directory', {
description: result.error,
if (!tauriIpcAvailable) {
sessionEvents.requestDirectoryDialog();
return;
}
import('@/lib/desktop')
.then(({ requestDirectoryAccess }) => requestDirectoryAccess(''))
.then((result) => {
if (result.success && result.path) {
const added = addProject(result.path, { id: result.projectId });
if (!added) {
toast.error('Failed to add project', {
description: 'Please select a valid directory.',
});
}
})
.catch((error) => {
console.error('Desktop: Error selecting directory:', error);
toast.error('Failed to select directory');
});
} else {
sessionEvents.requestDirectoryDialog();
}
}, [addProject, isDesktopRuntime]);
} else if (result.error && result.error !== 'Directory selection cancelled') {
toast.error('Failed to select directory', {
description: result.error,
});
}
})
.catch((error) => {
console.error('Desktop: Error selecting directory:', error);
toast.error('Failed to select directory');
});
}, [addProject, tauriIpcAvailable]);
const toggleParent = React.useCallback((sessionId: string) => {
setExpandedParents((prev) => {
@@ -1019,9 +1059,14 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
try {
safeStorage.setItem(PROJECT_COLLAPSE_STORAGE_KEY, JSON.stringify(Array.from(next)));
} catch { /* ignored */ }
// Persist collapse state to server settings (web + desktop local/remote).
if (!isVSCode) {
scheduleCollapsedProjectsPersist(next);
}
return next;
});
}, [safeStorage]);
}, [isVSCode, safeStorage, scheduleCollapsedProjectsPersist]);
const normalizedProjects = React.useMemo(() => {
return projects
@@ -1081,7 +1126,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
// Track when project sticky headers become "stuck"
React.useEffect(() => {
if (!isDesktopRuntime) return;
if (!isDesktopShellRuntime) return;
const observer = new IntersectionObserver(
(entries) => {
@@ -1108,7 +1153,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
});
return () => observer.disconnect();
}, [isDesktopRuntime, projectSections]);
}, [isDesktopShellRuntime, projectSections]);
const renderSessionNode = React.useCallback(
(node: SessionNode, depth = 0, groupDirectory?: string | null, projectId?: string | null): React.ReactNode => {
@@ -1202,8 +1247,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
);
}
const phase = sessionActivityPhase?.get(session.id) ?? 'idle';
const isStreaming = phase === 'busy' || phase === 'cooldown';
const statusType = sessionStatus?.get(session.id)?.type ?? 'idle';
const isStreaming = statusType === 'busy' || statusType === 'retry';
const pendingPermissionCount = permissions.get(session.id)?.length ?? 0;
const streamingIndicator = (() => {
@@ -1427,7 +1472,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
[
directoryStatus,
sessionMemoryState,
sessionActivityPhase,
sessionStatus,
permissions,
currentSessionId,
expandedParents,
@@ -1571,7 +1616,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
onClick={handleOpenDirectoryDialog}
className={cn(
'inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
!isDesktopRuntime && 'bg-sidebar/60 hover:bg-sidebar',
!isDesktopShellRuntime && 'bg-sidebar/60 hover:bg-sidebar',
)}
aria-label="Add project"
title="Add project"
@@ -1650,7 +1695,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
isActiveProject={isActiveProject}
isRepo={Boolean(isRepo)}
isHovered={isHovered}
isDesktopRuntime={isDesktopRuntime}
isDesktopShell={isDesktopShellRuntime}
isStuck={stuckProjectHeaders.has(projectKey)}
hideDirectoryControls={hideDirectoryControls}
mobileVariant={mobileVariant}
@@ -47,7 +47,7 @@ export const AboutDialog: React.FC<AboutDialogProps> = ({
React.useEffect(() => {
if (!open) return;
const isDesktop = typeof window !== 'undefined' && !!window.opencodeDesktop;
const isDesktop = typeof window !== 'undefined' && Boolean((window as unknown as { __TAURI__?: unknown }).__TAURI__);
if (isDesktop) {
const fetchVersion = async () => {
@@ -4,7 +4,6 @@ import { Card } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
import { RiCloseLine, RiDatabase2Line, RiDeleteBinLine, RiPulseLine } from '@remixicon/react';
import { useDesktopServerInfo } from '@/hooks/useDesktopServerInfo';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
interface MemoryDebugPanelProps {
@@ -20,7 +19,6 @@ export const MemoryDebugPanel: React.FC<MemoryDebugPanelProps> = ({ onClose }) =
trimToViewportWindow,
evictLeastRecentlyUsed
} = useSessionStore();
const desktopInfo = useDesktopServerInfo(4000);
const totalMessages = React.useMemo(() => {
let total = 0;
@@ -81,25 +79,7 @@ export const MemoryDebugPanel: React.FC<MemoryDebugPanelProps> = ({ onClose }) =
</div>
</div>
{desktopInfo && (
<div className="grid grid-cols-2 gap-2 typography-meta border-t pt-2">
<div className="bg-muted/50 rounded p-2">
<div className="text-muted-foreground">Desktop Host</div>
<div className="typography-markdown font-mono text-xs">
{desktopInfo.host ?? 'unknown'}
</div>
</div>
<div className="bg-muted/50 rounded p-2">
<div className="text-muted-foreground">OpenCode Port</div>
<div className="typography-markdown font-semibold">
{desktopInfo.openCodePort ?? 'n/a'}
<span className="ml-2 text-xs text-muted-foreground">
{desktopInfo.ready ? 'ready' : 'starting'}
</span>
</div>
</div>
</div>
)}
{null}
{}
<div className="typography-meta space-y-1 border-t pt-2">
@@ -0,0 +1,60 @@
import React from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { toast } from '@/components/ui';
import { useUIStore } from '@/stores/useUIStore';
export const OpenCodeStatusDialog: React.FC = () => {
const {
isOpenCodeStatusDialogOpen,
setOpenCodeStatusDialogOpen,
openCodeStatusText,
} = useUIStore();
const handleCopy = React.useCallback(() => {
if (!openCodeStatusText) {
return;
}
void navigator.clipboard
.writeText(openCodeStatusText)
.then(() => {
toast.success('Copied', { description: 'OpenCode status copied to clipboard.' });
})
.catch(() => {
toast.error('Copy failed');
});
}, [openCodeStatusText]);
return (
<Dialog open={isOpenCodeStatusDialogOpen} onOpenChange={setOpenCodeStatusDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>OpenCode Status</DialogTitle>
<DialogDescription>
Diagnostic snapshot for support and debugging.
</DialogDescription>
</DialogHeader>
<div className="flex items-center justify-end">
<button
type="button"
onClick={handleCopy}
className="app-region-no-drag inline-flex h-9 items-center justify-center rounded-md px-3 typography-ui-label font-medium text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
Copy
</button>
</div>
<pre className="max-h-[60vh] overflow-auto rounded-lg bg-surface-muted p-4 typography-code text-foreground whitespace-pre-wrap">
{openCodeStatusText || 'No data.'}
</pre>
</DialogContent>
</Dialog>
);
};
+17 -15
View File
@@ -6,6 +6,7 @@ export type ScrollShadowProps = React.HTMLAttributes<HTMLDivElement> & {
size?: number;
isEnabled?: boolean;
hideBottomShadow?: boolean;
observeMutations?: boolean;
onVisibilityChange?: (state: "both" | "none" | "top" | "bottom" | "left" | "right") => void;
};
@@ -22,18 +23,19 @@ function mergeRefs<T>(...refs: Array<React.Ref<T>>): React.RefCallback<T> {
}
export const ScrollShadow = React.forwardRef<HTMLDivElement, ScrollShadowProps>(
(
{
orientation = "vertical",
offset = 72,
size = 48,
isEnabled = true,
hideBottomShadow = false,
onVisibilityChange,
style,
className,
children,
...rest
(
{
orientation = "vertical",
offset = 72,
size = 48,
isEnabled = true,
hideBottomShadow = false,
observeMutations = true,
onVisibilityChange,
style,
className,
children,
...rest
},
ref,
) => {
@@ -119,13 +121,13 @@ export const ScrollShadow = React.forwardRef<HTMLDivElement, ScrollShadowProps>(
const handleScroll = () => checkOverflow(); // Scroll should be immediate
const resizeObserver = typeof ResizeObserver !== "undefined" ? new ResizeObserver(throttledCheck) : null;
const mutationObserver =
typeof MutationObserver !== "undefined" ? new MutationObserver(throttledCheck) : null;
observeMutations && typeof MutationObserver !== "undefined" ? new MutationObserver(throttledCheck) : null;
checkOverflow();
el.addEventListener("scroll", handleScroll, { passive: true });
resizeObserver?.observe(el);
mutationObserver?.observe(el, { childList: true, subtree: true, characterData: true });
mutationObserver?.observe(el, { childList: true, subtree: true });
return () => {
if (rafId !== null) cancelAnimationFrame(rafId);
@@ -133,7 +135,7 @@ export const ScrollShadow = React.forwardRef<HTMLDivElement, ScrollShadowProps>(
resizeObserver?.disconnect();
mutationObserver?.disconnect();
};
}, [checkOverflow]);
}, [checkOverflow, observeMutations]);
return (
<div
@@ -239,7 +239,7 @@ export const PlanView: React.FC = () => {
setLineSelection(null);
toast.success('Comment saved');
}, [lineSelection, commentText, content, displayPath, resolvedPath, addDraft, getSessionKey]);
}, [lineSelection, commentText, content, displayPath, resolvedPath, addDraft, getSessionKey, extractSelectedCode]);
const editorExtensions = React.useMemo(() => {
const extensions = [createFlexokiCodeMirrorTheme(currentTheme)];
@@ -104,10 +104,10 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
const startXRef = React.useRef(0);
const startWidthRef = React.useRef(sidebarWidth);
const [isDesktopApp, setIsDesktopApp] = React.useState<boolean>(() => {
const isTauri = React.useMemo(() => {
if (typeof window === 'undefined') return false;
return typeof (window as typeof window & { opencodeDesktop?: unknown }).opencodeDesktop !== 'undefined';
});
return Boolean((window as unknown as { __TAURI__?: unknown }).__TAURI__);
}, []);
const isMacPlatform = React.useMemo(() => {
if (typeof navigator === 'undefined') return false;
@@ -118,10 +118,7 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
const settingsSections = React.useMemo(() => getSettingsSections(isVSCode), [isVSCode]);
React.useEffect(() => {
if (typeof window === 'undefined') return;
setIsDesktopApp(typeof (window as typeof window & { opencodeDesktop?: unknown }).opencodeDesktop !== 'undefined');
}, []);
const isDesktopApp = isTauri;
// Track container width for responsive tab labels
React.useEffect(() => {
@@ -17,7 +17,6 @@ import { useUIStore } from '@/stores/useUIStore';
import { Button } from '@/components/ui/button';
import { useDeviceInfo } from '@/lib/device';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { isDesktopRuntime, isWebRuntime } from '@/lib/desktop';
type Modifier = 'ctrl' | 'cmd';
type MobileKey =
@@ -74,12 +73,13 @@ const getSequenceForKey = (key: MobileKey, modifier: Modifier | null): string |
};
export const TerminalView: React.FC = () => {
const { terminal } = useRuntimeAPIs();
const { terminal, runtime } = useRuntimeAPIs();
const { currentTheme } = useThemeSystem();
const { monoFont } = useFontPreferences();
const terminalFontSize = useUIStore(state => state.terminalFontSize);
const { isMobile, hasTouchInput } = useDeviceInfo();
const enableTabs = !isMobile && (isWebRuntime() || isDesktopRuntime());
// Tabs are supported for web + desktop runtimes (not VSCode).
const enableTabs = !isMobile && runtime.platform !== 'vscode';
const showTerminalQuickKeysOnDesktop = useUIStore((state) => state.showTerminalQuickKeysOnDesktop);
const showQuickKeys = isMobile || showTerminalQuickKeysOnDesktop;
@@ -73,19 +73,25 @@ type PullRequestDraftSnapshot = {
const pullRequestDraftSnapshots = new Map<string, PullRequestDraftSnapshot>();
type TauriShell = {
shell?: {
open?: (url: string) => Promise<unknown>;
};
};
const openExternal = async (url: string) => {
if (typeof window === 'undefined') return;
const desktop = (window as typeof window & { opencodeDesktop?: { openExternal?: (url: string) => Promise<unknown> } }).opencodeDesktop;
if (desktop?.openExternal) {
const tauri = (window as unknown as { __TAURI__?: TauriShell }).__TAURI__;
if (tauri?.shell?.open) {
try {
const result = await desktop.openExternal(url);
if (result && typeof result === 'object' && 'success' in result && (result as { success?: boolean }).success === true) {
return;
}
await tauri.shell.open(url);
return;
} catch {
// fall through
}
}
try {
window.open(url, '_blank', 'noopener,noreferrer');
} catch {
@@ -6,7 +6,7 @@ import React, {
} from 'react';
import type { Theme, ThemeMode } from '@/types/theme';
import type { DesktopSettings } from '@/lib/desktop';
import { isDesktopRuntime, isVSCodeRuntime } from '@/lib/desktop';
import { isDesktopLocalOriginActive, isVSCodeRuntime } from '@/lib/desktop';
import { CSSVariableGenerator } from '@/lib/theme/cssGenerator';
import { updateDesktopSettings } from '@/lib/persistence';
import {
@@ -190,7 +190,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
return existing || null;
});
const isVSCode = useMemo(() => isVSCodeRuntime(), []);
const isDesktop = useMemo(() => isDesktopRuntime(), []);
const isLocalDesktopOrigin = useMemo(() => isDesktopLocalOriginActive(), []);
const availableThemes = useMemo(() => {
const merged: Theme[] = [];
@@ -256,7 +256,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
try {
const res = await fetch('/api/config/themes', {
method: 'GET',
credentials: isDesktop ? 'omit' : 'include',
credentials: isLocalDesktopOrigin ? 'omit' : 'include',
headers: {
Accept: 'application/json',
},
@@ -280,7 +280,7 @@ export function ThemeSystemProvider({ children, defaultThemeId }: ThemeSystemPro
} finally {
setCustomThemesLoading(false);
}
}, [isDesktop, isVSCode]);
}, [isLocalDesktopOrigin, isVSCode]);
useEffect(() => {
void reloadCustomThemes();
+4 -4
View File
@@ -121,7 +121,7 @@ export function useAssistantStatus(): AssistantStatusSnapshot {
}))
);
const { phase: activityPhase, isWorking: isPhaseWorking, isCooldown: isPhaseCooldown } = useCurrentSessionActivity();
const { phase: activityPhase, isWorking: isPhaseWorking } = useCurrentSessionActivity();
const sessionMessages = React.useMemo<Array<{ info: Message; parts: Part[] }>>(() => {
if (!currentSessionId) {
@@ -298,7 +298,7 @@ export function useAssistantStatus(): AssistantStatusSnapshot {
const isWorking = isPhaseWorking;
const isStreaming = activityPhase === 'busy';
const isCooldown = isPhaseCooldown;
const isCooldown = false;
let activity: AssistantActivity = 'idle';
if (isWorking) {
@@ -327,9 +327,9 @@ export function useAssistantStatus(): AssistantStatusSnapshot {
wasAborted: false,
abortActive: false,
lastCompletionId: null,
isComplete: isCooldown,
isComplete: false,
};
}, [activityPhase, isPhaseWorking, isPhaseCooldown, parsedStatus, abortState]);
}, [activityPhase, isPhaseWorking, parsedStatus, abortState]);
const forming = React.useMemo<FormingSummary>(() => {
@@ -1,38 +0,0 @@
import { useEffect, useState } from "react";
import {
fetchDesktopServerInfo,
isDesktopRuntime,
type DesktopServerInfo
} from "@/lib/desktop";
export const useDesktopServerInfo = (pollInterval = 5000): DesktopServerInfo | null => {
const [info, setInfo] = useState<DesktopServerInfo | null>(null);
useEffect(() => {
if (!isDesktopRuntime()) {
return;
}
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const poll = async () => {
const payload = await fetchDesktopServerInfo();
if (!cancelled) {
setInfo(payload);
timer = setTimeout(poll, pollInterval);
}
};
poll();
return () => {
cancelled = true;
if (timer) {
clearTimeout(timer);
}
};
}, [pollInterval]);
return info;
};
+156 -285
View File
@@ -15,7 +15,7 @@ import { handleTodoUpdatedEvent } from '@/stores/useTodoStore';
import { useMcpStore } from '@/stores/useMcpStore';
import { useContextStore } from '@/stores/contextStore';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { isWebRuntime } from '@/lib/desktop';
import { isDesktopLocalOriginActive } from '@/lib/desktop';
interface EventData {
type: string;
@@ -99,36 +99,6 @@ const getMessageFromStore = (sessionId: string, messageId: string): { info: Mess
return message;
};
const formatModelID = (raw: string): string => {
if (!raw) {
return 'Assistant';
}
const tokens: string[] = raw.split(/[-_]/);
const result: string[] = [];
let i = 0;
while (i < tokens.length) {
const current = tokens[i];
if (/^\d+$/.test(current)) {
if (i + 1 < tokens.length && /^\d+$/.test(tokens[i + 1])) {
const combined = `${current}.${tokens[i + 1]}`;
result.push(combined);
i += 2;
continue;
}
}
result.push(current);
i += 1;
}
return result
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
};
export const useEventStream = () => {
const {
addStreamingPart,
@@ -149,8 +119,6 @@ export const useEventStream = () => {
const { checkConnection } = useConfigStore();
const nativeNotificationsEnabled = useUIStore((state) => state.nativeNotificationsEnabled);
const notificationMode = useUIStore((state) => state.notificationMode);
const notifyOnSubtasks = useUIStore((state) => state.notifyOnSubtasks);
const fallbackDirectory = useDirectoryStore((state) => state.currentDirectory);
const activeSessionDirectory = React.useMemo(() => {
@@ -412,7 +380,6 @@ export const useEventStream = () => {
(sessionId: string, reason: string, limit?: number) => Promise<void>
>(() => Promise.resolve());
const scheduleReconnectRef = React.useRef<(hint?: string) => void>(() => {});
const isDesktopRuntimeRef = React.useRef<boolean>(false);
const maybeBootstrapIfStale = React.useCallback(
(reason: string) => {
@@ -425,17 +392,6 @@ export const useEventStream = () => {
[bootstrapState]
);
React.useEffect(() => {
if (typeof window !== 'undefined') {
const apis = (window as typeof window & { __OPENCHAMBER_RUNTIME_APIS__?: { runtime?: { isDesktop?: boolean } } }).__OPENCHAMBER_RUNTIME_APIS__;
if (apis?.runtime?.isDesktop) {
isDesktopRuntimeRef.current = true;
}
}
}, []);
const sessionCooldownTimersRef = React.useRef<Map<string, NodeJS.Timeout>>(new Map());
const sessionActivityPhaseRef = React.useRef<Map<string, 'idle' | 'busy' | 'cooldown'>>(new Map());
const sessionStatusLastRefreshAtRef = React.useRef<number>(0);
const sessionStatusRefreshInFlightRef = React.useRef<Promise<void> | null>(null);
const currentSessionIdRef = React.useRef<string | null>(currentSessionId);
@@ -509,15 +465,46 @@ export const useEventStream = () => {
);
const updateSessionActivityPhase = React.useCallback((sessionId: string, phase: 'idle' | 'busy' | 'cooldown') => {
const storePhase = useSessionStore.getState().sessionActivityPhase?.get(sessionId);
if (storePhase === phase) {
sessionActivityPhaseRef.current = new Map(useSessionStore.getState().sessionActivityPhase ?? new Map());
return;
type SessionStatusPayload = {
type: 'idle' | 'busy' | 'retry';
attempt?: number;
message?: string;
next?: number;
};
const updateSessionStatus = React.useCallback((
sessionId: string,
status: SessionStatusPayload,
source: string = 'unknown'
) => {
if (!sessionId) return;
const storeStatus = useSessionStore.getState().sessionStatus?.get(sessionId);
const prevType = storeStatus?.type ?? 'idle';
const nextType = status?.type ?? 'idle';
if (prevType !== nextType) {
try {
console.info('[SESSION-STATUS]', {
sessionId,
from: prevType,
to: nextType,
source,
...(nextType === 'retry'
? {
attempt: status.attempt,
next: status.next,
message: status.message,
}
: {}),
});
} catch {
// ignore
}
}
const shouldArmMessageStallCheck = storePhase === 'idle' && (phase === 'busy' || phase === 'cooldown');
const shouldDisarmMessageStallCheck = phase === 'idle';
const shouldArmMessageStallCheck = prevType === 'idle' && (nextType === 'busy' || nextType === 'retry');
const shouldDisarmMessageStallCheck = nextType === 'idle';
if (shouldDisarmMessageStallCheck) {
const pending = pendingMessageStallTimersRef.current.get(sessionId);
@@ -536,8 +523,8 @@ export const useEventStream = () => {
const startAt = Date.now();
const timer = setTimeout(() => {
const currentPhase = useSessionStore.getState().sessionActivityPhase?.get(sessionId);
if (currentPhase !== 'busy' && currentPhase !== 'cooldown') {
const current = useSessionStore.getState().sessionStatus?.get(sessionId);
if (current?.type !== 'busy' && current?.type !== 'retry') {
return;
}
@@ -552,43 +539,25 @@ export const useEventStream = () => {
}
lastMessageStallRecoveryBySessionRef.current.set(sessionId, Date.now());
void scheduleSoftResyncRef.current(sessionId, 'activity_started_no_message', getActiveSessionWindow())
void scheduleSoftResyncRef.current(sessionId, 'status_busy_no_message', getActiveSessionWindow())
.finally(() => {
scheduleReconnectRef.current('No message events after activity start');
scheduleReconnectRef.current('No message events after busy status');
});
}, 2000);
pendingMessageStallTimersRef.current.set(sessionId, timer);
}
const existingTimer = sessionCooldownTimersRef.current.get(sessionId);
if (existingTimer) {
clearTimeout(existingTimer);
sessionCooldownTimersRef.current.delete(sessionId);
}
const next = new Map(useSessionStore.getState().sessionActivityPhase ?? new Map());
next.set(sessionId, phase);
sessionActivityPhaseRef.current = next;
useSessionStore.setState({ sessionActivityPhase: next });
if (phase === 'cooldown') {
const timer = setTimeout(() => {
sessionCooldownTimersRef.current.delete(sessionId);
const current = useSessionStore.getState().sessionActivityPhase?.get(sessionId);
if (current === 'cooldown') {
const latest = new Map(useSessionStore.getState().sessionActivityPhase ?? new Map());
latest.set(sessionId, 'idle');
sessionActivityPhaseRef.current = latest;
useSessionStore.setState({ sessionActivityPhase: latest });
}
}, 2000);
sessionCooldownTimersRef.current.set(sessionId, timer);
const next = new Map(useSessionStore.getState().sessionStatus ?? new Map());
if (nextType === 'idle') {
next.delete(sessionId);
} else {
next.set(sessionId, status);
}
useSessionStore.setState({ sessionStatus: next });
}, []);
const refreshSessionActivityStatus = React.useCallback(async () => {
const refreshSessionStatus = React.useCallback(async () => {
const now = Date.now();
if (sessionStatusRefreshInFlightRef.current) {
return sessionStatusRefreshInFlightRef.current;
@@ -598,8 +567,8 @@ export const useEventStream = () => {
}
sessionStatusLastRefreshAtRef.current = now;
const applyStatusMap = (statusMap: Record<string, { type?: string }>) => {
const observed = new Set<string>();
const applyStatusMap = (statusMap: Record<string, { type?: string }>) => {
const observed = new Set<string>();
// Use getState() to avoid sessions dependency which causes cascading updates
const currentSessions = useSessionStore.getState().sessions;
const knownSessionIds = new Set(currentSessions.map((session) => session.id));
@@ -607,38 +576,37 @@ export const useEventStream = () => {
for (const [sessionId, raw] of Object.entries(statusMap)) {
if (!sessionId || !raw) continue;
observed.add(sessionId);
const phase: 'idle' | 'busy' =
raw.type === 'busy' || raw.type === 'retry' ? 'busy' : 'idle';
updateSessionActivityPhase(sessionId, phase);
const typeRaw = raw.type;
const status: SessionStatusPayload =
typeRaw === 'retry'
? {
type: 'retry',
attempt: (raw as { attempt?: unknown }).attempt as number | undefined,
message: (raw as { message?: unknown }).message as string | undefined,
next: (raw as { next?: unknown }).next as number | undefined,
}
: typeRaw === 'busy' || typeRaw === 'cooldown'
? { type: 'busy' }
: { type: 'idle' };
updateSessionStatus(sessionId, status, 'poll:/session/status');
}
// OpenCode's /session/status may omit idle sessions (returns only busy/retry).
// Treat missing entries as idle to avoid sessions getting stuck "working".
const currentPhases = useSessionStore.getState().sessionActivityPhase;
if (!currentPhases) return;
const currentStatuses = useSessionStore.getState().sessionStatus;
if (!currentStatuses) return;
for (const [sessionId, phase] of currentPhases.entries()) {
for (const [sessionId, status] of currentStatuses.entries()) {
if (!knownSessionIds.has(sessionId)) continue;
if ((phase === 'busy' || phase === 'cooldown') && !observed.has(sessionId)) {
updateSessionActivityPhase(sessionId, 'idle');
if ((status.type === 'busy' || status.type === 'retry') && !observed.has(sessionId)) {
updateSessionStatus(sessionId, { type: 'idle' }, 'poll:missing->idle');
}
}
};
const task = (async (): Promise<void> => {
try {
// Try web server's tracked activity first - more reliable on visibility restore
// because it tracks activity even when UI is not listening to SSE.
// Only available in web runtime (desktop/vscode use native events instead).
if (isWebRuntime()) {
const webServerActivity = await opencodeClient.getWebServerSessionActivity();
if (webServerActivity && Object.keys(webServerActivity).length > 0) {
applyStatusMap(webServerActivity);
return;
}
}
// Fallback to OpenCode's global session status
// OpenCode global session status (busy/retry only; idle omitted)
const globalStatusMap = await opencodeClient.getGlobalSessionStatus();
if (globalStatusMap && Object.keys(globalStatusMap).length > 0) {
applyStatusMap(globalStatusMap);
@@ -677,11 +645,11 @@ export const useEventStream = () => {
}
if (Object.keys(merged).length === 0) {
const hasActivePhases = Array.from(useSessionStore.getState().sessionActivityPhase?.values?.() ?? []).some(
(phase) => phase === 'busy' || phase === 'cooldown'
const hasActiveStatuses = Array.from(useSessionStore.getState().sessionStatus?.values?.() ?? []).some(
(status) => status?.type === 'busy' || status?.type === 'retry'
);
if (hasActivePhases) {
if (hasActiveStatuses) {
const healthy = await opencodeClient.checkHealth().catch(() => false);
if (!healthy) {
return;
@@ -699,7 +667,7 @@ export const useEventStream = () => {
sessionStatusRefreshInFlightRef.current = task;
return task;
}, [effectiveDirectory, normalizeDirectory, resolveSessionDirectoryForStatus, updateSessionActivityPhase]);
}, [effectiveDirectory, normalizeDirectory, resolveSessionDirectoryForStatus, updateSessionStatus]);
React.useEffect(() => {
const nextSessionId = currentSessionId ?? null;
@@ -709,13 +677,13 @@ export const useEventStream = () => {
if (prevSessionId && nextSessionId && prevSessionId !== nextSessionId) {
if (prevDirectory && nextDirectory && prevDirectory !== nextDirectory) {
void refreshSessionActivityStatus();
void refreshSessionStatus();
}
}
previousSessionIdRef.current = nextSessionId;
previousSessionDirectoryRef.current = nextDirectory;
}, [currentSessionId, refreshSessionActivityStatus, resolveSessionDirectoryForStatus]);
}, [currentSessionId, refreshSessionStatus, resolveSessionDirectoryForStatus]);
const handleEvent = React.useCallback((event: EventData) => {
lastEventTimestampRef.current = Date.now();
@@ -766,15 +734,6 @@ export const useEventStream = () => {
void bootstrapState('server_disposed_event');
break;
}
case 'openchamber:session-activity': {
const sessionId = typeof props.sessionId === 'string' ? props.sessionId : null;
const phase = typeof props.phase === 'string' ? props.phase : null;
if (sessionId && (phase === 'idle' || phase === 'busy' || phase === 'cooldown')) {
updateSessionActivityPhase(sessionId, phase);
requestSessionMetadataRefresh(sessionId, typeof props.directory === 'string' ? props.directory : null);
}
break;
}
case 'mcp.tools.changed': {
const directory = typeof props.directory === 'string' ? props.directory : effectiveDirectory;
@@ -783,17 +742,25 @@ export const useEventStream = () => {
}
case 'session.status':
if (isDesktopRuntimeRef.current) break;
{
const sessionId = typeof props.sessionID === 'string' ? props.sessionID : null;
const statusObj = (typeof props.status === 'object' && props.status !== null) ? props.status as Record<string, unknown> : null;
const statusType = typeof statusObj?.type === 'string' ? statusObj.type : null;
const statusInfo = statusObj ?? {};
if (sessionId && statusType) {
updateSessionActivityPhase(
sessionId,
statusType === 'busy' || statusType === 'retry' ? 'busy' : 'idle',
);
if (statusType === 'busy') {
updateSessionStatus(sessionId, { type: 'busy' }, 'sse:session.status');
} else if (statusType === 'retry') {
updateSessionStatus(sessionId, {
type: 'retry',
attempt: typeof statusInfo.attempt === 'number' ? statusInfo.attempt : undefined,
message: typeof statusInfo.message === 'string' ? statusInfo.message : undefined,
next: typeof statusInfo.next === 'number' ? statusInfo.next : undefined,
}, 'sse:session.status');
} else {
updateSessionStatus(sessionId, { type: 'idle' }, 'sse:session.status');
}
requestSessionMetadataRefresh(sessionId, typeof props.directory === 'string' ? props.directory : null);
}
}
@@ -877,6 +844,25 @@ export const useEventStream = () => {
type: part.type || 'text',
} as Part;
// Fallback: if we see assistant parts but session.status hasn't arrived yet, mark busy.
if (roleInfo === 'assistant') {
const partType = (messagePart as { type?: unknown }).type;
const isStreamingPart =
partType === 'step-start' ||
partType === 'text' ||
partType === 'tool' ||
partType === 'reasoning' ||
partType === 'file' ||
partType === 'patch';
if (isStreamingPart) {
const currentStatus = useSessionStore.getState().sessionStatus?.get(sessionId);
if (!currentStatus || currentStatus.type === 'idle') {
updateSessionStatus(sessionId, { type: 'busy' }, 'sse:message.part.updated');
}
}
}
trackMessage(messageId, 'addStreamingPart_called');
addStreamingPart(sessionId, messageId, messagePart, roleInfo);
break;
@@ -926,7 +912,7 @@ export const useEventStream = () => {
break;
}
if (isDesktopRuntimeRef.current && streamDebugEnabled()) {
if (streamDebugEnabled()) {
try {
const serverParts = (props as { parts?: unknown }).parts || (messageExt as { parts?: unknown }).parts || [];
const textParts = Array.isArray(serverParts)
@@ -1137,21 +1123,6 @@ export const useEventStream = () => {
trackMessage(messageId, 'skipped_shrinking_update', { incomingLen, existingLen });
break;
}
if (isDesktopRuntimeRef.current) {
const zeroToleranceShrink = existingLen > 0 && incomingLen < existingLen;
const hasUsefulText = partsArray.some((p) => {
if (!p || p.type !== 'text') return false;
const textPart = p as { text?: string };
return typeof textPart.text === 'string' && textPart.text.length > 0;
});
const shrinkAllowed = eventHasStopFinish && hasUsefulText;
if (zeroToleranceShrink && !shrinkAllowed) {
trackMessage(messageId, 'desktop_shrinking_update_suppressed', { incomingLen, existingLen });
break;
}
}
}
updateMessageInfo(sessionId, messageId, message as unknown as Message);
@@ -1168,9 +1139,7 @@ export const useEventStream = () => {
{ count: partsArray.length }
);
const partsToInject = isDesktopRuntimeRef.current && (messageExt as { role?: unknown }).role === 'assistant'
? partsArray.filter((serverPart) => serverPart?.type !== 'text')
: partsArray;
const partsToInject = partsArray;
for (let i = 0; i < partsToInject.length; i++) {
const serverPart = partsToInject[i];
@@ -1213,11 +1182,6 @@ export const useEventStream = () => {
const isActiveSession = currentSessionId === sessionId;
if (isActiveSession && messageId !== latestAssistantMessageId) break;
if (!stopMarkerPresent && isDesktopRuntimeRef.current) {
trackMessage(messageId, 'desktop_completion_without_stop');
break;
}
const timeCompleted =
hasCompletedTimestamp
? (completedCandidate as number)
@@ -1306,53 +1270,6 @@ export const useEventStream = () => {
completeStreamingMessage(sessionId, messageId);
// Only notify when entire message is finished (finish === 'stop')
if (finish === 'stop' && isWebRuntime() && nativeNotificationsEnabled) {
const shouldNotify = notificationMode === 'always' || visibilityStateRef.current === 'hidden';
if (shouldNotify) {
// Check if this is a subtask and if we should notify for subtasks
if (!notifyOnSubtasks) {
const sessions = useSessionStore.getState().sessions;
const session = sessions.find(s => s.id === sessionId);
const isSubtask = session && 'parentID' in session && Boolean((session as { parentID?: string }).parentID);
if (isSubtask) {
// Skip notification for subtasks
return;
}
}
const notifiedMessages = notifiedMessagesRef.current;
if (!notifiedMessages.has(messageId)) {
notifiedMessages.add(messageId);
const runtimeAPIs = getRegisteredRuntimeAPIs();
if (runtimeAPIs?.notifications) {
const rawMode = (messageExt as { mode?: string }).mode || 'agent';
const rawModel = (messageExt as { modelID?: string }).modelID || 'assistant';
const title = `${rawMode.charAt(0).toUpperCase() + rawMode.slice(1)} agent is ready`;
const body = `${formatModelID(rawModel)} completed the task`;
void runtimeAPIs.notifications.notifyAgentCompletion({ title, body, tag: messageId });
}
}
}
}
// For web/vscode: trigger cooldown only when assistant message has finish === "stop"
// to match desktop backend semantics.
if (!isDesktopRuntimeRef.current) {
if (finish === 'stop') {
const currentPhase = useSessionStore.getState().sessionActivityPhase?.get(sessionId);
if (currentPhase === 'busy') {
updateSessionActivityPhase(sessionId, 'cooldown');
}
}
}
const rawMessageSessionId = (message as { sessionID?: string }).sessionID;
const messageSessionId: string =
typeof rawMessageSessionId === 'string' && rawMessageSessionId.length > 0
@@ -1481,19 +1398,6 @@ export const useEventStream = () => {
});
});
if (isWebRuntime() && nativeNotificationsEnabled) {
const shouldNotify = notificationMode === 'always' || visibilityStateRef.current === 'hidden';
if (shouldNotify) {
const runtimeAPIs = getRegisteredRuntimeAPIs();
if (runtimeAPIs?.notifications) {
void runtimeAPIs.notifications.notifyAgentCompletion({
title: 'Permission required',
body: sessionTitle,
tag: `permission-${toastKey}`,
});
}
}
}
}, 0);
}
@@ -1513,39 +1417,7 @@ export const useEventStream = () => {
const toastKey = `${request.sessionID}:${request.id}`;
if (isWebRuntime() && nativeNotificationsEnabled) {
const shouldNotify = notificationMode === 'always' || visibilityStateRef.current === 'hidden';
if (shouldNotify) {
const notifiedQuestions = notifiedQuestionsRef.current;
if (!notifiedQuestions.has(toastKey)) {
notifiedQuestions.add(toastKey);
const runtimeAPIs = getRegisteredRuntimeAPIs();
if (runtimeAPIs?.notifications) {
const first = Array.isArray(request.questions) ? request.questions[0] : undefined;
const header = typeof first?.header === 'string' ? first.header.trim() : '';
const questionText = typeof first?.question === 'string' ? first.question.trim() : '';
const title = /plan\s*mode/i.test(header)
? 'Switch to plan mode'
: /build\s*agent/i.test(header)
? 'Switch to build mode'
: header || 'Input needed';
const body = questionText || 'Agent is waiting for your response';
void runtimeAPIs.notifications.notifyAgentCompletion({
title,
body,
tag: toastKey,
});
}
}
}
}
// notifications are emitted server-side (see openchamber:notification)
if (!questionToastShownRef.current.has(toastKey)) {
setTimeout(() => {
@@ -1606,6 +1478,34 @@ export const useEventStream = () => {
break;
}
case 'openchamber:notification': {
const title = typeof (props as { title?: unknown }).title === 'string' ? (props as { title: string }).title : '';
const body = typeof (props as { body?: unknown }).body === 'string' ? (props as { body: string }).body : '';
const tag = typeof (props as { tag?: unknown }).tag === 'string' ? (props as { tag: string }).tag : undefined;
const requireHidden = Boolean((props as { requireHidden?: unknown }).requireHidden);
if (requireHidden && visibilityStateRef.current !== 'hidden') {
break;
}
// Desktop local instance uses native notifications via sidecar stdout.
// Avoid duplicating via UI runtime notifications.
if (isDesktopLocalOriginActive()) {
break;
}
if (!nativeNotificationsEnabled) {
break;
}
const runtimeAPIs = getRegisteredRuntimeAPIs();
if (runtimeAPIs?.notifications && title) {
void runtimeAPIs.notifications.notifyAgentCompletion({ title, body, tag });
}
break;
}
case 'todo.updated': {
const sessionId = typeof props.sessionID === 'string' ? props.sessionID : null;
const todos = Array.isArray(props.todos) ? props.todos : null;
@@ -1621,8 +1521,6 @@ export const useEventStream = () => {
}, [
currentSessionId,
nativeNotificationsEnabled,
notificationMode,
notifyOnSubtasks,
addStreamingPart,
completeStreamingMessage,
updateMessageInfo,
@@ -1635,7 +1533,7 @@ export const useEventStream = () => {
applySessionMetadata,
trackMessage,
reportMessage,
updateSessionActivityPhase,
updateSessionStatus,
updateSession,
removeSessionFromStore,
bootstrapState,
@@ -1651,7 +1549,6 @@ export const useEventStream = () => {
const debugConnectionState = React.useCallback(() => {
if (streamDebugEnabled()) {
console.debug('[useEventStream] Connection state:', {
isDesktopRuntime: isDesktopRuntimeRef.current,
hasUnsubscribe: Boolean(unsubscribeRef.current),
currentSessionId: currentSessionIdRef.current,
effectiveDirectory,
@@ -1663,8 +1560,6 @@ export const useEventStream = () => {
}
}, [effectiveDirectory]);
const waitForDesktopBridge = React.useCallback(async (): Promise<boolean> => true, []);
const stopStream = React.useCallback(() => {
if (isCleaningUpRef.current) {
if (streamDebugEnabled()) {
@@ -1708,13 +1603,6 @@ export const useEventStream = () => {
return;
}
if (isDesktopRuntimeRef.current) {
const bridgeReady = await waitForDesktopBridge();
if (!bridgeReady) {
console.warn('[useEventStream] Desktop bridge not ready, falling back to SDK');
}
}
if (options?.resetAttempts) {
reconnectAttemptsRef.current = 0;
}
@@ -1740,9 +1628,9 @@ export const useEventStream = () => {
publishStatus('connected', null);
checkConnection();
// Always refresh session activity status on connect to detect any
// Always refresh session status on connect to detect any
// already-running sessions (e.g., started via CLI before UI opened)
void refreshSessionActivityStatus();
void refreshSessionStatus();
if (shouldRefresh) {
void bootstrapState('sse_reconnected');
@@ -1825,8 +1713,7 @@ export const useEventStream = () => {
requestSessionMetadataRefresh,
handleEvent,
effectiveDirectory,
refreshSessionActivityStatus,
waitForDesktopBridge,
refreshSessionStatus,
debugConnectionState,
bootstrapState
]);
@@ -1872,24 +1759,13 @@ export const useEventStream = () => {
}, [scheduleReconnect]);
React.useEffect(() => {
const cooldownTimers = sessionCooldownTimersRef.current;
if (typeof window !== 'undefined') {
window.__messageTracker = trackMessage;
}
let desktopActivityHandler: ((event: CustomEvent<{ sessionId?: string; phase?: string }>) => void) | null = null;
if (isDesktopRuntimeRef.current && typeof window !== 'undefined') {
desktopActivityHandler = (event: CustomEvent<{ sessionId?: string; phase?: string }>) => {
const sessionId = typeof event.detail?.sessionId === 'string' ? event.detail.sessionId : null;
const phase = typeof event.detail?.phase === 'string' ? event.detail.phase : null;
if (sessionId && (phase === 'idle' || phase === 'busy' || phase === 'cooldown')) {
updateSessionActivityPhase(sessionId, phase);
requestSessionMetadataRefresh(sessionId);
}
};
window.addEventListener('openchamber:session-activity', desktopActivityHandler as EventListener);
}
// No-op
const desktopActivityHandler = null;
const clearPauseTimeout = () => {
if (pauseTimeoutRef.current) {
@@ -1924,7 +1800,7 @@ export const useEventStream = () => {
requestSessionMetadataRefresh(sessionId);
}
void refreshSessionActivityStatus();
void refreshSessionStatus();
publishStatus('connecting', 'Resuming stream');
startStream({ resetAttempts: true });
}
@@ -1949,7 +1825,7 @@ export const useEventStream = () => {
requestSessionMetadataRefresh(sessionId);
scheduleSoftResync(sessionId, 'window_focus', getActiveSessionWindow());
}
void refreshSessionActivityStatus();
void refreshSessionStatus();
publishStatus('connecting', 'Resuming stream');
startStream({ resetAttempts: true });
@@ -1989,7 +1865,7 @@ export const useEventStream = () => {
void scheduleSoftResync(sessionId, 'page_show', getActiveSessionWindow());
requestSessionMetadataRefresh(sessionId);
}
void refreshSessionActivityStatus();
void refreshSessionStatus();
startStream({ resetAttempts: true });
}
};
@@ -2018,12 +1894,12 @@ export const useEventStream = () => {
if (!shouldHoldConnection()) return;
const now = Date.now();
const hasBusySessions = Array.from(useSessionStore.getState().sessionActivityPhase?.values?.() ?? []).some(
(phase) => phase === 'busy' || phase === 'cooldown'
const hasBusySessions = Array.from(useSessionStore.getState().sessionStatus?.values?.() ?? []).some(
(status) => status?.type === 'busy' || status?.type === 'retry'
);
if (hasBusySessions) {
void refreshSessionActivityStatus();
void refreshSessionStatus();
}
if (now - lastEventTimestampRef.current > 45000) {
Promise.resolve().then(async () => {
@@ -2048,9 +1924,7 @@ export const useEventStream = () => {
return () => {
clearTimeout(startTimer);
if (desktopActivityHandler && typeof window !== 'undefined') {
window.removeEventListener('openchamber:session-activity', desktopActivityHandler as EventListener);
}
void desktopActivityHandler;
if (typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', handleVisibilityChange);
@@ -2071,8 +1945,6 @@ export const useEventStream = () => {
staleCheckIntervalRef.current = null;
}
cooldownTimers.forEach((timer) => clearTimeout(timer));
cooldownTimers.clear();
messageCache.clear();
// eslint-disable-next-line react-hooks/exhaustive-deps -- Intentionally accessing current ref value at cleanup time
notifiedMessagesRef.current.clear();
@@ -2102,13 +1974,12 @@ export const useEventStream = () => {
scheduleReconnect,
loadMessages,
requestSessionMetadataRefresh,
updateSessionActivityPhase,
refreshSessionActivityStatus,
updateSessionStatus,
refreshSessionStatus,
shouldHoldConnection,
loadSessions,
maybeBootstrapIfStale,
resyncMessages,
scheduleSoftResync,
notifyOnSubtasks,
]);
};
+3 -3
View File
@@ -1,11 +1,11 @@
import { useCallback, useEffect, useState } from 'react';
import { isDesktopRuntime, requestDirectoryAccess, startAccessingDirectory, stopAccessingDirectory } from '@/lib/desktop';
import { isTauriShell, requestDirectoryAccess, startAccessingDirectory, stopAccessingDirectory } from '@/lib/desktop';
export const useFileSystemAccess = () => {
const [isDesktop, setIsDesktop] = useState(false);
useEffect(() => {
setIsDesktop(isDesktopRuntime());
setIsDesktop(isTauriShell());
}, []);
const requestAccess = useCallback(async (directoryPath: string): Promise<{ success: boolean; path?: string; projectId?: string; error?: string }> => {
@@ -38,4 +38,4 @@ export const useFileSystemAccess = () => {
startAccessing,
stopAccessing
};
};
};
+2 -30
View File
@@ -3,11 +3,11 @@ import { useSessionStore } from '@/stores/useSessionStore';
import { useUIStore } from '@/stores/useUIStore';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useAssistantStatus } from '@/hooks/useAssistantStatus';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { hasModifier } from '@/lib/utils';
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
import { useConfigStore } from '@/stores/useConfigStore';
import { isVSCodeRuntime } from '@/lib/desktop';
import { showOpenCodeStatus } from '@/lib/openCodeStatus';
export const useKeyboardShortcuts = () => {
const { openNewSessionDraft, abortCurrentOperation, armAbortPrompt, clearAbortPrompt, currentSessionId } = useSessionStore();
@@ -24,7 +24,6 @@ export const useKeyboardShortcuts = () => {
const { working } = useAssistantStatus();
const abortPrimedUntilRef = React.useRef<number | null>(null);
const abortPrimedTimeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const isDownloadingLogsRef = React.useRef(false);
const resetAbortPriming = React.useCallback(() => {
if (abortPrimedTimeoutRef.current) {
@@ -44,35 +43,8 @@ export const useKeyboardShortcuts = () => {
}
if (hasModifier(e) && e.shiftKey && e.key.toLowerCase() === 'l') {
const runtimeAPIs = getRegisteredRuntimeAPIs();
const diagnostics = runtimeAPIs?.diagnostics;
if (!diagnostics) {
return;
}
e.preventDefault();
if (isDownloadingLogsRef.current) {
return;
}
isDownloadingLogsRef.current = true;
diagnostics
.downloadLogs()
.then(({ fileName, content }) => {
const finalFileName = fileName || 'openchamber.log';
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = finalFileName;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
})
.finally(() => {
isDownloadingLogsRef.current = false;
});
void showOpenCodeStatus();
return;
}
+101 -53
View File
@@ -4,13 +4,25 @@ import { useSessionStore } from '@/stores/useSessionStore';
import { useUIStore } from '@/stores/useUIStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { sessionEvents } from '@/lib/sessionEvents';
import { isDesktopRuntime } from '@/lib/desktop';
import { isTauriShell } from '@/lib/desktop';
import { useFileSystemAccess } from '@/hooks/useFileSystemAccess';
import { createWorktreeSession } from '@/lib/worktreeSessionCreator';
import { showOpenCodeStatus } from '@/lib/openCodeStatus';
const MENU_ACTION_EVENT = 'openchamber:menu-action';
const CHECK_FOR_UPDATES_EVENT = 'openchamber:check-for-updates';
type TauriEventApi = {
listen?: (
event: string,
handler: (evt: { payload?: unknown }) => void
) => Promise<() => void>;
};
type TauriGlobal = {
event?: TauriEventApi;
};
type MenuAction =
| 'about'
@@ -21,6 +33,7 @@ type MenuAction =
| 'change-workspace'
| 'open-git-tab'
| 'open-diff-tab'
| 'open-files-tab'
| 'open-terminal-tab'
| 'theme-light'
| 'theme-dark'
@@ -46,10 +59,9 @@ export const useMenuActions = (
const { addProject } = useProjectsStore();
const { requestAccess, startAccessing } = useFileSystemAccess();
const { setThemeMode } = useThemeSystem();
const isDownloadingLogsRef = React.useRef(false);
const handleChangeWorkspace = React.useCallback(() => {
if (isDesktopRuntime()) {
if (isTauriShell()) {
requestAccess('')
.then(async (result) => {
if (!result.success || !result.path) {
@@ -80,15 +92,13 @@ export const useMenuActions = (
console.error('Desktop: Error selecting directory:', error);
toast.error('Failed to select directory');
});
} else {
sessionEvents.requestDirectoryDialog();
}
sessionEvents.requestDirectoryDialog();
}, [addProject, requestAccess, startAccessing]);
React.useEffect(() => {
const handleMenuAction = (event: Event) => {
const action = (event as CustomEvent<MenuAction>).detail;
const handleAction = React.useCallback(
(action: MenuAction) => {
switch (action) {
case 'about':
setAboutDialogOpen(true);
@@ -130,6 +140,12 @@ export const useMenuActions = (
break;
}
case 'open-files-tab': {
const { activeMainTab } = useUIStore.getState();
setActiveMainTab(activeMainTab === 'files' ? 'chat' : 'files');
break;
}
case 'open-terminal-tab': {
const { activeMainTab } = useUIStore.getState();
setActiveMainTab(activeMainTab === 'terminal' ? 'chat' : 'terminal');
@@ -161,54 +177,86 @@ export const useMenuActions = (
break;
case 'download-logs': {
const runtimeAPIs = getRegisteredRuntimeAPIs();
const diagnostics = runtimeAPIs?.diagnostics;
if (!diagnostics || isDownloadingLogsRef.current) {
break;
}
isDownloadingLogsRef.current = true;
diagnostics
.downloadLogs()
.then(({ fileName, content }) => {
const finalFileName = fileName || 'openchamber.log';
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = finalFileName;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
toast.success('Logs saved', {
description: `Downloaded to ~/Downloads/${finalFileName}`,
});
})
.catch(() => {
toast.error('Failed to download logs');
})
.finally(() => {
isDownloadingLogsRef.current = false;
});
void showOpenCodeStatus().catch(() => {
toast.error('Failed to collect OpenCode status');
});
break;
}
}
},
[
handleChangeWorkspace,
onToggleMemoryDebug,
openNewSessionDraft,
setAboutDialogOpen,
setActiveMainTab,
setSessionSwitcherOpen,
setSettingsDialogOpen,
setThemeMode,
toggleCommandPalette,
toggleHelpDialog,
toggleSidebar,
]
);
React.useEffect(() => {
const handleMenuAction = (event: Event) => {
const action = (event as CustomEvent<MenuAction>).detail;
if (!action) return;
handleAction(action);
};
window.addEventListener(MENU_ACTION_EVENT, handleMenuAction);
return () => window.removeEventListener(MENU_ACTION_EVENT, handleMenuAction);
}, [
openNewSessionDraft,
toggleCommandPalette,
toggleHelpDialog,
toggleSidebar,
setSessionSwitcherOpen,
setActiveMainTab,
setSettingsDialogOpen,
setAboutDialogOpen,
setThemeMode,
onToggleMemoryDebug,
handleChangeWorkspace,
]);
}, [handleAction]);
React.useEffect(() => {
if (typeof window === 'undefined') return;
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
const listen = tauri?.event?.listen;
if (typeof listen !== 'function') return;
let unlistenMenu: null | (() => void | Promise<void>) = null;
let unlistenUpdate: null | (() => void | Promise<void>) = null;
listen('openchamber:menu-action', (evt) => {
const action = evt?.payload;
if (typeof action !== 'string') return;
handleAction(action as MenuAction);
})
.then((fn) => {
unlistenMenu = fn;
})
.catch(() => {
// ignore
});
listen('openchamber:check-for-updates', () => {
window.dispatchEvent(new Event(CHECK_FOR_UPDATES_EVENT));
})
.then((fn) => {
unlistenUpdate = fn;
})
.catch(() => {
// ignore
});
return () => {
const cleanup = async () => {
try {
const a = unlistenMenu?.();
if (a instanceof Promise) await a;
} catch {
// ignore
}
try {
const b = unlistenUpdate?.();
if (b instanceof Promise) await b;
} catch {
// ignore
}
};
void cleanup();
};
}, [handleAction]);
};
-2
View File
@@ -15,6 +15,4 @@ export const useRuntimeAPI = <TValue,>(selector: RuntimeAPISelector<TValue>): TV
return selector(apis);
};
export const useIsDesktopRuntime = (): boolean => useRuntimeAPI((api) => api.runtime.isDesktop);
export const useIsVSCodeRuntime = (): boolean => useRuntimeAPI((api) => api.runtime.isVSCode);
+9 -5
View File
@@ -3,7 +3,8 @@
import React from 'react';
import { useSessionStore } from '@/stores/useSessionStore';
export type SessionActivityPhase = 'idle' | 'busy' | 'cooldown';
// Mirrors OpenCode SessionStatus: busy|retry|idle.
export type SessionActivityPhase = 'idle' | 'busy' | 'retry';
export interface SessionActivityResult {
@@ -13,6 +14,7 @@ export interface SessionActivityResult {
isBusy: boolean;
// Kept for backward compatibility; always false with server session.status.
isCooldown: boolean;
}
@@ -26,10 +28,11 @@ const IDLE_RESULT: SessionActivityResult = {
export function useSessionActivity(sessionId: string | null | undefined): SessionActivityResult {
const phase = useSessionStore((state) => {
if (!sessionId || !state.sessionActivityPhase) {
if (!sessionId || !state.sessionStatus) {
return 'idle' as SessionActivityPhase;
}
return state.sessionActivityPhase.get(sessionId) ?? ('idle' as SessionActivityPhase);
const status = state.sessionStatus.get(sessionId);
return (status?.type ?? 'idle') as SessionActivityPhase;
});
return React.useMemo<SessionActivityResult>(() => {
@@ -37,10 +40,11 @@ export function useSessionActivity(sessionId: string | null | undefined): Sessio
return IDLE_RESULT;
}
const isBusy = phase === 'busy';
const isCooldown = phase === 'cooldown';
// No cooldown in server session.status; treat retry as working.
const isCooldown = false;
return {
phase,
isWorking: isBusy || isCooldown,
isWorking: phase === 'busy' || phase === 'retry',
isBusy,
isCooldown,
};
@@ -20,17 +20,15 @@ export const useSessionStatusBootstrap = () => {
const statusMap = await opencodeClient.getGlobalSessionStatus();
if (cancelled || !statusMap) return;
const phases = new Map<string, 'idle' | 'busy' | 'cooldown'>();
const nextStatus = new Map<string, SessionStatusPayload>();
Object.entries(statusMap).forEach(([sessionId, raw]) => {
if (!sessionId || !raw) return;
const status = raw as SessionStatusPayload;
const phase: 'idle' | 'busy' | 'cooldown' =
status.type === 'busy' || status.type === 'retry' ? 'busy' : 'idle';
phases.set(sessionId, phase);
nextStatus.set(sessionId, status);
});
if (phases.size > 0) {
useSessionStore.setState({ sessionActivityPhase: phases });
if (nextStatus.size > 0) {
useSessionStore.setState({ sessionStatus: nextStatus });
}
} catch { /* ignored */ }
};
@@ -42,4 +40,3 @@ export const useSessionStatusBootstrap = () => {
};
}, []);
};
+23
View File
@@ -56,6 +56,10 @@ textarea[data-chat-input="true"]:focus-visible {
[data-scroll-shadow="true"][data-orientation="vertical"] {
mask-mode: alpha;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
}
[data-scroll-shadow="true"][data-orientation="vertical"][data-top-bottom-scroll="true"] {
@@ -66,6 +70,13 @@ textarea[data-chat-input="true"]:focus-visible {
#000 calc(100% - var(--scroll-shadow-size)),
transparent 100%
);
-webkit-mask-image: linear-gradient(
to bottom,
transparent 0%,
#000 var(--scroll-shadow-size),
#000 calc(100% - var(--scroll-shadow-size)),
transparent 100%
);
}
[data-scroll-shadow="true"][data-orientation="vertical"][data-top-scroll="true"] {
@@ -75,6 +86,12 @@ textarea[data-chat-input="true"]:focus-visible {
#000 var(--scroll-shadow-size),
#000 100%
);
-webkit-mask-image: linear-gradient(
to bottom,
transparent 0%,
#000 var(--scroll-shadow-size),
#000 100%
);
}
[data-scroll-shadow="true"][data-orientation="vertical"][data-bottom-scroll="true"] {
@@ -84,6 +101,12 @@ textarea[data-chat-input="true"]:focus-visible {
#000 calc(100% - var(--scroll-shadow-size)),
transparent 100%
);
-webkit-mask-image: linear-gradient(
to bottom,
#000 0%,
#000 calc(100% - var(--scroll-shadow-size)),
transparent 100%
);
}
+1
View File
@@ -378,6 +378,7 @@ export interface ProjectEntry {
addedAt?: number;
lastOpenedAt?: number;
worktreeDefaults?: WorktreeDefaults;
sidebarCollapsed?: boolean;
}
export interface SettingsPayload {
+3 -26
View File
@@ -1,4 +1,3 @@
import { isDesktopRuntime } from '@/lib/desktop';
import { useUIStore } from '@/stores/useUIStore';
export interface AppearancePreferences {
@@ -37,20 +36,14 @@ const extractRawAppearance = (data: unknown): RawAppearancePayload | null => {
};
export const saveAppearancePreferences = (preferences: AppearancePreferences): boolean => {
if (typeof window === 'undefined' || !isDesktopRuntime()) {
return false;
}
const api = window.opencodeAppearance;
if (!api || typeof api.save !== 'function') {
if (typeof window === 'undefined') {
return false;
}
try {
void api.save(preferences);
localStorage.setItem('appearance-preferences', JSON.stringify(preferences));
return true;
} catch (error) {
console.warn('Failed to save appearance preferences to desktop storage:', error);
} catch {
return false;
}
};
@@ -68,22 +61,6 @@ export const loadAppearancePreferences = async (): Promise<AppearancePreferences
return null;
}
if (isDesktopRuntime()) {
const api = window.opencodeAppearance;
if (!api || typeof api.load !== 'function') {
return null;
}
try {
const raw = await api.load();
const payload = typeof raw === 'object' && raw !== null ? (raw as RawAppearancePayload) : null;
return sanitizePreferences(payload);
} catch (error) {
console.warn('Failed to load appearance preferences from desktop storage:', error);
return null;
}
}
const stored = localStorage.getItem('appearance-preferences');
if (!stored) {
return null;
+3 -9
View File
@@ -216,13 +216,7 @@ export const debugUtils = {
const runtimeApis = typeof window !== 'undefined'
? (window as any).__OPENCHAMBER_RUNTIME_APIS__
: null;
const desktopServer = typeof window !== 'undefined'
? (window as any).__OPENCHAMBER_DESKTOP_SERVER__
: null;
const isDesktopRuntime = Boolean(
runtimeApis?.runtime?.isDesktop ||
(typeof window !== 'undefined' && (window as any).opencodeDesktop)
);
const isTauriShell = typeof window !== 'undefined' && Boolean((window as any).__TAURI__);
const safeJson = async (resp: Response) => {
try {
@@ -302,10 +296,10 @@ export const debugUtils = {
const report = {
runtime: {
platform: runtimeApis?.runtime?.platform ?? null,
isDesktop: isDesktopRuntime,
isDesktop: isTauriShell,
isVSCode: Boolean(runtimeApis?.runtime?.isVSCode),
hasRuntimeApis: Boolean(runtimeApis),
desktopServerOrigin: desktopServer?.origin ?? null,
desktopServerOrigin: null,
},
location: typeof window !== 'undefined'
? {
+160 -178
View File
@@ -21,14 +21,6 @@ export type UpdateProgress = {
total?: number;
};
export type DesktopServerInfo = {
webPort: number | null;
openCodePort: number | null;
host: string | null;
ready: boolean;
cliAvailable: boolean;
};
export type SkillCatalogConfig = {
id: string;
label: string;
@@ -74,6 +66,9 @@ export type DesktopSettings = {
padding?: number;
cornerRadius?: number;
inputBarOffset?: number;
favoriteModels?: Array<{ providerID: string; modelID: string }>;
recentModels?: Array<{ providerID: string; modelID: string }>;
diffLayoutPreference?: 'dynamic' | 'inline' | 'side-by-side';
diffViewMode?: 'single' | 'stacked';
directoryShowHidden?: boolean;
@@ -88,34 +83,58 @@ export type DesktopSettings = {
skillCatalogs?: SkillCatalogConfig[];
};
export type DesktopSettingsApi = {
getSettings: () => Promise<DesktopSettings>;
updateSettings: (changes: Partial<DesktopSettings>) => Promise<DesktopSettings>;
type TauriGlobal = {
core?: {
invoke?: (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;
};
dialog?: {
open?: (options: Record<string, unknown>) => Promise<unknown>;
};
event?: {
listen?: (
event: string,
handler: (evt: { payload?: unknown }) => void,
) => Promise<() => void>;
};
};
export type DesktopApi = {
homeDirectory?: string;
macosMajorVersion?: number | null;
getServerInfo: () => Promise<DesktopServerInfo>;
restartOpenCode: () => Promise<{ success: boolean }>;
shutdown: () => Promise<{ success: boolean }>;
markRendererReady?: () => Promise<void> | void;
windowControl?: (action: 'close' | 'minimize' | 'maximize') => Promise<{ success: boolean }>;
getHomeDirectory?: () => Promise<{ success: boolean; path: string | null }>;
getSettings?: () => Promise<DesktopSettings>;
updateSettings?: (changes: Partial<DesktopSettings>) => Promise<DesktopSettings>;
requestDirectoryAccess?: (path: string) => Promise<{ success: boolean; path?: string; projectId?: string; error?: string }>;
startAccessingDirectory?: (path: string) => Promise<{ success: boolean; error?: string }>;
stopAccessingDirectory?: (path: string) => Promise<{ success: boolean; error?: string }>;
notifyAssistantCompletion?: (payload?: AssistantNotificationPayload) => Promise<{ success: boolean }>;
checkForUpdates?: () => Promise<UpdateInfo>;
downloadUpdate?: (onProgress?: (progress: UpdateProgress) => void) => Promise<void>;
restartToUpdate?: () => Promise<void>;
openExternal?: (url: string) => Promise<{ success: boolean; error?: string }>;
export const isTauriShell = (): boolean => {
if (typeof window === 'undefined') return false;
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
return typeof tauri?.core?.invoke === 'function';
};
export const isDesktopRuntime = (): boolean =>
typeof window !== "undefined" && typeof window.opencodeDesktop !== "undefined";
const normalizeOrigin = (raw: string): string | null => {
const trimmed = raw.trim();
if (!trimmed) return null;
try {
return new URL(trimmed).origin;
} catch {
try {
return new URL(trimmed.endsWith('/') ? trimmed : `${trimmed}/`).origin;
} catch {
return null;
}
}
};
export const isDesktopLocalOriginActive = (): boolean => {
if (typeof window === 'undefined') return false;
const local = typeof window.__OPENCHAMBER_LOCAL_ORIGIN__ === 'string' ? window.__OPENCHAMBER_LOCAL_ORIGIN__ : '';
const localOrigin = normalizeOrigin(local);
const currentOrigin = normalizeOrigin(window.location.origin) || window.location.origin;
return Boolean(localOrigin && currentOrigin && localOrigin === currentOrigin);
};
// Desktop shell detection that doesn't require Tauri IPC availability.
// (Remote pages can temporarily lose window.__TAURI__ if URL doesn't match remote allowlist.)
export const isDesktopShell = (): boolean => {
if (typeof window === 'undefined') return false;
if (typeof window.__OPENCHAMBER_LOCAL_ORIGIN__ === 'string' && window.__OPENCHAMBER_LOCAL_ORIGIN__.length > 0) {
return true;
}
return isTauriShell();
};
export const isVSCodeRuntime = (): boolean => {
if (typeof window === "undefined") return false;
@@ -125,37 +144,19 @@ export const isVSCodeRuntime = (): boolean => {
export const isWebRuntime = (): boolean => {
if (typeof window === "undefined") return false;
// Web runtime: not desktop, not VSCode
return !isDesktopRuntime() && !isVSCodeRuntime();
};
export const getDesktopApi = (): DesktopApi | null => {
if (!isDesktopRuntime()) {
return null;
const apis = (window as { __OPENCHAMBER_RUNTIME_APIS__?: { runtime?: { platform?: string } } }).__OPENCHAMBER_RUNTIME_APIS__;
const platform = apis?.runtime?.platform;
if (platform === 'web') {
return true;
}
return window.opencodeDesktop ?? null;
};
export const getDesktopSettingsApi = (): DesktopSettingsApi | null => {
if (typeof window === 'undefined') {
return null;
if (platform === 'desktop' || platform === 'vscode') {
return false;
}
if (window.opencodeDesktopSettings) {
return window.opencodeDesktopSettings;
}
const base = window.opencodeDesktop;
if (base?.getSettings && base?.updateSettings) {
return {
getSettings: base.getSettings.bind(base),
updateSettings: base.updateSettings.bind(base)
};
}
return null;
// Default: anything that's not VSCode behaves like web (HTTP UI).
return !isVSCodeRuntime();
};
export const getDesktopHomeDirectory = async (): Promise<string | null> => {
const api = getDesktopApi();
if (typeof window !== 'undefined') {
const embedded = window.__OPENCHAMBER_HOME__;
if (embedded && embedded.length > 0) {
@@ -163,148 +164,82 @@ export const getDesktopHomeDirectory = async (): Promise<string | null> => {
}
}
if (!api) {
return null;
}
if (typeof api.homeDirectory === 'string' && api.homeDirectory.length > 0) {
return api.homeDirectory;
}
try {
if (!api.getHomeDirectory) {
return null;
}
const result = await api.getHomeDirectory();
if (result?.success && typeof result.path === 'string' && result.path.length > 0) {
return result.path;
}
} catch (error) {
console.warn('Failed to obtain desktop home directory:', error);
}
return null;
};
export const fetchDesktopServerInfo = async (): Promise<DesktopServerInfo | null> => {
const api = getDesktopApi();
if (!api) {
return null;
}
try {
return await api.getServerInfo();
} catch (error) {
console.warn("Failed to read desktop server info", error);
return null;
}
};
export const isCliAvailable = (): boolean => {
if (typeof window === 'undefined') {
return false;
}
return window.__OPENCHAMBER_DESKTOP_SERVER__?.cliAvailable ?? false;
};
export const getDesktopSettings = async (): Promise<DesktopSettings | null> => {
const api = getDesktopSettingsApi();
if (!api) {
return null;
}
try {
return await api.getSettings();
} catch (error) {
console.warn('Failed to read desktop settings', error);
return null;
}
};
export const updateDesktopSettings = async (
changes: Partial<DesktopSettings>
): Promise<DesktopSettings | null> => {
const api = getDesktopSettingsApi();
if (!api) {
return null;
}
try {
return await api.updateSettings(changes);
} catch (error) {
console.warn('[desktop] Failed to update desktop settings', error);
return null;
}
};
export const requestDirectoryAccess = async (
directoryPath: string
): Promise<{ success: boolean; path?: string; projectId?: string; error?: string }> => {
const api = getDesktopApi();
if (!api || !api.requestDirectoryAccess) {
return { success: true, path: directoryPath };
}
try {
return await api.requestDirectoryAccess(directoryPath);
} catch (error) {
console.warn('Failed to request directory access', error);
return { success: false, error: error instanceof Error ? error.message : String(error) };
// Desktop shell: use native folder picker.
if (isTauriShell()) {
try {
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
const selected = await tauri?.dialog?.open?.({
directory: true,
multiple: false,
title: 'Select Working Directory',
});
if (!selected || typeof selected !== 'string') {
return { success: false, error: 'Directory selection cancelled' };
}
return { success: true, path: selected };
} catch (error) {
console.warn('Failed to request directory access (tauri)', error);
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
return { success: true, path: directoryPath };
};
export const startAccessingDirectory = async (
directoryPath: string
): Promise<{ success: boolean; error?: string }> => {
const api = getDesktopApi();
if (!api || !api.startAccessingDirectory) {
return { success: true };
}
try {
return await api.startAccessingDirectory(directoryPath);
} catch (error) {
console.warn('Failed to start accessing directory', error);
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
void directoryPath;
return { success: true };
};
export const stopAccessingDirectory = async (
directoryPath: string
): Promise<{ success: boolean; error?: string }> => {
const api = getDesktopApi();
if (!api || !api.stopAccessingDirectory) {
return { success: true };
}
try {
return await api.stopAccessingDirectory(directoryPath);
} catch (error) {
console.warn('Failed to stop accessing directory', error);
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
void directoryPath;
return { success: true };
};
export const sendAssistantCompletionNotification = async (
payload?: AssistantNotificationPayload
): Promise<boolean> => {
const api = getDesktopApi();
if (!api || !api.notifyAssistantCompletion) {
return false;
}
try {
const result = await api.notifyAssistantCompletion(payload ?? {});
return Boolean(result?.success);
} catch (error) {
console.warn('Failed to send assistant completion notification', error);
return false;
if (isTauriShell()) {
try {
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
await tauri?.core?.invoke?.('desktop_notify', {
payload: {
title: payload?.title,
body: payload?.body,
tag: 'openchamber-agent-complete',
},
});
return true;
} catch (error) {
console.warn('Failed to send assistant completion notification (tauri)', error);
return false;
}
}
return false;
};
export const checkForDesktopUpdates = async (): Promise<UpdateInfo | null> => {
const api = getDesktopApi();
if (!api || !api.checkForUpdates) {
if (!isTauriShell() || !isDesktopLocalOriginActive()) {
return null;
}
try {
return await api.checkForUpdates();
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
const info = await tauri?.core?.invoke?.('desktop_check_for_updates');
return info as UpdateInfo;
} catch (error) {
console.warn('Failed to check for updates', error);
console.warn('Failed to check for updates (tauri)', error);
return null;
}
};
@@ -312,29 +247,76 @@ export const checkForDesktopUpdates = async (): Promise<UpdateInfo | null> => {
export const downloadDesktopUpdate = async (
onProgress?: (progress: UpdateProgress) => void
): Promise<boolean> => {
const api = getDesktopApi();
if (!api || !api.downloadUpdate) {
if (!isTauriShell() || !isDesktopLocalOriginActive()) {
return false;
}
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
let unlisten: null | (() => void | Promise<void>) = null;
let downloaded = 0;
let total: number | undefined;
try {
await api.downloadUpdate(onProgress);
if (typeof onProgress === 'function' && tauri?.event?.listen) {
unlisten = await tauri.event.listen('openchamber:update-progress', (evt) => {
const payload = evt?.payload;
if (!payload || typeof payload !== 'object') return;
const data = payload as { event?: unknown; data?: unknown };
const eventName = typeof data.event === 'string' ? data.event : null;
const eventData = data.data && typeof data.data === 'object' ? (data.data as Record<string, unknown>) : null;
if (eventName === 'Started') {
downloaded = 0;
total = typeof eventData?.contentLength === 'number' ? (eventData.contentLength as number) : undefined;
onProgress({ downloaded, total });
return;
}
if (eventName === 'Progress') {
const d = eventData?.downloaded;
const t = eventData?.total;
if (typeof d === 'number') downloaded = d;
if (typeof t === 'number') total = t;
onProgress({ downloaded, total });
return;
}
if (eventName === 'Finished') {
onProgress({ downloaded, total });
}
});
}
await tauri?.core?.invoke?.('desktop_download_and_install_update');
return true;
} catch (error) {
console.warn('Failed to download update', error);
console.warn('Failed to download update (tauri)', error);
return false;
} finally {
if (unlisten) {
try {
const result = unlisten();
if (result instanceof Promise) {
await result;
}
} catch {
// ignored
}
}
}
};
export const restartToApplyUpdate = async (): Promise<boolean> => {
const api = getDesktopApi();
if (!api || !api.restartToUpdate) {
if (!isTauriShell() || !isDesktopLocalOriginActive()) {
return false;
}
try {
await api.restartToUpdate();
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
await tauri?.core?.invoke?.('desktop_restart');
return true;
} catch (error) {
console.warn('Failed to restart for update', error);
console.warn('Failed to restart for update (tauri)', error);
return false;
}
};
+110
View File
@@ -0,0 +1,110 @@
import { isTauriShell } from '@/lib/desktop';
type TauriInvoke = (cmd: string, args?: Record<string, unknown>) => Promise<unknown>;
type TauriGlobal = {
core?: {
invoke?: TauriInvoke;
};
};
export type DesktopHost = {
id: string;
label: string;
url: string;
};
export type DesktopHostsConfig = {
hosts: DesktopHost[];
defaultHostId: string | null;
};
export type HostProbeResult = {
status: 'ok' | 'auth' | 'unreachable';
latencyMs: number;
};
const isRecord = (value: unknown): value is Record<string, unknown> => {
return typeof value === 'object' && value !== null;
};
const readString = (obj: Record<string, unknown>, key: string): string | null => {
const val = obj[key];
return typeof val === 'string' ? val : null;
};
const readNumber = (obj: Record<string, unknown>, key: string): number | null => {
const val = obj[key];
return typeof val === 'number' && Number.isFinite(val) ? val : null;
};
const parseHost = (value: unknown): DesktopHost | null => {
if (!isRecord(value)) return null;
const id = readString(value, 'id');
const label = readString(value, 'label');
const url = readString(value, 'url');
if (!id || !label || !url) return null;
return { id, label, url };
};
const getInvoke = (): TauriInvoke | null => {
if (!isTauriShell()) return null;
const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__;
return typeof tauri?.core?.invoke === 'function' ? tauri.core.invoke : null;
};
export const desktopHostsGet = async (): Promise<DesktopHostsConfig> => {
const invoke = getInvoke();
if (!invoke) {
return { hosts: [], defaultHostId: 'local' };
}
const raw = await invoke('desktop_hosts_get');
if (!isRecord(raw)) {
return { hosts: [], defaultHostId: null };
}
const hostsRaw = raw.hosts;
const hosts = Array.isArray(hostsRaw)
? hostsRaw.map(parseHost).filter((h): h is DesktopHost => Boolean(h))
: [];
const defaultHostId =
readString(raw, 'defaultHostId') ||
readString(raw, 'default_host_id') ||
readString(raw, 'defaultHostID');
return { hosts, defaultHostId };
};
export const desktopHostsSet = async (config: DesktopHostsConfig): Promise<void> => {
const invoke = getInvoke();
if (!invoke) return;
await invoke('desktop_hosts_set', {
config: {
hosts: config.hosts,
defaultHostId: config.defaultHostId,
},
});
};
export const desktopHostProbe = async (url: string): Promise<HostProbeResult> => {
const invoke = getInvoke();
if (!invoke) {
return { status: 'unreachable', latencyMs: 0 };
}
const raw = await invoke('desktop_host_probe', { url });
if (!isRecord(raw)) {
return { status: 'unreachable', latencyMs: 0 };
}
const rawStatus = raw.status;
const status: HostProbeResult['status'] =
rawStatus === 'ok' || rawStatus === 'auth' || rawStatus === 'unreachable'
? rawStatus
: 'unreachable';
const latencyMs = readNumber(raw, 'latencyMs') ?? readNumber(raw, 'latency_ms') ?? 0;
return { status, latencyMs };
};
+9 -8
View File
@@ -1,4 +1,5 @@
import React from 'react';
import { isTauriShell } from '@/lib/desktop';
export type DeviceType = 'desktop' | 'mobile' | 'tablet';
@@ -28,7 +29,7 @@ export const BREAKPOINTS = {
} as const;
const setRootDeviceAttributes = (
isDesktopRuntime: boolean,
isTauriShellRuntime: boolean,
deviceType: DeviceType,
hasTouchInput: boolean,
) => {
@@ -49,7 +50,7 @@ const setRootDeviceAttributes = (
: 'device-desktop'
);
if (isDesktopRuntime) {
if (isTauriShellRuntime) {
root.classList.add('desktop-runtime');
root.style.setProperty('--is-mobile', '0');
root.style.setProperty('--device-type', 'desktop');
@@ -81,7 +82,7 @@ export function getDeviceInfo(): DeviceInfo {
const noHover = hoverQuery?.matches ?? false;
const maxTouchPoints = typeof navigator !== 'undefined' ? navigator.maxTouchPoints ?? 0 : 0;
const isDesktopRuntime = typeof window !== 'undefined' && typeof window.opencodeDesktop !== 'undefined';
const isTauriShellRuntime = isTauriShell();
const hasTouchInput = prefersCoarsePointer || noHover || maxTouchPoints > 0;
@@ -93,7 +94,7 @@ export function getDeviceInfo(): DeviceInfo {
let isDesktop = !hasTouchInput || width > BREAKPOINTS.lg;
let deviceType: DeviceType = 'desktop';
if (isDesktopRuntime) {
if (isTauriShellRuntime) {
isMobile = false;
isTablet = false;
isDesktop = true;
@@ -107,7 +108,7 @@ export function getDeviceInfo(): DeviceInfo {
deviceType = 'desktop';
}
setRootDeviceAttributes(isDesktopRuntime, deviceType, hasTouchInput);
setRootDeviceAttributes(isTauriShellRuntime, deviceType, hasTouchInput);
let breakpoint: keyof typeof BREAKPOINTS = 'xs';
for (const [key, value] of Object.entries(BREAKPOINTS)) {
@@ -130,7 +131,7 @@ export function getDeviceInfo(): DeviceInfo {
export function isMobileDeviceViaCSS(): boolean {
if (typeof window === 'undefined') return false;
if (typeof window.opencodeDesktop !== 'undefined') {
if (typeof window !== 'undefined' && isTauriShell()) {
return false;
}
@@ -205,7 +206,7 @@ export function useDeviceInfo(): DeviceInfo {
React.useEffect(() => {
if (typeof window === 'undefined') return;
const isDesktopRuntime = typeof window.opencodeDesktop !== 'undefined';
const isTauriShellRuntime = isTauriShell();
const supportsMatchMedia = typeof window.matchMedia === 'function';
const pointerQuery = supportsMatchMedia ? window.matchMedia('(pointer: coarse)') : null;
const hoverQuery = supportsMatchMedia ? window.matchMedia('(hover: none)') : null;
@@ -213,7 +214,7 @@ export function useDeviceInfo(): DeviceInfo {
const noHover = hoverQuery?.matches ?? false;
const maxTouchPoints = typeof navigator !== 'undefined' ? navigator.maxTouchPoints ?? 0 : 0;
const hasTouchInput = prefersCoarsePointer || noHover || maxTouchPoints > 0;
setRootDeviceAttributes(isDesktopRuntime, deviceInfo.deviceType, hasTouchInput);
setRootDeviceAttributes(isTauriShellRuntime, deviceInfo.deviceType, hasTouchInput);
}, [deviceInfo.deviceType, deviceInfo.hasTouchInput]);
return deviceInfo;
+76
View File
@@ -0,0 +1,76 @@
import { useUIStore } from '@/stores/useUIStore';
import { updateDesktopSettings } from '@/lib/persistence';
import { isVSCodeRuntime } from '@/lib/desktop';
type ModelRef = { providerID: string; modelID: string };
const refsEqual = (a: ModelRef[], b: ModelRef[]): boolean => {
if (a === b) return true;
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i += 1) {
if (a[i]?.providerID !== b[i]?.providerID) return false;
if (a[i]?.modelID !== b[i]?.modelID) return false;
}
return true;
};
export const startModelPrefsAutoSave = () => {
if (typeof window === 'undefined') {
return () => {};
}
if (isVSCodeRuntime()) {
return () => {};
}
let timer: number | null = null;
let lastSent: { favoriteModels: ModelRef[]; recentModels: ModelRef[] } | null = null;
let didSkipInitial = false;
const flush = () => {
timer = null;
const state = useUIStore.getState();
const payload = { favoriteModels: state.favoriteModels, recentModels: state.recentModels };
if (
lastSent &&
refsEqual(lastSent.favoriteModels, payload.favoriteModels) &&
refsEqual(lastSent.recentModels, payload.recentModels)
) {
return;
}
lastSent = {
favoriteModels: payload.favoriteModels.slice(),
recentModels: payload.recentModels.slice(),
};
void updateDesktopSettings(payload).catch(() => {});
};
const schedule = () => {
if (!didSkipInitial) {
didSkipInitial = true;
return;
}
if (timer !== null) {
window.clearTimeout(timer);
}
timer = window.setTimeout(flush, 1200);
};
const unsubscribe = useUIStore.subscribe((state, prevState) => {
const next = { favoriteModels: state.favoriteModels, recentModels: state.recentModels };
const prev = { favoriteModels: prevState.favoriteModels, recentModels: prevState.recentModels };
if (refsEqual(next.favoriteModels, prev.favoriteModels) && refsEqual(next.recentModels, prev.recentModels)) {
return;
}
schedule();
});
return () => {
unsubscribe();
if (timer !== null) {
window.clearTimeout(timer);
}
};
};
+161
View File
@@ -0,0 +1,161 @@
import { useSessionStore } from '@/stores/useSessionStore';
import { useUIStore } from '@/stores/useUIStore';
declare const __APP_VERSION__: string | undefined;
type ProbeResult = {
ok: boolean;
status: number;
elapsedMs: number;
summary: string;
};
const getCurrentDirectory = (): string => {
const state = useSessionStore.getState();
const currentSessionId = state.currentSessionId;
if (!currentSessionId) return '';
const session = state.sessions.find((s) => s.id === currentSessionId);
return typeof session?.directory === 'string' ? session.directory : '';
};
const safeFetch = async (input: string, timeoutMs = 6000): Promise<ProbeResult> => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
const startedAt = Date.now();
try {
const resp = await fetch(input, {
method: 'GET',
headers: { Accept: 'application/json' },
signal: controller.signal,
});
const elapsedMs = Date.now() - startedAt;
const contentType = resp.headers.get('content-type') || '';
const lower = contentType.toLowerCase();
const isJson = lower.includes('json') && !lower.includes('text/html');
let summary = '';
if (isJson) {
const json = await resp.json().catch(() => null);
if (Array.isArray(json)) {
summary = `json[array] len=${json.length}`;
} else if (json && typeof json === 'object') {
const keys = Object.keys(json).slice(0, 8);
summary = `json[object] keys=${keys.join(',')}${Object.keys(json).length > keys.length ? ',…' : ''}`;
} else {
summary = `json[${typeof json}]`;
}
} else {
summary = contentType ? `content-type=${contentType}` : 'no content-type';
}
return { ok: resp.ok && isJson, status: resp.status, elapsedMs, summary };
} catch (error) {
const elapsedMs = Date.now() - startedAt;
const isAbort =
controller.signal.aborted ||
(error instanceof Error && (error.name === 'AbortError' || error.message.toLowerCase().includes('aborted')));
const message = isAbort
? `timeout after ${timeoutMs}ms`
: error instanceof Error
? error.message
: String(error);
return { ok: false, status: 0, elapsedMs, summary: `error=${message}` };
} finally {
clearTimeout(timeout);
}
};
const formatIso = (timestamp: number | null | undefined): string => {
if (!timestamp || !Number.isFinite(timestamp)) return '(n/a)';
try {
return new Date(timestamp).toISOString();
} catch {
return '(invalid)';
}
};
export const buildOpenCodeStatusReport = async (): Promise<string> => {
const now = new Date();
const appVersion = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : '(unknown)';
const platform = typeof navigator !== 'undefined' ? navigator.userAgent : '(no navigator)';
const directory = getCurrentDirectory();
const eventStreamStatus = useUIStore.getState().eventStreamStatus;
const origin = typeof window !== 'undefined' ? window.location.origin : '';
const apiBase = origin ? `${origin.replace(/\/+$/, '')}/api/` : '';
const buildProbeUrl = (pathname: string, includeDirectory = true): string | null => {
if (!apiBase) return null;
const url = new URL(pathname.replace(/^\/+/, ''), apiBase);
if (includeDirectory && directory) {
url.searchParams.set('directory', directory);
}
return url.toString();
};
const probeTargets: Array<{ label: string; path: string; includeDirectory?: boolean; timeoutMs?: number }> = [
{ label: 'health', path: '/global/health', includeDirectory: false },
{ label: 'config', path: '/config', includeDirectory: true },
{ label: 'providers', path: '/config/providers', includeDirectory: true },
{ label: 'agents', path: '/agent', includeDirectory: true, timeoutMs: 12000 },
{ label: 'commands', path: '/command', includeDirectory: true, timeoutMs: 10000 },
{ label: 'project', path: '/project/current', includeDirectory: true },
{ label: 'path', path: '/path', includeDirectory: true },
{ label: 'sessions', path: '/session', includeDirectory: true, timeoutMs: 12000 },
{ label: 'sessionStatus', path: '/session/status', includeDirectory: true },
];
const probes = apiBase
? await Promise.all(
probeTargets.map(async (entry) => {
const url = buildProbeUrl(entry.path, entry.includeDirectory !== false);
if (!url) return { label: entry.label, url: '(none)', result: null as ProbeResult | null };
const result = await safeFetch(url, typeof entry.timeoutMs === 'number' ? entry.timeoutMs : undefined);
return { label: entry.label, url, result };
})
)
: [];
const lines: string[] = [];
lines.push(`Time: ${now.toISOString()}`);
lines.push(`OpenChamber version: ${appVersion}`);
lines.push(`Runtime: ${origin || '(unknown)'} (api=${origin ? origin + '/api' : '(unknown)'})`);
lines.push(`Event stream: ${eventStreamStatus}`);
lines.push(`Directory: ${directory || '(none)'}`);
lines.push(`Platform: ${platform}`);
if (typeof window !== 'undefined') {
const injected = (window as unknown as { __OPENCHAMBER_MACOS_MAJOR__?: unknown }).__OPENCHAMBER_MACOS_MAJOR__;
if (typeof injected === 'number' && Number.isFinite(injected) && injected > 0) {
lines.push(`macOS major: ${injected}`);
}
}
lines.push('');
if (probes.length) {
lines.push('OpenCode API probes:');
for (const probe of probes) {
if (!probe.result) {
lines.push(`- ${probe.label}: (no url)`);
continue;
}
const { ok, status, elapsedMs, summary } = probe.result;
const suffix = ok ? '' : ` url=${probe.url}`;
lines.push(`- ${probe.label}: ${ok ? 'ok' : 'fail'} status=${status} time=${elapsedMs}ms ${summary}${suffix}`);
}
} else {
lines.push('OpenCode API probes: (skipped)');
}
lines.push('');
lines.push(`Generated: ${formatIso(Date.now())}`);
return lines.join('\n');
};
export const showOpenCodeStatus = async (): Promise<void> => {
const text = await buildOpenCodeStatusReport();
const ui = useUIStore.getState();
ui.setOpenCodeStatusText(text);
ui.setOpenCodeStatusDialogOpen(true);
};
+75 -16
View File
@@ -1,4 +1,3 @@
import { getDesktopSettings, updateDesktopSettings as updateDesktopSettingsApi, isDesktopRuntime } from '@/lib/desktop';
import type { DesktopSettings } from '@/lib/desktop';
import { useUIStore } from '@/stores/useUIStore';
import { useMessageQueueStore } from '@/stores/messageQueueStore';
@@ -49,6 +48,18 @@ const persistToLocalStorage = (settings: DesktopSettings) => {
} else {
localStorage.removeItem('pinnedDirectories');
}
if (Array.isArray(settings.projects) && settings.projects.length > 0) {
const collapsed = settings.projects
.filter((project) => (project as unknown as { sidebarCollapsed?: boolean }).sidebarCollapsed === true)
.map((project) => project.id)
.filter((id): id is string => typeof id === 'string' && id.length > 0);
if (collapsed.length > 0) {
localStorage.setItem('oc.sessions.projectCollapse', JSON.stringify(collapsed));
} else {
localStorage.removeItem('oc.sessions.projectCollapse');
}
}
if (typeof settings.gitmojiEnabled === 'boolean') {
localStorage.setItem('gitmojiEnabled', String(settings.gitmojiEnabled));
} else {
@@ -143,6 +154,9 @@ const sanitizeProjects = (value: unknown): DesktopSettings['projects'] | undefin
) {
project.lastOpenedAt = candidate.lastOpenedAt;
}
if (typeof candidate.sidebarCollapsed === 'boolean') {
(project as unknown as Record<string, unknown>).sidebarCollapsed = candidate.sidebarCollapsed;
}
// Preserve worktreeDefaults
if (candidate.worktreeDefaults && typeof candidate.worktreeDefaults === 'object') {
const wt = candidate.worktreeDefaults as Record<string, unknown>;
@@ -164,6 +178,30 @@ const sanitizeProjects = (value: unknown): DesktopSettings['projects'] | undefin
return result.length > 0 ? result : undefined;
};
const sanitizeModelRefs = (value: unknown, limit: number): Array<{ providerID: string; modelID: string }> | undefined => {
if (!Array.isArray(value)) {
return undefined;
}
const result: Array<{ providerID: string; modelID: string }> = [];
const seen = new Set<string>();
for (const entry of value) {
if (!entry || typeof entry !== 'object') continue;
const candidate = entry as Record<string, unknown>;
const providerID = typeof candidate.providerID === 'string' ? candidate.providerID.trim() : '';
const modelID = typeof candidate.modelID === 'string' ? candidate.modelID.trim() : '';
if (!providerID || !modelID) continue;
const key = `${providerID}/${modelID}`;
if (seen.has(key)) continue;
seen.add(key);
result.push({ providerID, modelID });
if (result.length >= limit) break;
}
return result;
};
const getPersistApi = (): PersistApi | undefined => {
const candidate = (useUIStore as unknown as { persist?: PersistApi }).persist;
if (candidate && typeof candidate === 'object') {
@@ -240,6 +278,28 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => {
if (typeof settings.inputBarOffset === 'number' && Number.isFinite(settings.inputBarOffset) && settings.inputBarOffset !== store.inputBarOffset) {
store.setInputBarOffset(settings.inputBarOffset);
}
if (Array.isArray(settings.favoriteModels)) {
const current = store.favoriteModels;
const next = settings.favoriteModels;
const same =
current.length === next.length &&
current.every((item, idx) => item.providerID === next[idx]?.providerID && item.modelID === next[idx]?.modelID);
if (!same) {
useUIStore.setState({ favoriteModels: next });
}
}
if (Array.isArray(settings.recentModels)) {
const current = store.recentModels;
const next = settings.recentModels;
const same =
current.length === next.length &&
current.every((item, idx) => item.providerID === next[idx]?.providerID && item.modelID === next[idx]?.modelID);
if (!same) {
useUIStore.setState({ recentModels: next });
}
}
if (typeof settings.diffLayoutPreference === 'string'
&& (settings.diffLayoutPreference === 'dynamic' || settings.diffLayoutPreference === 'inline' || settings.diffLayoutPreference === 'side-by-side')) {
if (settings.diffLayoutPreference !== store.diffLayoutPreference) {
@@ -391,6 +451,16 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
if (typeof candidate.inputBarOffset === 'number' && Number.isFinite(candidate.inputBarOffset)) {
result.inputBarOffset = candidate.inputBarOffset;
}
const favoriteModels = sanitizeModelRefs(candidate.favoriteModels, 64);
if (favoriteModels) {
result.favoriteModels = favoriteModels;
}
const recentModels = sanitizeModelRefs(candidate.recentModels, 16);
if (recentModels) {
result.recentModels = recentModels;
}
if (
typeof candidate.diffLayoutPreference === 'string'
&& (candidate.diffLayoutPreference === 'dynamic'
@@ -487,9 +557,9 @@ export const syncDesktopSettings = async (): Promise<void> => {
};
try {
const settings = isDesktopRuntime() ? await getDesktopSettings() : await fetchWebSettings();
if (settings) {
applySettings(settings);
const webSettings = await fetchWebSettings();
if (webSettings) {
applySettings(webSettings);
}
} catch (error) {
console.warn('Failed to synchronise settings:', error);
@@ -501,18 +571,7 @@ export const updateDesktopSettings = async (changes: Partial<DesktopSettings>):
return;
}
if (isDesktopRuntime()) {
try {
const updated = await updateDesktopSettingsApi(changes);
if (updated) {
persistToLocalStorage(updated);
applyDesktopUiPreferences(updated);
}
} catch (error) {
console.warn('Failed to update desktop settings:', error);
}
return;
}
// Desktop shell uses the same HTTP settings API as web.
const runtimeSettings = getRuntimeSettingsAPI();
if (runtimeSettings) {
+3 -3
View File
@@ -1,6 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import { isDesktopRuntime } from "@/lib/desktop";
import { isTauriShell } from "@/lib/desktop";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
@@ -21,7 +21,7 @@ export const isMacOS = (): boolean => {
* Browser intercepts Cmd shortcuts, so we only use Cmd in Tauri desktop app.
*/
export const hasModifier = (e: KeyboardEvent | React.KeyboardEvent): boolean => {
return isMacOS() && isDesktopRuntime() ? e.metaKey : e.ctrlKey;
return isMacOS() && isTauriShell() ? e.metaKey : e.ctrlKey;
};
/**
@@ -30,7 +30,7 @@ export const hasModifier = (e: KeyboardEvent | React.KeyboardEvent): boolean =>
* Browser intercepts Cmd shortcuts, so we only show Cmd in Tauri desktop app.
*/
export const getModifierLabel = (): string => {
return isMacOS() && isDesktopRuntime() ? '⌘' : 'Ctrl';
return isMacOS() && isTauriShell() ? '⌘' : 'Ctrl';
};
export const truncatePathMiddle = (
+2 -18
View File
@@ -11,6 +11,7 @@ import { syncDesktopSettings, initializeAppearancePreferences } from './lib/pers
import { startAppearanceAutoSave } from './lib/appearanceAutoSave'
import { applyPersistedDirectoryPreferences } from './lib/directoryPersistence'
import { startTypographyWatcher } from './lib/typographyWatcher'
import { startModelPrefsAutoSave } from './lib/modelPrefsAutoSave'
import type { RuntimeAPIs } from './lib/api/types'
declare global {
@@ -26,6 +27,7 @@ const runtimeAPIs = (typeof window !== 'undefined' && window.__OPENCHAMBER_RUNTI
await syncDesktopSettings();
await initializeAppearancePreferences();
startAppearanceAutoSave();
startModelPrefsAutoSave();
startTypographyWatcher();
await applyPersistedDirectoryPreferences();
@@ -97,21 +99,3 @@ createRoot(rootElement).render(
</ThemeSystemProvider>
</StrictMode>,
);
if (typeof window !== 'undefined') {
const markRendererReady = () => {
try {
window.opencodeDesktop?.markRendererReady?.();
} catch (error) {
console.warn('Failed to notify desktop runtime that renderer is ready:', error);
}
};
markRendererReady();
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
markRendererReady();
}
});
}
+6 -1
View File
@@ -134,7 +134,12 @@ export interface SessionStore {
sessionAgentEditModes: Map<string, Map<string, EditPermissionMode>>;
sessionActivityPhase?: Map<string, 'idle' | 'busy' | 'cooldown'>;
// Server-owned session status (mirrors OpenCode SessionStatus: busy|retry|idle).
// Use as the single source of truth for "assistant working" UI.
sessionStatus?: Map<
string,
{ type: 'idle' | 'busy' | 'retry'; attempt?: number; message?: string; next?: number }
>;
userSummaryTitles: Map<string, { title: string; createdAt: number | null }>;
+2 -15
View File
@@ -8,7 +8,6 @@ import type { ModelMetadata } from "@/types";
import { getSafeStorage } from "./utils/safeStorage";
import type { SessionStore } from "./types/sessionTypes";
import { filterVisibleAgents } from "./useAgentsStore";
import { isDesktopRuntime, getDesktopSettings } from "@/lib/desktop";
import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry";
import { updateDesktopSettings } from "@/lib/persistence";
import { useDirectoryStore } from "@/stores/useDirectoryStore";
@@ -30,19 +29,7 @@ interface OpenChamberDefaults {
const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
try {
// 1. Desktop runtime (Tauri)
if (isDesktopRuntime()) {
const settings = await getDesktopSettings();
return {
defaultModel: settings?.defaultModel,
defaultVariant: settings?.defaultVariant,
defaultAgent: settings?.defaultAgent,
autoCreateWorktree: settings?.autoCreateWorktree,
gitmojiEnabled: settings?.gitmojiEnabled,
};
}
// 2. Runtime settings API (VSCode)
// 1. Runtime settings API (VSCode)
const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
if (runtimeSettings) {
try {
@@ -67,7 +54,7 @@ const fetchOpenChamberDefaults = async (): Promise<OpenChamberDefaults> => {
}
}
// 3. Fetch API (Web)
// 2. Fetch API (Web/server)
const response = await fetch('/api/config/settings', {
method: 'GET',
headers: { Accept: 'application/json' },
+1 -3
View File
@@ -85,9 +85,7 @@ const getHomeDirectory = () => {
const desktopHome =
(typeof window.__OPENCHAMBER_HOME__ === 'string' && window.__OPENCHAMBER_HOME__.length > 0
? window.__OPENCHAMBER_HOME__
: window.opencodeDesktop && typeof window.opencodeDesktop.homeDirectory === 'string'
? window.opencodeDesktop.homeDirectory
: null);
: null);
if (desktopHome && desktopHome.length > 0) {
cachedHomeDirectory = desktopHome;
+13 -17
View File
@@ -10,7 +10,6 @@ import {
discoverGitCredentials,
getGlobalGitIdentity
} from "@/lib/gitApi";
import { getDesktopSettings, isDesktopRuntime } from "@/lib/desktop";
import { updateDesktopSettings } from "@/lib/persistence";
import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry";
@@ -145,10 +144,7 @@ export const useGitIdentitiesStore = create<GitIdentitiesStore>()(
try {
let defaultId: string | null = null;
if (isDesktopRuntime()) {
const settings = await getDesktopSettings();
defaultId = normalize((settings as { defaultGitIdentityId?: unknown } | null | undefined)?.defaultGitIdentityId);
} else {
if (defaultId === null) {
const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
if (runtimeSettings) {
try {
@@ -159,20 +155,20 @@ export const useGitIdentitiesStore = create<GitIdentitiesStore>()(
// fall through
}
}
}
if (defaultId === null) {
try {
const response = await fetch('/api/config/settings', {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (response.ok) {
const data = (await response.json().catch(() => null)) as Record<string, unknown> | null;
defaultId = normalize(data?.defaultGitIdentityId);
}
} catch {
// ignore
if (defaultId === null) {
try {
const response = await fetch('/api/config/settings', {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (response.ok) {
const data = (await response.json().catch(() => null)) as Record<string, unknown> | null;
defaultId = normalize(data?.defaultGitIdentityId);
}
} catch {
// ignore
}
}
@@ -121,6 +121,9 @@ const sanitizeProjects = (value: unknown): ProjectEntry[] => {
if (typeof candidate.lastOpenedAt === 'number' && Number.isFinite(candidate.lastOpenedAt) && candidate.lastOpenedAt >= 0) {
project.lastOpenedAt = candidate.lastOpenedAt;
}
if (typeof candidate.sidebarCollapsed === 'boolean') {
project.sidebarCollapsed = candidate.sidebarCollapsed;
}
if (candidate.worktreeDefaults && typeof candidate.worktreeDefaults === 'object') {
const wt = candidate.worktreeDefaults as Record<string, unknown>;
const defaults: WorktreeDefaults = {};
+1 -6
View File
@@ -3,7 +3,7 @@ import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import type { ProviderResult, QuotaProviderId } from '@/types';
import { QUOTA_PROVIDERS } from '@/lib/quota';
import { getDesktopSettings, isDesktopRuntime, isVSCodeRuntime } from '@/lib/desktop';
import { isVSCodeRuntime } from '@/lib/desktop';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
const DEFAULT_REFRESH_INTERVAL_MS = 60000;
@@ -57,11 +57,6 @@ const parseSettings = (data: Record<string, unknown> | null): QuotaSettingsState
};
const loadSettingsFromRuntime = async (): Promise<QuotaSettingsState> => {
if (isDesktopRuntime()) {
const data = await getDesktopSettings();
return parseSettings((data as Record<string, unknown>) ?? null);
}
const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
if (runtimeSettings) {
try {
+12 -20
View File
@@ -98,7 +98,7 @@ export const useSessionStore = create<SessionStore>()(
sessionAgentEditModes: new Map(),
abortPromptSessionId: null,
abortPromptExpiresAt: null,
sessionActivityPhase: new Map(),
sessionStatus: new Map(),
userSummaryTitles: new Map(),
pendingInputText: null,
newSessionDraft: { open: true, directoryOverride: null, parentID: null },
@@ -315,19 +315,11 @@ export const useSessionStore = create<SessionStore>()(
const draft = get().newSessionDraft;
const trimmedAgent = typeof agent === 'string' && agent.trim().length > 0 ? agent.trim() : undefined;
const setBusyPhase = (sessionId: string) => {
const setStatus = (sessionId: string, type: 'idle' | 'busy') => {
set((state) => {
const next = new Map(state.sessionActivityPhase ?? new Map());
next.set(sessionId, 'busy');
return { sessionActivityPhase: next };
});
};
const setIdlePhase = (sessionId: string) => {
set((state) => {
const next = new Map(state.sessionActivityPhase ?? new Map());
next.set(sessionId, 'idle');
return { sessionActivityPhase: next };
const next = new Map(state.sessionStatus ?? new Map());
next.set(sessionId, { type });
return { sessionStatus: next };
});
};
@@ -391,14 +383,14 @@ export const useSessionStore = create<SessionStore>()(
}
get().closeNewSessionDraft();
setBusyPhase(created.id);
setStatus(created.id, 'busy');
try {
return await useMessageStore
.getState()
.sendMessage(content, providerID, modelID, effectiveDraftAgent, created.id, attachments, agentMentionName, additionalParts, variant);
} catch (error) {
setIdlePhase(created.id);
setStatus(created.id, 'idle');
throw error;
}
}
@@ -429,14 +421,14 @@ export const useSessionStore = create<SessionStore>()(
}
if (currentSessionId) {
setBusyPhase(currentSessionId);
setStatus(currentSessionId, 'busy');
}
try {
return await useMessageStore.getState().sendMessage(content, providerID, modelID, effectiveAgent, currentSessionId || undefined, attachments, agentMentionName, additionalParts, variant);
} catch (error) {
if (currentSessionId) {
setIdlePhase(currentSessionId);
setStatus(currentSessionId, 'idle');
}
throw error;
}
@@ -504,9 +496,9 @@ export const useSessionStore = create<SessionStore>()(
updateViewportAnchor: (sessionId: string, anchor: number) => useMessageStore.getState().updateViewportAnchor(sessionId, anchor),
trimToViewportWindow: (sessionId: string, targetSize?: number) => {
const currentSessionId = useSessionManagementStore.getState().currentSessionId;
// Skip trimming for sessions in active phase (busy/cooldown)
const phase = get().sessionActivityPhase?.get(sessionId);
if (phase === 'busy' || phase === 'cooldown') {
// Skip trimming while session is working (busy/retry)
const status = get().sessionStatus?.get(sessionId);
if (status?.type === 'busy' || status?.type === 'retry') {
return;
}
return useMessageStore.getState().trimToViewportWindow(sessionId, targetSize, currentSessionId || undefined);
+15
View File
@@ -34,6 +34,8 @@ interface UIStore {
isCommandPaletteOpen: boolean;
isHelpDialogOpen: boolean;
isAboutDialogOpen: boolean;
isOpenCodeStatusDialogOpen: boolean;
openCodeStatusText: string;
isSessionCreateDialogOpen: boolean;
isSettingsDialogOpen: boolean;
isModelSelectorOpen: boolean;
@@ -89,6 +91,8 @@ interface UIStore {
toggleHelpDialog: () => void;
setHelpDialogOpen: (open: boolean) => void;
setAboutDialogOpen: (open: boolean) => void;
setOpenCodeStatusDialogOpen: (open: boolean) => void;
setOpenCodeStatusText: (text: string) => void;
setSessionCreateDialogOpen: (open: boolean) => void;
setSettingsDialogOpen: (open: boolean) => void;
setModelSelectorOpen: (open: boolean) => void;
@@ -133,6 +137,7 @@ interface UIStore {
openMultiRunLauncherWithPrompt: (prompt: string) => void;
}
export const useUIStore = create<UIStore>()(
devtools(
persist(
@@ -154,6 +159,8 @@ export const useUIStore = create<UIStore>()(
isCommandPaletteOpen: false,
isHelpDialogOpen: false,
isAboutDialogOpen: false,
isOpenCodeStatusDialogOpen: false,
openCodeStatusText: '',
isSessionCreateDialogOpen: false,
isSettingsDialogOpen: false,
isModelSelectorOpen: false,
@@ -336,6 +343,14 @@ export const useUIStore = create<UIStore>()(
set({ isAboutDialogOpen: open });
},
setOpenCodeStatusDialogOpen: (open) => {
set({ isOpenCodeStatusDialogOpen: open });
},
setOpenCodeStatusText: (text) => {
set({ openCodeStatusText: text });
},
setSessionCreateDialogOpen: (open) => {
set({ isSessionCreateDialogOpen: open });
},
+15 -4
View File
@@ -4,7 +4,8 @@ import {
checkForDesktopUpdates,
downloadDesktopUpdate,
restartToApplyUpdate,
isDesktopRuntime,
isDesktopLocalOriginActive,
isTauriShell,
isWebRuntime,
} from '@/lib/desktop';
@@ -55,7 +56,11 @@ async function checkForWebUpdates(): Promise<UpdateInfo | null> {
}
function detectRuntimeType(): 'desktop' | 'web' | 'vscode' | null {
if (isDesktopRuntime()) return 'desktop';
if (isTauriShell()) {
// Only use Tauri updater when we're on the local instance.
// When viewing a remote host inside the desktop shell, treat update as web update.
return isDesktopLocalOriginActive() ? 'desktop' : 'web';
}
if (isWebRuntime()) return 'web';
return null;
}
@@ -115,9 +120,12 @@ export const useUpdateStore = create<UpdateStore>()((set, get) => ({
set({ downloading: true, error: null, progress: null });
try {
await downloadDesktopUpdate((progress) => {
const ok = await downloadDesktopUpdate((progress) => {
set({ progress });
});
if (!ok) {
throw new Error('Desktop update only works on Local instance');
}
set({ downloading: false, downloaded: true });
} catch (error) {
set({
@@ -135,7 +143,10 @@ export const useUpdateStore = create<UpdateStore>()((set, get) => ({
}
try {
await restartToApplyUpdate();
const ok = await restartToApplyUpdate();
if (!ok) {
throw new Error('Desktop restart only works on Local instance');
}
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to restart',
@@ -6,3 +6,12 @@ export const streamDebugEnabled = (): boolean => {
return false;
}
};
export const sessionStatusDebugEnabled = (): boolean => {
if (typeof window === 'undefined') return false;
try {
return window.localStorage.getItem('openchamber_session_status_debug') === '1';
} catch {
return false;
}
};
+11
View File
@@ -124,6 +124,17 @@
font-weight: var(--ui-regular-font-weight, 400);
}
/* Desktop shell: prevent rubber-band scrolling of the page itself.
App scroll should live inside dedicated scroll containers. */
:root.desktop-runtime,
:root.desktop-runtime body,
:root.desktop-runtime #root {
height: 100%;
overflow: hidden;
overscroll-behavior: none;
overscroll-behavior-y: none;
}
.font-sans {
font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif) !important;
}
+8 -31
View File
@@ -1,32 +1,9 @@
import type { DesktopApi, DesktopSettingsApi } from "../lib/desktop";
declare global {
interface Window {
__OPENCHAMBER_HOME__?: string;
__OPENCHAMBER_MACOS_MAJOR__?: number;
__OPENCHAMBER_LOCAL_ORIGIN__?: string;
}
}
type AppearanceBridgePayload = {
uiFont?: string;
monoFont?: string;
markdownDisplayMode?: string;
typographySizes?: {
markdown?: string;
code?: string;
uiHeader?: string;
uiLabel?: string;
meta?: string;
micro?: string;
} | null;
showReasoningTraces?: boolean;
};
type AppearanceBridgeApi = {
load: () => Promise<AppearanceBridgePayload | null>;
save: (payload: AppearanceBridgePayload) => Promise<{ success: boolean; data?: AppearanceBridgePayload | null; error?: string }>;
};
declare global {
interface Window {
opencodeDesktop?: DesktopApi;
opencodeDesktopSettings?: DesktopSettingsApi;
opencodeAppearance?: AppearanceBridgeApi;
__OPENCHAMBER_HOME__?: string;
}
}
export {};
export {};