fix: reduce image thumbnails, refactor WorkingPlaceholder state machine, expand debug utilities
This commit is contained in:
@@ -1000,6 +1000,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
|
|||||||
<StatusRow
|
<StatusRow
|
||||||
isWorking={working.isWorking}
|
isWorking={working.isWorking}
|
||||||
statusText={workingStatusText}
|
statusText={workingStatusText}
|
||||||
|
isGenericStatus={working.isGenericStatus}
|
||||||
isWaitingForPermission={working.isWaitingForPermission}
|
isWaitingForPermission={working.isWaitingForPermission}
|
||||||
wasAborted={working.wasAborted}
|
wasAborted={working.wasAborted}
|
||||||
abortActive={working.abortActive}
|
abortActive={working.abortActive}
|
||||||
|
|||||||
@@ -310,7 +310,7 @@ export const MessageFilesDisplay = memo(({ files, onShowPopup }: MessageFilesDis
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleImageClick(file)}
|
onClick={() => 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}
|
aria-label={filename}
|
||||||
>
|
>
|
||||||
{file.url ? (
|
{file.url ? (
|
||||||
|
|||||||
@@ -1,428 +1,252 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { Text } from '@/components/ui/text';
|
import { Text } from '@/components/ui/text';
|
||||||
|
|
||||||
interface WorkingPlaceholderProps {
|
interface WorkingPlaceholderProps {
|
||||||
statusText: string | null;
|
statusText: string | null;
|
||||||
|
isGenericStatus?: boolean;
|
||||||
isWaitingForPermission?: boolean;
|
isWaitingForPermission?: boolean;
|
||||||
wasAborted?: boolean;
|
wasAborted?: boolean;
|
||||||
completionId?: string | null;
|
completionId?: string | null;
|
||||||
isComplete?: boolean;
|
isComplete?: boolean;
|
||||||
|
onResultVisibilityChange?: (isShowingResult: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MIN_DISPLAY_TIME = 2000;
|
const STATUS_DISPLAY_TIME = 1500; // Minimum time to show each status
|
||||||
const DONE_DISPLAY_TIME = 1500;
|
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({
|
export function WorkingPlaceholder({
|
||||||
statusText,
|
statusText,
|
||||||
|
isGenericStatus,
|
||||||
isWaitingForPermission,
|
isWaitingForPermission,
|
||||||
wasAborted,
|
wasAborted,
|
||||||
completionId,
|
completionId,
|
||||||
isComplete,
|
isComplete,
|
||||||
|
onResultVisibilityChange,
|
||||||
}: WorkingPlaceholderProps) {
|
}: WorkingPlaceholderProps) {
|
||||||
const [displayedStatus, setDisplayedStatus] = useState<string | null>(null);
|
// Internal state machine
|
||||||
|
const [state, setState] = useState<PlaceholderState>('idle');
|
||||||
|
const [displayedText, setDisplayedText] = useState<string | null>(null);
|
||||||
const [displayedPermission, setDisplayedPermission] = useState<boolean>(false);
|
const [displayedPermission, setDisplayedPermission] = useState<boolean>(false);
|
||||||
const [isVisible, setIsVisible] = useState<boolean>(false);
|
|
||||||
const [isFadingOut, setIsFadingOut] = useState<boolean>(false);
|
|
||||||
const [resultState, setResultState] = useState<ResultState>(null);
|
|
||||||
|
|
||||||
|
// Refs for timing
|
||||||
const displayStartTimeRef = useRef<number>(0);
|
const statusShownAtRef = useRef<number>(0);
|
||||||
const statusQueueRef = useRef<Array<{ status: string; permission: boolean }>>([]);
|
const queuedStatusRef = useRef<{ text: string; permission: boolean } | null>(null);
|
||||||
const removalPendingRef = useRef<boolean>(false);
|
const processQueueTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const fadeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const doneTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const resultTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const lastCompletionIdRef = useRef<string | null>(null);
|
||||||
|
// Track if we've ever shown activity in this turn
|
||||||
const rafIdRef = useRef<number | null>(null);
|
|
||||||
const lastCheckTimeRef = useRef<number>(0);
|
|
||||||
const lastActiveStatusRef = useRef<string | null>(null);
|
|
||||||
const hasShownActivityRef = useRef<boolean>(false);
|
const hasShownActivityRef = useRef<boolean>(false);
|
||||||
const wasAbortedRef = useRef<boolean>(false);
|
// Track the previous isComplete value to detect edges
|
||||||
const isCompleteRef = useRef<boolean>(false);
|
const prevIsCompleteRef = useRef<boolean>(false);
|
||||||
const windowFocusRef = useRef<boolean>(true);
|
const prevWasAbortedRef = useRef<boolean>(false);
|
||||||
const lastCompletionShownRef = useRef<string | null>(null);
|
|
||||||
const resultShownAtRef = useRef<number | null>(null);
|
|
||||||
|
|
||||||
const activateStatus = (status: string, permission: boolean) => {
|
// Clear all timers
|
||||||
if (fadeTimeoutRef.current) {
|
const clearTimers = useCallback(() => {
|
||||||
clearTimeout(fadeTimeoutRef.current);
|
if (processQueueTimerRef.current) {
|
||||||
fadeTimeoutRef.current = null;
|
clearTimeout(processQueueTimerRef.current);
|
||||||
|
processQueueTimerRef.current = null;
|
||||||
}
|
}
|
||||||
if (resultTimeoutRef.current) {
|
if (doneTimerRef.current) {
|
||||||
clearTimeout(resultTimeoutRef.current);
|
clearTimeout(doneTimerRef.current);
|
||||||
resultTimeoutRef.current = null;
|
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') {
|
// Show a status immediately
|
||||||
requestAnimationFrame(() => setIsVisible(true));
|
const showStatus = useCallback((text: string, permission: boolean) => {
|
||||||
} else {
|
clearTimers();
|
||||||
setIsVisible(true);
|
queuedStatusRef.current = null;
|
||||||
}
|
setDisplayedText(text);
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setResultState(null);
|
|
||||||
setIsFadingOut(false);
|
|
||||||
lastActiveStatusRef.current = status;
|
|
||||||
hasShownActivityRef.current = true;
|
|
||||||
|
|
||||||
setDisplayedStatus(status);
|
|
||||||
setDisplayedPermission(permission);
|
setDisplayedPermission(permission);
|
||||||
|
setState('showing');
|
||||||
|
statusShownAtRef.current = Date.now();
|
||||||
|
hasShownActivityRef.current = true;
|
||||||
|
}, [clearTimers]);
|
||||||
|
|
||||||
if (!isVisible) {
|
// Schedule processing of queued status
|
||||||
|
const scheduleQueueProcess = useCallback(() => {
|
||||||
|
if (processQueueTimerRef.current) return; // Already scheduled
|
||||||
|
|
||||||
if (typeof requestAnimationFrame === 'function') {
|
const elapsed = Date.now() - statusShownAtRef.current;
|
||||||
requestAnimationFrame(() => {
|
const remaining = Math.max(0, STATUS_DISPLAY_TIME - elapsed);
|
||||||
setIsVisible(true);
|
|
||||||
});
|
processQueueTimerRef.current = setTimeout(() => {
|
||||||
} else {
|
processQueueTimerRef.current = null;
|
||||||
setIsVisible(true);
|
const queued = queuedStatusRef.current;
|
||||||
|
if (queued) {
|
||||||
|
showStatus(queued.text, queued.permission);
|
||||||
}
|
}
|
||||||
}
|
// If nothing queued, keep showing current status
|
||||||
};
|
}, remaining);
|
||||||
|
}, [showStatus]);
|
||||||
|
|
||||||
useEffect(() => {
|
// Show done/aborted result
|
||||||
const now = Date.now();
|
const showResult = useCallback((result: 'done' | 'aborted') => {
|
||||||
|
clearTimers();
|
||||||
|
queuedStatusRef.current = null;
|
||||||
|
|
||||||
if (statusText) {
|
// Only show result if we had activity
|
||||||
removalPendingRef.current = false;
|
if (!hasShownActivityRef.current) {
|
||||||
|
setState('idle');
|
||||||
if (!displayedStatus) {
|
setDisplayedText(null);
|
||||||
activateStatus(statusText, !!isWaitingForPermission);
|
onResultVisibilityChange?.(false);
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hadActiveStatus =
|
// Skip duplicate completion for same completionId
|
||||||
lastActiveStatusRef.current !== null || hasShownActivityRef.current;
|
if (result === 'done' && completionId && lastCompletionIdRef.current === completionId) {
|
||||||
|
setState('idle');
|
||||||
if (result && hadActiveStatus) {
|
setDisplayedText(null);
|
||||||
|
|
||||||
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;
|
hasShownActivityRef.current = false;
|
||||||
resultTimeoutRef.current = null;
|
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);
|
}, DONE_DISPLAY_TIME);
|
||||||
} else {
|
}, [clearTimers, completionId, onResultVisibilityChange]);
|
||||||
|
|
||||||
setIsFadingOut(true);
|
// Main effect: handle prop changes
|
||||||
setIsVisible(false);
|
useEffect(() => {
|
||||||
setResultState(null);
|
// Detect abort edge (false -> true)
|
||||||
|
if (wasAborted && !prevWasAbortedRef.current) {
|
||||||
|
prevWasAbortedRef.current = true;
|
||||||
|
showResult('aborted');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
prevWasAbortedRef.current = !!wasAborted;
|
||||||
|
|
||||||
if (fadeTimeoutRef.current) {
|
// Detect completion edge (false -> true)
|
||||||
clearTimeout(fadeTimeoutRef.current);
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
fadeTimeoutRef.current = setTimeout(() => {
|
// If we're showing done/aborted, don't process new status
|
||||||
setDisplayedStatus(null);
|
if (state === 'done' || state === 'aborted') {
|
||||||
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;
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const checkLoop = (timestamp: number) => {
|
// Handle new status text
|
||||||
if (timestamp - lastCheckTimeRef.current < CHECK_THROTTLE_MS) {
|
if (statusText) {
|
||||||
rafIdRef.current = requestAnimationFrame(checkLoop);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lastCheckTimeRef.current = timestamp;
|
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const elapsed = now - displayStartTimeRef.current;
|
const elapsed = now - statusShownAtRef.current;
|
||||||
|
|
||||||
const isDone = removalPendingRef.current && isCompleteRef.current;
|
if (state === 'idle' || !displayedText) {
|
||||||
const shouldWaitForMinTime = !isDone && statusQueueRef.current.length > 0;
|
// Not showing anything - show immediately (generic OK at turn start)
|
||||||
|
showStatus(statusText, !!isWaitingForPermission);
|
||||||
if (shouldWaitForMinTime && elapsed < MIN_DISPLAY_TIME) {
|
} else if (statusText !== displayedText || !!isWaitingForPermission !== displayedPermission) {
|
||||||
rafIdRef.current = requestAnimationFrame(checkLoop);
|
// Already showing something - ignore generic statuses
|
||||||
|
if (isGenericStatus) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (removalPendingRef.current && wasAbortedRef.current) {
|
// Different specific status
|
||||||
removalPendingRef.current = false;
|
if (elapsed >= STATUS_DISPLAY_TIME) {
|
||||||
statusQueueRef.current = [];
|
// Minimum time passed - show immediately
|
||||||
startFadeOut('aborted');
|
showStatus(statusText, !!isWaitingForPermission);
|
||||||
} 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;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
startFadeOut(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasPendingWork = Boolean(
|
|
||||||
displayedStatus ||
|
|
||||||
resultState !== null ||
|
|
||||||
statusQueueRef.current.length > 0 ||
|
|
||||||
removalPendingRef.current ||
|
|
||||||
wasAbortedRef.current ||
|
|
||||||
isCompleteRef.current
|
|
||||||
);
|
|
||||||
|
|
||||||
if (hasPendingWork) {
|
|
||||||
rafIdRef.current = requestAnimationFrame(checkLoop);
|
|
||||||
} else {
|
} else {
|
||||||
rafIdRef.current = null;
|
// Queue the latest (overwrites previous queued)
|
||||||
|
queuedStatusRef.current = { text: statusText, permission: !!isWaitingForPermission };
|
||||||
|
scheduleQueueProcess();
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
rafIdRef.current = requestAnimationFrame(checkLoop);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (rafIdRef.current !== null) {
|
|
||||||
cancelAnimationFrame(rafIdRef.current);
|
|
||||||
rafIdRef.current = null;
|
|
||||||
}
|
}
|
||||||
};
|
// Same status - keep showing
|
||||||
|
}
|
||||||
|
// IMPORTANT: When statusText becomes null, we do NOT clear the display
|
||||||
|
// Only done/abort signals clear the display
|
||||||
|
|
||||||
}, [statusText, displayedStatus, resultState, isComplete, wasAborted, isFadingOut]);
|
}, [statusText, isWaitingForPermission, wasAborted, isComplete, state, displayedText, displayedPermission, showStatus, showResult, scheduleQueueProcess]);
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => clearTimers();
|
||||||
if (fadeTimeoutRef.current) {
|
}, [clearTimers]);
|
||||||
clearTimeout(fadeTimeoutRef.current);
|
|
||||||
}
|
|
||||||
if (resultTimeoutRef.current) {
|
|
||||||
clearTimeout(resultTimeoutRef.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
|
// Handle tab visibility changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') {
|
const handleVisibilityChange = () => {
|
||||||
return;
|
if (typeof document === 'undefined') return;
|
||||||
}
|
if (document.visibilityState !== 'visible') return;
|
||||||
|
|
||||||
windowFocusRef.current = typeof document !== 'undefined' && typeof document.hasFocus === 'function'
|
// If showing done/aborted when user returns, hide it
|
||||||
? document.hasFocus()
|
if (state === 'done' || state === 'aborted') {
|
||||||
: true;
|
clearTimers();
|
||||||
|
setState('idle');
|
||||||
const handleFocus = () => {
|
setDisplayedText(null);
|
||||||
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);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleVisibilityRestore = () => {
|
|
||||||
if (typeof document === 'undefined' || typeof Date === 'undefined') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (document.visibilityState !== 'visible') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = [];
|
|
||||||
hasShownActivityRef.current = false;
|
hasShownActivityRef.current = false;
|
||||||
lastActiveStatusRef.current = null;
|
|
||||||
removalPendingRef.current = false;
|
|
||||||
wasAbortedRef.current = false;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('visibilitychange', handleVisibilityRestore);
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
window.addEventListener('focus', handleVisibilityRestore);
|
return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
}, [state, clearTimers]);
|
||||||
|
|
||||||
return () => {
|
// Render nothing if idle with no text
|
||||||
document.removeEventListener('visibilitychange', handleVisibilityRestore);
|
if (state === 'idle' && !displayedText) {
|
||||||
window.removeEventListener('focus', handleVisibilityRestore);
|
|
||||||
};
|
|
||||||
}, [displayedStatus, resultState]);
|
|
||||||
|
|
||||||
if (!displayedStatus && resultState === null) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine what to show
|
||||||
let label: string;
|
let label: string;
|
||||||
if (resultState === 'success') {
|
let showEllipsis = true;
|
||||||
|
|
||||||
|
if (state === 'done') {
|
||||||
label = 'Done';
|
label = 'Done';
|
||||||
} else if (resultState === 'aborted') {
|
showEllipsis = false;
|
||||||
|
} else if (state === 'aborted') {
|
||||||
label = 'Aborted';
|
label = 'Aborted';
|
||||||
} else if (displayedStatus) {
|
showEllipsis = false;
|
||||||
label = displayedStatus.charAt(0).toUpperCase() + displayedStatus.slice(1);
|
} else if (displayedText) {
|
||||||
|
label = displayedText.charAt(0).toUpperCase() + displayedText.slice(1);
|
||||||
} else {
|
} else {
|
||||||
label = 'Working';
|
label = 'Working';
|
||||||
}
|
}
|
||||||
|
|
||||||
const ariaLive = displayedPermission ? 'assertive' : 'polite';
|
const displayText = showEllipsis ? `${label}...` : label;
|
||||||
|
const isVisible = state !== 'idle';
|
||||||
const displayText = resultState === null ? `${label}...` : label;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`flex h-full items-center text-muted-foreground pl-[2ch] transition-opacity duration-200 ${isVisible && !isFadingOut ? 'opacity-100' : 'opacity-0'}`}
|
className={`flex h-full items-center text-muted-foreground pl-[2ch] transition-opacity duration-200 ${isVisible ? 'opacity-100' : 'opacity-0'}`}
|
||||||
role="status"
|
role="status"
|
||||||
aria-live={ariaLive}
|
aria-live={displayedPermission ? 'assertive' : 'polite'}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
data-waiting={displayedPermission ? 'true' : undefined}
|
data-waiting={displayedPermission ? 'true' : undefined}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
{resultState === null && (
|
{state === 'done' ? (
|
||||||
<Text
|
<Text variant="hover-enter" className="typography-ui-header">
|
||||||
variant="shine"
|
|
||||||
className="typography-ui-header"
|
|
||||||
>
|
|
||||||
{displayText}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{resultState === 'success' && (
|
|
||||||
<Text
|
|
||||||
variant="hover-enter"
|
|
||||||
className="typography-ui-header"
|
|
||||||
>
|
|
||||||
Done
|
Done
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
) : state === 'aborted' ? (
|
||||||
{resultState === 'aborted' && (
|
<Text variant="hover-enter" className="typography-ui-header text-status-error">
|
||||||
<Text
|
|
||||||
variant="hover-enter"
|
|
||||||
className="typography-ui-header text-status-error"
|
|
||||||
>
|
|
||||||
Aborted
|
Aborted
|
||||||
</Text>
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text variant="shine" className="typography-ui-header">
|
||||||
|
{displayText}
|
||||||
|
</Text>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface WorkingSummary {
|
|||||||
isCooldown: boolean;
|
isCooldown: boolean;
|
||||||
lifecyclePhase: MessageStreamPhase | null;
|
lifecyclePhase: MessageStreamPhase | null;
|
||||||
statusText: string | null;
|
statusText: string | null;
|
||||||
|
isGenericStatus: boolean;
|
||||||
isWaitingForPermission: boolean;
|
isWaitingForPermission: boolean;
|
||||||
canAbort: boolean;
|
canAbort: boolean;
|
||||||
compactionDeadline: number | null;
|
compactionDeadline: number | null;
|
||||||
@@ -59,6 +60,7 @@ const DEFAULT_WORKING: WorkingSummary = {
|
|||||||
isCooldown: false,
|
isCooldown: false,
|
||||||
lifecyclePhase: null,
|
lifecyclePhase: null,
|
||||||
statusText: null,
|
statusText: null,
|
||||||
|
isGenericStatus: true,
|
||||||
isWaitingForPermission: false,
|
isWaitingForPermission: false,
|
||||||
canAbort: false,
|
canAbort: false,
|
||||||
compactionDeadline: null,
|
compactionDeadline: null,
|
||||||
@@ -133,11 +135,12 @@ export function useAssistantStatus(): AssistantStatusSnapshot {
|
|||||||
activePartType: 'text' | 'tool' | 'reasoning' | 'editing' | undefined;
|
activePartType: 'text' | 'tool' | 'reasoning' | 'editing' | undefined;
|
||||||
activeToolName: string | undefined;
|
activeToolName: string | undefined;
|
||||||
statusText: string;
|
statusText: string;
|
||||||
|
isGenericStatus: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const parsedStatus = React.useMemo<ParsedStatusResult>(() => {
|
const parsedStatus = React.useMemo<ParsedStatusResult>(() => {
|
||||||
if (sessionMessages.length === 0) {
|
if (sessionMessages.length === 0) {
|
||||||
return { activePartType: undefined, activeToolName: undefined, statusText: 'working' };
|
return { activePartType: undefined, activeToolName: undefined, statusText: 'working', isGenericStatus: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
const assistantMessages = sessionMessages
|
const assistantMessages = sessionMessages
|
||||||
@@ -147,7 +150,7 @@ export function useAssistantStatus(): AssistantStatusSnapshot {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (assistantMessages.length === 0) {
|
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) => {
|
const sortedAssistantMessages = [...assistantMessages].sort((a, b) => {
|
||||||
@@ -252,6 +255,7 @@ export function useAssistantStatus(): AssistantStatusSnapshot {
|
|||||||
return WORKING_PHRASES[Math.floor(Math.random() * WORKING_PHRASES.length)];
|
return WORKING_PHRASES[Math.floor(Math.random() * WORKING_PHRASES.length)];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isGenericStatus = activePartType === undefined;
|
||||||
const statusText = (() => {
|
const statusText = (() => {
|
||||||
if (activePartType === 'editing') return 'editing file';
|
if (activePartType === 'editing') return 'editing file';
|
||||||
if (activePartType === 'tool' && activeToolName) return getToolStatusPhrase(activeToolName);
|
if (activePartType === 'tool' && activeToolName) return getToolStatusPhrase(activeToolName);
|
||||||
@@ -260,7 +264,7 @@ export function useAssistantStatus(): AssistantStatusSnapshot {
|
|||||||
return getRandomWorkingPhrase();
|
return getRandomWorkingPhrase();
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return { activePartType, activeToolName, statusText };
|
return { activePartType, activeToolName, statusText, isGenericStatus };
|
||||||
}, [sessionMessages]);
|
}, [sessionMessages]);
|
||||||
|
|
||||||
const abortState = React.useMemo(() => {
|
const abortState = React.useMemo(() => {
|
||||||
@@ -309,6 +313,7 @@ export function useAssistantStatus(): AssistantStatusSnapshot {
|
|||||||
isCooldown,
|
isCooldown,
|
||||||
lifecyclePhase: isStreaming ? 'streaming' : isCooldown ? 'cooldown' : null,
|
lifecyclePhase: isStreaming ? 'streaming' : isCooldown ? 'cooldown' : null,
|
||||||
statusText: isWorking ? parsedStatus.statusText : null,
|
statusText: isWorking ? parsedStatus.statusText : null,
|
||||||
|
isGenericStatus: isWorking ? parsedStatus.isGenericStatus : true,
|
||||||
isWaitingForPermission: false,
|
isWaitingForPermission: false,
|
||||||
canAbort: isWorking,
|
canAbort: isWorking,
|
||||||
compactionDeadline: null,
|
compactionDeadline: null,
|
||||||
@@ -317,7 +322,6 @@ export function useAssistantStatus(): AssistantStatusSnapshot {
|
|||||||
wasAborted: false,
|
wasAborted: false,
|
||||||
abortActive: false,
|
abortActive: false,
|
||||||
lastCompletionId: null,
|
lastCompletionId: null,
|
||||||
|
|
||||||
isComplete: isCooldown,
|
isComplete: isCooldown,
|
||||||
};
|
};
|
||||||
}, [activityPhase, isPhaseWorking, isPhaseCooldown, parsedStatus, abortState]);
|
}, [activityPhase, isPhaseWorking, isPhaseCooldown, parsedStatus, abortState]);
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import { useSessionStore } from '@/stores/useSessionStore';
|
import { useSessionStore } from '@/stores/useSessionStore';
|
||||||
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
|
import { opencodeClient } from '@/lib/opencode/client';
|
||||||
|
import { checkIsGitRepository } from '@/lib/gitApi';
|
||||||
|
|
||||||
export interface DebugMessageInfo {
|
export interface DebugMessageInfo {
|
||||||
messageId: string;
|
messageId: string;
|
||||||
@@ -169,6 +172,174 @@ export const debugUtils = {
|
|||||||
return truncate ? this.truncateMessages(messages) : messages;
|
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<string>();
|
||||||
|
const sessionDirectoryCounts: Record<string, number> = {};
|
||||||
|
|
||||||
|
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<unknown> => {
|
||||||
|
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() {
|
checkLastMessage() {
|
||||||
const info = this.getLastAssistantMessage();
|
const info = this.getLastAssistantMessage();
|
||||||
if (!info) return false;
|
if (!info) return false;
|
||||||
@@ -465,6 +636,7 @@ if (typeof window !== 'undefined') {
|
|||||||
console.log(' __opencodeDebug.getLastAssistantMessage() - Get last assistant message details');
|
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.getAllMessages(truncate?) - List all messages (truncate=true for short preview)');
|
||||||
console.log(' __opencodeDebug.truncateMessages(messages) - Truncate long fields in messages array');
|
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.checkLastMessage() - Check if last message is problematic');
|
||||||
console.log(' __opencodeDebug.findEmptyMessages() - Find all empty assistant messages');
|
console.log(' __opencodeDebug.findEmptyMessages() - Find all empty assistant messages');
|
||||||
console.log(' __opencodeDebug.showRetryHelp() - Show instructions for handling empty responses');
|
console.log(' __opencodeDebug.showRetryHelp() - Show instructions for handling empty responses');
|
||||||
|
|||||||
@@ -351,17 +351,24 @@ export const useConfigStore = create<ConfigStore>()(
|
|||||||
const buildAgent = primaryAgents.find((agent) => agent.name === "build");
|
const buildAgent = primaryAgents.find((agent) => agent.name === "build");
|
||||||
const defaultAgent = buildAgent || primaryAgents[0] || safeAgents[0];
|
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) {
|
if (resolvedAgentName !== existingAgentName) {
|
||||||
const agentProvider = providers.find((p) => p.id === defaultAgent.model!.providerID);
|
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) {
|
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) {
|
if (agentModel) {
|
||||||
set({
|
set({
|
||||||
currentProviderId: defaultAgent.model!.providerID,
|
currentProviderId: agentForDefaults.model!.providerID,
|
||||||
currentModelId: defaultAgent.model!.modelID,
|
currentModelId: agentForDefaults.model!.modelID,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = () => {
|
const getHomeDirectory = () => {
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
|
const storedHome = safeStorage.getItem('homeDirectory') || cachedHomeDirectory || null;
|
||||||
const saved = safeStorage.getItem('lastDirectory');
|
const saved = safeStorage.getItem('lastDirectory');
|
||||||
if (saved) return saved;
|
if (saved) {
|
||||||
|
return resolveDirectoryPath(saved, storedHome);
|
||||||
|
}
|
||||||
|
|
||||||
if (cachedHomeDirectory) return cachedHomeDirectory;
|
if (cachedHomeDirectory) return cachedHomeDirectory;
|
||||||
|
|
||||||
@@ -149,7 +183,6 @@ const getHomeDirectory = () => {
|
|||||||
return desktopHome;
|
return desktopHome;
|
||||||
}
|
}
|
||||||
|
|
||||||
const storedHome = safeStorage.getItem('homeDirectory');
|
|
||||||
if (storedHome) {
|
if (storedHome) {
|
||||||
cachedHomeDirectory = storedHome;
|
cachedHomeDirectory = storedHome;
|
||||||
return storedHome;
|
return storedHome;
|
||||||
@@ -163,6 +196,7 @@ const getHomeDirectory = () => {
|
|||||||
return process?.cwd?.() || '/';
|
return process?.cwd?.() || '/';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const normalizeHomeCandidate = (value?: string | null) => {
|
const normalizeHomeCandidate = (value?: string | null) => {
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== 'string') {
|
||||||
return null;
|
return null;
|
||||||
@@ -260,24 +294,26 @@ export const useDirectoryStore = create<DirectoryStore>()(
|
|||||||
isSwitchingDirectory: false,
|
isSwitchingDirectory: false,
|
||||||
|
|
||||||
setDirectory: (path: string, options?: { showOverlay?: boolean }) => {
|
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;
|
const showOverlay = options?.showOverlay ?? true;
|
||||||
|
|
||||||
opencodeClient.setDirectory(path);
|
opencodeClient.setDirectory(resolvedPath);
|
||||||
invalidateFileSearchCache();
|
invalidateFileSearchCache();
|
||||||
const restartPromise = notifyOpenCodeWorkingDirectory(path, { showOverlay });
|
const restartPromise = notifyOpenCodeWorkingDirectory(resolvedPath, { showOverlay });
|
||||||
console.log('[DirectoryStore] notifyOpenCodeWorkingDirectory initiated');
|
console.log('[DirectoryStore] notifyOpenCodeWorkingDirectory initiated');
|
||||||
|
|
||||||
set((state) => {
|
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 {
|
return {
|
||||||
currentDirectory: path,
|
currentDirectory: resolvedPath,
|
||||||
directoryHistory: newHistory,
|
directoryHistory: newHistory,
|
||||||
historyIndex: newHistory.length - 1,
|
historyIndex: newHistory.length - 1,
|
||||||
hasPersistedDirectory: true,
|
hasPersistedDirectory: true,
|
||||||
@@ -288,7 +324,7 @@ export const useDirectoryStore = create<DirectoryStore>()(
|
|||||||
|
|
||||||
scheduleDirectoryFollowUp(restartPromise, { showOverlay }, () => {
|
scheduleDirectoryFollowUp(restartPromise, { showOverlay }, () => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
if (state.currentDirectory !== path) {
|
if (state.currentDirectory !== resolvedPath) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
if (!state.isSwitchingDirectory) {
|
if (!state.isSwitchingDirectory) {
|
||||||
@@ -426,6 +462,13 @@ export const useDirectoryStore = create<DirectoryStore>()(
|
|||||||
|
|
||||||
const resolvedReady = typeof resolvedHome === 'string' && resolvedHome !== '' && resolvedHome !== '/';
|
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<DirectoryStore> = {
|
const updates: Partial<DirectoryStore> = {
|
||||||
homeDirectory: resolvedHome,
|
homeDirectory: resolvedHome,
|
||||||
hasPersistedDirectory: hasSavedLastDirectory,
|
hasPersistedDirectory: hasSavedLastDirectory,
|
||||||
@@ -437,20 +480,26 @@ export const useDirectoryStore = create<DirectoryStore>()(
|
|||||||
updates.directoryHistory = [resolvedHome];
|
updates.directoryHistory = [resolvedHome];
|
||||||
updates.historyIndex = 0;
|
updates.historyIndex = 0;
|
||||||
updates.isSwitchingDirectory = true;
|
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<DirectoryStore>);
|
set(() => updates as Partial<DirectoryStore>);
|
||||||
|
|
||||||
if (shouldReplaceCurrent && resolvedReady) {
|
if ((shouldReplaceCurrent || currentChanged) && resolvedReady) {
|
||||||
opencodeClient.setDirectory(resolvedHome);
|
const nextDirectory = shouldReplaceCurrent ? resolvedHome : (resolvedCurrent as string);
|
||||||
|
opencodeClient.setDirectory(nextDirectory);
|
||||||
invalidateFileSearchCache();
|
invalidateFileSearchCache();
|
||||||
safeStorage.setItem('lastDirectory', resolvedHome);
|
safeStorage.setItem('lastDirectory', nextDirectory);
|
||||||
void updateDesktopSettings({ lastDirectory: resolvedHome });
|
void updateDesktopSettings({ lastDirectory: nextDirectory });
|
||||||
|
|
||||||
const restartPromise = notifyOpenCodeWorkingDirectory(resolvedHome, { showOverlay: false });
|
const restartPromise = notifyOpenCodeWorkingDirectory(nextDirectory, { showOverlay: false });
|
||||||
scheduleDirectoryFollowUp(restartPromise, { showOverlay: false }, () => {
|
scheduleDirectoryFollowUp(restartPromise, { showOverlay: false }, () => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
if (state.currentDirectory !== resolvedHome) {
|
if (state.currentDirectory !== nextDirectory) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
if (!state.isSwitchingDirectory) {
|
if (!state.isSwitchingDirectory) {
|
||||||
|
|||||||
Vendored
+1
@@ -5,6 +5,7 @@ interface Window {
|
|||||||
getLastAssistantMessage: () => unknown;
|
getLastAssistantMessage: () => unknown;
|
||||||
getAllMessages: (truncate?: boolean) => unknown[];
|
getAllMessages: (truncate?: boolean) => unknown[];
|
||||||
truncateMessages: (messages: unknown[]) => unknown[];
|
truncateMessages: (messages: unknown[]) => unknown[];
|
||||||
|
getAppStatus: () => Promise<unknown>;
|
||||||
checkLastMessage: () => boolean;
|
checkLastMessage: () => boolean;
|
||||||
findEmptyMessages: () => unknown[];
|
findEmptyMessages: () => unknown[];
|
||||||
showRetryHelp: () => void;
|
showRetryHelp: () => void;
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ import { createOpenCodeManager, type OpenCodeManager } from './opencode';
|
|||||||
let chatViewProvider: ChatViewProvider | undefined;
|
let chatViewProvider: ChatViewProvider | undefined;
|
||||||
let openCodeManager: OpenCodeManager | undefined;
|
let openCodeManager: OpenCodeManager | undefined;
|
||||||
|
|
||||||
export function activate(context: vscode.ExtensionContext) {
|
export async function activate(context: vscode.ExtensionContext) {
|
||||||
// Create OpenCode manager first
|
// Create OpenCode manager first
|
||||||
openCodeManager = createOpenCodeManager(context);
|
openCodeManager = createOpenCodeManager(context);
|
||||||
|
|
||||||
// Create chat view provider with manager reference
|
// 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);
|
chatViewProvider = new ChatViewProvider(context, context.extensionUri, openCodeManager);
|
||||||
|
|
||||||
context.subscriptions.push(
|
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(
|
context.subscriptions.push(
|
||||||
openCodeManager.onStatusChange((status, error) => {
|
openCodeManager.onStatusChange((status, error) => {
|
||||||
chatViewProvider?.updateConnectionStatus(status, error);
|
chatViewProvider?.updateConnectionStatus(status, error);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Auto-start OpenCode API
|
// Start OpenCode API and wait for it to be ready
|
||||||
openCodeManager.start();
|
// The webview will show loading state during this time
|
||||||
|
await openCodeManager.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deactivate() {
|
export async function deactivate() {
|
||||||
openCodeManager?.stop();
|
await openCodeManager?.stop();
|
||||||
openCodeManager = undefined;
|
openCodeManager = undefined;
|
||||||
chatViewProvider = undefined;
|
chatViewProvider = undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,11 +20,5 @@
|
|||||||
"@openchamber/ui/*": ["../ui/src/*"]
|
"@openchamber/ui/*": ["../ui/src/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["webview/**/*", "../ui/src/**/*"],
|
"include": ["webview/**/*", "../ui/src/**/*"]
|
||||||
"exclude": [
|
|
||||||
"webview/components/**/*",
|
|
||||||
"webview/stores/**/*",
|
|
||||||
"webview/hooks/**/*",
|
|
||||||
"webview/App.tsx"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user