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:
committed by
GitHub
parent
b733f26aed
commit
83ffb1af34
+47
-12
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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=<id> 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>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = () => {
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
|
||||
|
||||
@@ -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%
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -378,6 +378,7 @@ export interface ProjectEntry {
|
||||
addedAt?: number;
|
||||
lastOpenedAt?: number;
|
||||
worktreeDefaults?: WorktreeDefaults;
|
||||
sidebarCollapsed?: boolean;
|
||||
}
|
||||
|
||||
export interface SettingsPayload {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 }>;
|
||||
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Vendored
+8
-31
@@ -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 {};
|
||||
|
||||
Reference in New Issue
Block a user