diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 05f318c2..c665879c 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -1000,6 +1000,7 @@ export const ChatInput: React.FC = ({ onOpenSettings, scrollToBo handleImageClick(file)} - className="relative flex-none w-32 sm:w-36 md:w-40 aspect-square rounded-xl border border-border/40 bg-muted/10 overflow-hidden snap-start focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-primary" + className="relative flex-none w-16 sm:w-20 md:w-24 aspect-square rounded-xl border border-border/40 bg-muted/10 overflow-hidden snap-start focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-primary" aria-label={filename} > {file.url ? ( diff --git a/packages/ui/src/components/chat/message/parts/WorkingPlaceholder.tsx b/packages/ui/src/components/chat/message/parts/WorkingPlaceholder.tsx index 1b67f41b..55bb3a18 100644 --- a/packages/ui/src/components/chat/message/parts/WorkingPlaceholder.tsx +++ b/packages/ui/src/components/chat/message/parts/WorkingPlaceholder.tsx @@ -1,428 +1,252 @@ -import React, { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useCallback } 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; } -const MIN_DISPLAY_TIME = 2000; -const DONE_DISPLAY_TIME = 1500; +const STATUS_DISPLAY_TIME = 1500; // Minimum time to show each status +const DONE_DISPLAY_TIME = 2000; // Time to show Done/Aborted status -type ResultState = 'success' | 'aborted' | null; +type PlaceholderState = 'idle' | 'showing' | 'done' | 'aborted'; export function WorkingPlaceholder({ statusText, + isGenericStatus, isWaitingForPermission, wasAborted, completionId, isComplete, + onResultVisibilityChange, }: WorkingPlaceholderProps) { - const [displayedStatus, setDisplayedStatus] = useState(null); + // Internal state machine + const [state, setState] = useState('idle'); + const [displayedText, setDisplayedText] = useState(null); const [displayedPermission, setDisplayedPermission] = useState(false); - const [isVisible, setIsVisible] = useState(false); - const [isFadingOut, setIsFadingOut] = useState(false); - const [resultState, setResultState] = useState(null); - - const displayStartTimeRef = useRef(0); - const statusQueueRef = useRef>([]); - const removalPendingRef = useRef(false); - const fadeTimeoutRef = useRef | null>(null); - const resultTimeoutRef = useRef | null>(null); - - const rafIdRef = useRef(null); - const lastCheckTimeRef = useRef(0); - const lastActiveStatusRef = useRef(null); + // Refs for timing + const statusShownAtRef = useRef(0); + const queuedStatusRef = useRef<{ text: string; permission: boolean } | null>(null); + const processQueueTimerRef = useRef | null>(null); + const doneTimerRef = useRef | null>(null); + const lastCompletionIdRef = useRef(null); + // Track if we've ever shown activity in this turn const hasShownActivityRef = useRef(false); - const wasAbortedRef = useRef(false); - const isCompleteRef = useRef(false); - const windowFocusRef = useRef(true); - const lastCompletionShownRef = useRef(null); - const resultShownAtRef = useRef(null); + // Track the previous isComplete value to detect edges + const prevIsCompleteRef = useRef(false); + const prevWasAbortedRef = useRef(false); - const activateStatus = (status: string, permission: boolean) => { - if (fadeTimeoutRef.current) { - clearTimeout(fadeTimeoutRef.current); - fadeTimeoutRef.current = null; + // Clear all timers + const clearTimers = useCallback(() => { + if (processQueueTimerRef.current) { + clearTimeout(processQueueTimerRef.current); + processQueueTimerRef.current = null; } - if (resultTimeoutRef.current) { - clearTimeout(resultTimeoutRef.current); - resultTimeoutRef.current = null; + if (doneTimerRef.current) { + clearTimeout(doneTimerRef.current); + doneTimerRef.current = null; } - if (status === 'aborted') { - setDisplayedStatus(null); - setDisplayedPermission(false); - setIsFadingOut(false); - setResultState('aborted'); - lastActiveStatusRef.current = 'aborted'; - hasShownActivityRef.current = true; - wasAbortedRef.current = true; + }, []); - if (typeof requestAnimationFrame === 'function') { - requestAnimationFrame(() => setIsVisible(true)); - } else { - setIsVisible(true); - } - - return; - } - - setResultState(null); - setIsFadingOut(false); - lastActiveStatusRef.current = status; - hasShownActivityRef.current = true; - - setDisplayedStatus(status); + // 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]); - if (!isVisible) { - - if (typeof requestAnimationFrame === 'function') { - requestAnimationFrame(() => { - setIsVisible(true); - }); - } else { - setIsVisible(true); + // 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]); - useEffect(() => { - const now = Date.now(); - - if (statusText) { - removalPendingRef.current = false; - - if (!displayedStatus) { - activateStatus(statusText, !!isWaitingForPermission); - displayStartTimeRef.current = now; - statusQueueRef.current = []; - } else if ( - statusText !== displayedStatus || - !!isWaitingForPermission !== displayedPermission - ) { - statusQueueRef.current.push({ - status: statusText, - permission: !!isWaitingForPermission, - }); - } - } else { - removalPendingRef.current = true; - } - - }, [statusText, isWaitingForPermission, displayedStatus, displayedPermission, wasAborted]); - - useEffect(() => { - if (wasAborted) { - wasAbortedRef.current = true; - } - }, [wasAborted]); - - useEffect(() => { - isCompleteRef.current = !!isComplete; - }, [isComplete]); - - useEffect(() => { - if (isComplete) { - removalPendingRef.current = true; - } - }, [isComplete]); - - useEffect(() => { - const startFadeOut = (result: ResultState) => { - if (isFadingOut) { - return; - } - - const hadActiveStatus = - lastActiveStatusRef.current !== null || hasShownActivityRef.current; - - if (result && hadActiveStatus) { - - setIsFadingOut(false); - setIsVisible(true); - - setDisplayedStatus(null); - setDisplayedPermission(false); - setResultState(result); - lastActiveStatusRef.current = null; - - if (result === 'success' && completionId) { - lastCompletionShownRef.current = completionId; - } - - resultShownAtRef.current = Date.now(); - - if (resultTimeoutRef.current) { - clearTimeout(resultTimeoutRef.current); - } - - resultTimeoutRef.current = setTimeout(() => { - setIsVisible(false); - setResultState(null); - hasShownActivityRef.current = false; - resultTimeoutRef.current = null; - }, DONE_DISPLAY_TIME); - } else { - - setIsFadingOut(true); - setIsVisible(false); - setResultState(null); - - if (fadeTimeoutRef.current) { - clearTimeout(fadeTimeoutRef.current); - } - - fadeTimeoutRef.current = setTimeout(() => { - setDisplayedStatus(null); - setDisplayedPermission(false); - setIsFadingOut(false); - hasShownActivityRef.current = false; - lastActiveStatusRef.current = null; - fadeTimeoutRef.current = null; - }, 180); - } - - wasAbortedRef.current = false; - }; - - const CHECK_THROTTLE_MS = 150; // Throttle checks to ~6-7 times per second - - const hasInitialWork = Boolean( - statusText || - displayedStatus || - resultState !== null || - removalPendingRef.current || - statusQueueRef.current.length > 0 || - wasAbortedRef.current || - isCompleteRef.current - ); - - if (!hasInitialWork) { - if (rafIdRef.current !== null) { - cancelAnimationFrame(rafIdRef.current); - rafIdRef.current = null; - } + // 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; } - const checkLoop = (timestamp: number) => { - if (timestamp - lastCheckTimeRef.current < CHECK_THROTTLE_MS) { - rafIdRef.current = requestAnimationFrame(checkLoop); - return; - } - lastCheckTimeRef.current = timestamp; + // 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 - displayStartTimeRef.current; - - const isDone = removalPendingRef.current && isCompleteRef.current; - const shouldWaitForMinTime = !isDone && statusQueueRef.current.length > 0; - - if (shouldWaitForMinTime && elapsed < MIN_DISPLAY_TIME) { - rafIdRef.current = requestAnimationFrame(checkLoop); - return; - } - - if (removalPendingRef.current && wasAbortedRef.current) { - removalPendingRef.current = false; - statusQueueRef.current = []; - startFadeOut('aborted'); - } else if (!isDone && statusQueueRef.current.length > 0) { - const latest = statusQueueRef.current[statusQueueRef.current.length - 1]; - activateStatus(latest.status, latest.permission); - displayStartTimeRef.current = now; - statusQueueRef.current = []; - } else if (removalPendingRef.current) { - - removalPendingRef.current = false; - - if (statusQueueRef.current.length > 0) { - hasShownActivityRef.current = true; - } - statusQueueRef.current = []; - - let result: ResultState = null; - if (wasAbortedRef.current) { - result = 'aborted'; - } else if (isCompleteRef.current) { - result = 'success'; - - hasShownActivityRef.current = true; - } - - if (result === 'success' && completionId && lastCompletionShownRef.current === completionId) { - setDisplayedStatus(null); - setDisplayedPermission(false); - setIsFadingOut(false); - setIsVisible(false); - setResultState(null); - statusQueueRef.current = []; - hasShownActivityRef.current = false; - lastActiveStatusRef.current = null; - removalPendingRef.current = false; - wasAbortedRef.current = false; - rafIdRef.current = null; + 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; } - - startFadeOut(result); + + // 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(); + } } - - const hasPendingWork = Boolean( - displayedStatus || - resultState !== null || - statusQueueRef.current.length > 0 || - removalPendingRef.current || - wasAbortedRef.current || - isCompleteRef.current - ); - - if (hasPendingWork) { - rafIdRef.current = requestAnimationFrame(checkLoop); - } else { - rafIdRef.current = null; - } - }; - - rafIdRef.current = requestAnimationFrame(checkLoop); - - return () => { - if (rafIdRef.current !== null) { - cancelAnimationFrame(rafIdRef.current); - rafIdRef.current = null; - } - }; - - }, [statusText, displayedStatus, resultState, isComplete, wasAborted, isFadingOut]); - - useEffect(() => { - return () => { - if (fadeTimeoutRef.current) { - clearTimeout(fadeTimeoutRef.current); - } - if (resultTimeoutRef.current) { - clearTimeout(resultTimeoutRef.current); - } - }; - }, []); - - useEffect(() => { - if (typeof window === 'undefined') { - return; + // 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]); - windowFocusRef.current = typeof document !== 'undefined' && typeof document.hasFocus === 'function' - ? document.hasFocus() - : true; - - const handleFocus = () => { - windowFocusRef.current = true; - }; - - const handleBlur = () => { - windowFocusRef.current = false; - }; - - window.addEventListener('focus', handleFocus); - window.addEventListener('blur', handleBlur); - - return () => { - window.removeEventListener('focus', handleFocus); - window.removeEventListener('blur', handleBlur); - }; - }, []); - + // Cleanup on unmount useEffect(() => { - const handleVisibilityRestore = () => { - if (typeof document === 'undefined' || typeof Date === 'undefined') { - return; - } - if (document.visibilityState !== 'visible') { - return; - } + return () => clearTimers(); + }, [clearTimers]); - const shownAt = resultShownAtRef.current; - const isCompletionVisible = resultState !== null || displayedStatus !== null; - - if (isCompletionVisible && shownAt && Date.now() - shownAt > 500) { - setDisplayedStatus(null); - setDisplayedPermission(false); - setIsFadingOut(false); - setIsVisible(false); - setResultState(null); - statusQueueRef.current = []; + // 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; - lastActiveStatusRef.current = null; - removalPendingRef.current = false; - wasAbortedRef.current = false; } }; - document.addEventListener('visibilitychange', handleVisibilityRestore); - window.addEventListener('focus', handleVisibilityRestore); + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => document.removeEventListener('visibilitychange', handleVisibilityChange); + }, [state, clearTimers]); - return () => { - document.removeEventListener('visibilitychange', handleVisibilityRestore); - window.removeEventListener('focus', handleVisibilityRestore); - }; - }, [displayedStatus, resultState]); - - if (!displayedStatus && resultState === null) { + // Render nothing if idle with no text + if (state === 'idle' && !displayedText) { return null; } + // Determine what to show let label: string; - if (resultState === 'success') { + let showEllipsis = true; + + if (state === 'done') { label = 'Done'; - } else if (resultState === 'aborted') { + showEllipsis = false; + } else if (state === 'aborted') { label = 'Aborted'; - } else if (displayedStatus) { - label = displayedStatus.charAt(0).toUpperCase() + displayedStatus.slice(1); + showEllipsis = false; + } else if (displayedText) { + label = displayedText.charAt(0).toUpperCase() + displayedText.slice(1); } else { label = 'Working'; } - const ariaLive = displayedPermission ? 'assertive' : 'polite'; - - const displayText = resultState === null ? `${label}...` : label; + const displayText = showEllipsis ? `${label}...` : label; + const isVisible = state !== 'idle'; return (
- {resultState === null && ( - - {displayText} - - )} - {resultState === 'success' && ( - + {state === 'done' ? ( + Done - )} - {resultState === 'aborted' && ( - + ) : state === 'aborted' ? ( + Aborted + ) : ( + + {displayText} + )}
diff --git a/packages/ui/src/hooks/useAssistantStatus.ts b/packages/ui/src/hooks/useAssistantStatus.ts index af4b79bb..718020a4 100644 --- a/packages/ui/src/hooks/useAssistantStatus.ts +++ b/packages/ui/src/hooks/useAssistantStatus.ts @@ -18,6 +18,7 @@ interface WorkingSummary { isCooldown: boolean; lifecyclePhase: MessageStreamPhase | null; statusText: string | null; + isGenericStatus: boolean; isWaitingForPermission: boolean; canAbort: boolean; compactionDeadline: number | null; @@ -59,6 +60,7 @@ const DEFAULT_WORKING: WorkingSummary = { isCooldown: false, lifecyclePhase: null, statusText: null, + isGenericStatus: true, isWaitingForPermission: false, canAbort: false, compactionDeadline: null, @@ -133,11 +135,12 @@ export function useAssistantStatus(): AssistantStatusSnapshot { activePartType: 'text' | 'tool' | 'reasoning' | 'editing' | undefined; activeToolName: string | undefined; statusText: string; + isGenericStatus: boolean; }; const parsedStatus = React.useMemo(() => { if (sessionMessages.length === 0) { - return { activePartType: undefined, activeToolName: undefined, statusText: 'working' }; + return { activePartType: undefined, activeToolName: undefined, statusText: 'working', isGenericStatus: true }; } const assistantMessages = sessionMessages @@ -147,7 +150,7 @@ export function useAssistantStatus(): AssistantStatusSnapshot { ); if (assistantMessages.length === 0) { - return { activePartType: undefined, activeToolName: undefined, statusText: 'working' }; + return { activePartType: undefined, activeToolName: undefined, statusText: 'working', isGenericStatus: true }; } const sortedAssistantMessages = [...assistantMessages].sort((a, b) => { @@ -252,6 +255,7 @@ export function useAssistantStatus(): AssistantStatusSnapshot { return WORKING_PHRASES[Math.floor(Math.random() * WORKING_PHRASES.length)]; }; + const isGenericStatus = activePartType === undefined; const statusText = (() => { if (activePartType === 'editing') return 'editing file'; if (activePartType === 'tool' && activeToolName) return getToolStatusPhrase(activeToolName); @@ -260,7 +264,7 @@ export function useAssistantStatus(): AssistantStatusSnapshot { return getRandomWorkingPhrase(); })(); - return { activePartType, activeToolName, statusText }; + return { activePartType, activeToolName, statusText, isGenericStatus }; }, [sessionMessages]); const abortState = React.useMemo(() => { @@ -309,6 +313,7 @@ export function useAssistantStatus(): AssistantStatusSnapshot { isCooldown, lifecyclePhase: isStreaming ? 'streaming' : isCooldown ? 'cooldown' : null, statusText: isWorking ? parsedStatus.statusText : null, + isGenericStatus: isWorking ? parsedStatus.isGenericStatus : true, isWaitingForPermission: false, canAbort: isWorking, compactionDeadline: null, @@ -317,7 +322,6 @@ export function useAssistantStatus(): AssistantStatusSnapshot { wasAborted: false, abortActive: false, lastCompletionId: null, - isComplete: isCooldown, }; }, [activityPhase, isPhaseWorking, isPhaseCooldown, parsedStatus, abortState]); diff --git a/packages/ui/src/lib/debug.ts b/packages/ui/src/lib/debug.ts index 92bb2dbc..391102cb 100644 --- a/packages/ui/src/lib/debug.ts +++ b/packages/ui/src/lib/debug.ts @@ -1,5 +1,8 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { useSessionStore } from '@/stores/useSessionStore'; +import { useDirectoryStore } from '@/stores/useDirectoryStore'; +import { opencodeClient } from '@/lib/opencode/client'; +import { checkIsGitRepository } from '@/lib/gitApi'; export interface DebugMessageInfo { messageId: string; @@ -169,6 +172,174 @@ export const debugUtils = { return truncate ? this.truncateMessages(messages) : messages; }, + async getAppStatus() { + const directoryState = useDirectoryStore.getState(); + const sessionState = useSessionStore.getState(); + const currentDirectory = directoryState.currentDirectory || null; + const opencodeDirectory = opencodeClient.getDirectory() ?? null; + + const sessions = sessionState.sessions || []; + const sessionDirectories = new Set(); + const sessionDirectoryCounts: Record = {}; + + sessions.forEach((session) => { + const directory = (session as { directory?: string | null }).directory; + if (typeof directory === 'string' && directory.trim().length > 0) { + sessionDirectories.add(directory); + sessionDirectoryCounts[directory] = (sessionDirectoryCounts[directory] ?? 0) + 1; + } else { + sessionDirectoryCounts['(none)'] = (sessionDirectoryCounts['(none)'] ?? 0) + 1; + } + }); + + const localStorageSnapshot = (() => { + if (typeof window === 'undefined') { + return { available: false }; + } + try { + return { + available: true, + lastDirectory: window.localStorage.getItem('lastDirectory'), + homeDirectory: window.localStorage.getItem('homeDirectory'), + }; + } catch (error) { + return { + available: false, + error: error instanceof Error ? error.message : String(error), + }; + } + })(); + + 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 safeJson = async (resp: Response) => { + try { + return await resp.json(); + } catch { + return null; + } + }; + + const safeText = async (resp: Response) => { + try { + return await resp.text(); + } catch { + return null; + } + }; + + const safeFetchJson = async (url: string): Promise => { + try { + const resp = await fetch(url); + return resp.ok ? await safeJson(resp) : { status: resp.status }; + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) }; + } + }; + + let pathInfo: unknown = null; + let projectInfo: unknown = null; + let settingsInfo: unknown = null; + let opencodeHealth: unknown = null; + + const pathUrl = currentDirectory + ? `/api/path?directory=${encodeURIComponent(currentDirectory)}` + : '/api/path'; + pathInfo = await safeFetchJson(pathUrl); + + const projectUrl = currentDirectory + ? `/api/project/current?directory=${encodeURIComponent(currentDirectory)}` + : '/api/project/current'; + projectInfo = await safeFetchJson(projectUrl); + + settingsInfo = await safeFetchJson('/api/config/settings'); + + try { + const resp = await fetch('/api/health'); + const contentType = resp.headers.get('content-type') || ''; + const body = await safeText(resp); + opencodeHealth = { + status: resp.status, + ok: resp.ok, + contentType, + type: contentType.includes('application/json') ? 'json' : 'html', + preview: body ? body.slice(0, 120) : null, + }; + } catch (error) { + opencodeHealth = { error: error instanceof Error ? error.message : String(error) }; + } + + let gitCheck: { isGitRepo: boolean | null; error?: string } = { isGitRepo: null }; + if (currentDirectory) { + try { + gitCheck.isGitRepo = await checkIsGitRepository(currentDirectory); + } catch (error) { + gitCheck = { + isGitRepo: null, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + const report = { + runtime: { + isDesktop: isDesktopRuntime, + hasRuntimeApis: Boolean(runtimeApis), + desktopServerOrigin: desktopServer?.origin ?? null, + }, + location: typeof window !== 'undefined' + ? { + href: window.location?.href ?? null, + origin: window.location?.origin ?? null, + } + : null, + directories: { + currentDirectory, + opencodeDirectory: (pathInfo as { directory?: string; worktree?: string } | null)?.directory + || (pathInfo as { worktree?: string } | null)?.worktree + || opencodeDirectory, + homeDirectory: directoryState.homeDirectory || null, + isHomeReady: directoryState.isHomeReady, + hasPersistedDirectory: directoryState.hasPersistedDirectory, + isSwitchingDirectory: directoryState.isSwitchingDirectory, + }, + sessions: { + total: sessions.length, + currentSessionId: sessionState.currentSessionId, + lastLoadedDirectory: sessionState.lastLoadedDirectory, + uniqueDirectories: sessionDirectories.size, + directorySamples: Array.from(sessionDirectories).slice(0, 5), + directoryCounts: sessionDirectoryCounts, + }, + worktrees: { + available: sessionState.availableWorktrees.length, + metadataEntries: sessionState.worktreeMetadata.size, + }, + git: gitCheck, + localStorage: localStorageSnapshot, + opencode: { + pathInfo, + projectInfo, + health: opencodeHealth, + }, + openchamber: { + settingsInfo, + }, + }; + + console.log('[DEBUG] App status snapshot:', report); + return report; + }, + checkLastMessage() { const info = this.getLastAssistantMessage(); if (!info) return false; @@ -465,6 +636,7 @@ if (typeof window !== 'undefined') { console.log(' __opencodeDebug.getLastAssistantMessage() - Get last assistant message details'); console.log(' __opencodeDebug.getAllMessages(truncate?) - List all messages (truncate=true for short preview)'); console.log(' __opencodeDebug.truncateMessages(messages) - Truncate long fields in messages array'); + console.log(' __opencodeDebug.getAppStatus() - Show app status snapshot'); console.log(' __opencodeDebug.checkLastMessage() - Check if last message is problematic'); console.log(' __opencodeDebug.findEmptyMessages() - Find all empty assistant messages'); console.log(' __opencodeDebug.showRetryHelp() - Show instructions for handling empty responses'); diff --git a/packages/ui/src/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts index fc7b9a65..8ec01e92 100644 --- a/packages/ui/src/stores/useConfigStore.ts +++ b/packages/ui/src/stores/useConfigStore.ts @@ -351,17 +351,24 @@ export const useConfigStore = create()( const buildAgent = primaryAgents.find((agent) => agent.name === "build"); const defaultAgent = buildAgent || primaryAgents[0] || safeAgents[0]; - set({ currentAgentName: defaultAgent.name }); + const existingAgentName = get().currentAgentName; + const existingAgent = existingAgentName ? safeAgents.find((agent) => agent.name === existingAgentName) : undefined; + const resolvedAgentName = existingAgent ? existingAgentName : defaultAgent.name; - if (defaultAgent?.model?.providerID && defaultAgent?.model?.modelID) { - const agentProvider = providers.find((p) => p.id === defaultAgent.model!.providerID); + if (resolvedAgentName !== existingAgentName) { + set({ currentAgentName: resolvedAgentName }); + } + + const agentForDefaults = existingAgent || defaultAgent; + if (agentForDefaults?.model?.providerID && agentForDefaults?.model?.modelID) { + const agentProvider = providers.find((p) => p.id === agentForDefaults.model!.providerID); if (agentProvider) { - const agentModel = agentProvider.models.find((model) => model.id === defaultAgent.model!.modelID); + const agentModel = agentProvider.models.find((model) => model.id === agentForDefaults.model!.modelID); if (agentModel) { set({ - currentProviderId: defaultAgent.model!.providerID, - currentModelId: defaultAgent.model!.modelID, + currentProviderId: agentForDefaults.model!.providerID, + currentModelId: agentForDefaults.model!.modelID, }); } } diff --git a/packages/ui/src/stores/useDirectoryStore.ts b/packages/ui/src/stores/useDirectoryStore.ts index a005bc1b..585a530e 100644 --- a/packages/ui/src/stores/useDirectoryStore.ts +++ b/packages/ui/src/stores/useDirectoryStore.ts @@ -128,11 +128,45 @@ const invalidateFileSearchCache = (scope?: string | null) => { } }; +const normalizeDirectoryPath = (value: string): string => { + const trimmed = value.trim(); + if (!trimmed) { + return trimmed; + } + const normalized = trimmed.replace(/\\/g, '/'); + if (normalized.length > 1) { + return normalized.replace(/\/+$/, ''); + } + return normalized; +}; + +const resolveTildePath = (path: string, homeDir?: string | null): string => { + const trimmed = path.trim(); + if (!trimmed.startsWith('~')) { + return trimmed; + } + if (trimmed === '~') { + return homeDir || trimmed; + } + if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) { + return homeDir ? `${homeDir}${trimmed.slice(1)}` : trimmed; + } + return trimmed; +}; + +const resolveDirectoryPath = (path: string, homeDir?: string | null): string => { + const expanded = resolveTildePath(path, homeDir); + return normalizeDirectoryPath(expanded); +}; + const getHomeDirectory = () => { if (typeof window !== 'undefined') { + const storedHome = safeStorage.getItem('homeDirectory') || cachedHomeDirectory || null; const saved = safeStorage.getItem('lastDirectory'); - if (saved) return saved; + if (saved) { + return resolveDirectoryPath(saved, storedHome); + } if (cachedHomeDirectory) return cachedHomeDirectory; @@ -149,7 +183,6 @@ const getHomeDirectory = () => { return desktopHome; } - const storedHome = safeStorage.getItem('homeDirectory'); if (storedHome) { cachedHomeDirectory = storedHome; return storedHome; @@ -163,6 +196,7 @@ const getHomeDirectory = () => { return process?.cwd?.() || '/'; }; + const normalizeHomeCandidate = (value?: string | null) => { if (typeof value !== 'string') { return null; @@ -260,24 +294,26 @@ export const useDirectoryStore = create()( isSwitchingDirectory: false, setDirectory: (path: string, options?: { showOverlay?: boolean }) => { - console.log('[DirectoryStore] setDirectory called with path:', path); + const homeDir = cachedHomeDirectory || get().homeDirectory || safeStorage.getItem('homeDirectory'); + const resolvedPath = resolveDirectoryPath(path, homeDir); + console.log('[DirectoryStore] setDirectory called with path:', resolvedPath); const showOverlay = options?.showOverlay ?? true; - opencodeClient.setDirectory(path); + opencodeClient.setDirectory(resolvedPath); invalidateFileSearchCache(); - const restartPromise = notifyOpenCodeWorkingDirectory(path, { showOverlay }); + const restartPromise = notifyOpenCodeWorkingDirectory(resolvedPath, { showOverlay }); console.log('[DirectoryStore] notifyOpenCodeWorkingDirectory initiated'); set((state) => { - const newHistory = [...state.directoryHistory.slice(0, state.historyIndex + 1), path]; + const newHistory = [...state.directoryHistory.slice(0, state.historyIndex + 1), resolvedPath]; - safeStorage.setItem('lastDirectory', path); + safeStorage.setItem('lastDirectory', resolvedPath); - void updateDesktopSettings({ lastDirectory: path }); + void updateDesktopSettings({ lastDirectory: resolvedPath }); return { - currentDirectory: path, + currentDirectory: resolvedPath, directoryHistory: newHistory, historyIndex: newHistory.length - 1, hasPersistedDirectory: true, @@ -288,7 +324,7 @@ export const useDirectoryStore = create()( scheduleDirectoryFollowUp(restartPromise, { showOverlay }, () => { set((state) => { - if (state.currentDirectory !== path) { + if (state.currentDirectory !== resolvedPath) { return {}; } if (!state.isSwitchingDirectory) { @@ -426,6 +462,13 @@ export const useDirectoryStore = create()( const resolvedReady = typeof resolvedHome === 'string' && resolvedHome !== '' && resolvedHome !== '/'; + const resolvedCurrent = state.currentDirectory + ? resolveDirectoryPath(state.currentDirectory, resolvedHome) + : state.currentDirectory; + const resolvedHistory = state.directoryHistory.map((entry) => resolveDirectoryPath(entry, resolvedHome)); + const historyChanged = resolvedHistory.some((entry, index) => entry !== state.directoryHistory[index]); + const currentChanged = Boolean(resolvedCurrent && resolvedCurrent !== state.currentDirectory); + const updates: Partial = { homeDirectory: resolvedHome, hasPersistedDirectory: hasSavedLastDirectory, @@ -437,20 +480,26 @@ export const useDirectoryStore = create()( updates.directoryHistory = [resolvedHome]; updates.historyIndex = 0; updates.isSwitchingDirectory = true; + } else if (currentChanged || historyChanged) { + updates.currentDirectory = resolvedCurrent as string; + updates.directoryHistory = resolvedHistory; + updates.historyIndex = Math.min(state.historyIndex, resolvedHistory.length - 1); + updates.isSwitchingDirectory = true; } set(() => updates as Partial); - if (shouldReplaceCurrent && resolvedReady) { - opencodeClient.setDirectory(resolvedHome); + if ((shouldReplaceCurrent || currentChanged) && resolvedReady) { + const nextDirectory = shouldReplaceCurrent ? resolvedHome : (resolvedCurrent as string); + opencodeClient.setDirectory(nextDirectory); invalidateFileSearchCache(); - safeStorage.setItem('lastDirectory', resolvedHome); - void updateDesktopSettings({ lastDirectory: resolvedHome }); + safeStorage.setItem('lastDirectory', nextDirectory); + void updateDesktopSettings({ lastDirectory: nextDirectory }); - const restartPromise = notifyOpenCodeWorkingDirectory(resolvedHome, { showOverlay: false }); + const restartPromise = notifyOpenCodeWorkingDirectory(nextDirectory, { showOverlay: false }); scheduleDirectoryFollowUp(restartPromise, { showOverlay: false }, () => { set((state) => { - if (state.currentDirectory !== resolvedHome) { + if (state.currentDirectory !== nextDirectory) { return {}; } if (!state.isSwitchingDirectory) { diff --git a/packages/ui/src/vite-env.d.ts b/packages/ui/src/vite-env.d.ts index 2fd280c6..2e3fa7b4 100644 --- a/packages/ui/src/vite-env.d.ts +++ b/packages/ui/src/vite-env.d.ts @@ -5,6 +5,7 @@ interface Window { getLastAssistantMessage: () => unknown; getAllMessages: (truncate?: boolean) => unknown[]; truncateMessages: (messages: unknown[]) => unknown[]; + getAppStatus: () => Promise; checkLastMessage: () => boolean; findEmptyMessages: () => unknown[]; showRetryHelp: () => void; diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 0a1028b0..16bad6f6 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -5,11 +5,12 @@ import { createOpenCodeManager, type OpenCodeManager } from './opencode'; let chatViewProvider: ChatViewProvider | undefined; let openCodeManager: OpenCodeManager | undefined; -export function activate(context: vscode.ExtensionContext) { +export async function activate(context: vscode.ExtensionContext) { // Create OpenCode manager first openCodeManager = createOpenCodeManager(context); // Create chat view provider with manager reference + // The webview will show a loading state until OpenCode is ready chatViewProvider = new ChatViewProvider(context, context.extensionUri, openCodeManager); context.subscriptions.push( @@ -52,19 +53,20 @@ export function activate(context: vscode.ExtensionContext) { }) ); - // Subscribe to status changes + // Subscribe to status changes - this broadcasts to webview context.subscriptions.push( openCodeManager.onStatusChange((status, error) => { chatViewProvider?.updateConnectionStatus(status, error); }) ); - // Auto-start OpenCode API - openCodeManager.start(); + // Start OpenCode API and wait for it to be ready + // The webview will show loading state during this time + await openCodeManager.start(); } -export function deactivate() { - openCodeManager?.stop(); +export async function deactivate() { + await openCodeManager?.stop(); openCodeManager = undefined; chatViewProvider = undefined; } diff --git a/packages/vscode/tsconfig.webview.json b/packages/vscode/tsconfig.webview.json index 53fba48a..6b33cd0d 100644 --- a/packages/vscode/tsconfig.webview.json +++ b/packages/vscode/tsconfig.webview.json @@ -20,11 +20,5 @@ "@openchamber/ui/*": ["../ui/src/*"] } }, - "include": ["webview/**/*", "../ui/src/**/*"], - "exclude": [ - "webview/components/**/*", - "webview/stores/**/*", - "webview/hooks/**/*", - "webview/App.tsx" - ] + "include": ["webview/**/*", "../ui/src/**/*"] }