Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 13 additions & 8 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,14 @@ import {
import { listRecentWorkspaceFiles } from './utils/recent-workspace-files';
import { buildDiagnosticsSummary } from './utils/diagnostics-summary';

function omitCredentialPassword<T extends { password?: unknown }>(
credential: T
): Omit<T, 'password'> {
const safeCredential = { ...credential };
delete safeCredential.password;
return safeCredential;
}

// Current working directory (persisted between sessions)
let currentWorkingDir: string | null = null;

Expand Down Expand Up @@ -1634,8 +1642,7 @@ ipcMain.handle('credentials.getById', (_event, id: string) => {
const cred = credentialsStore.getById(id);
if (!cred) return undefined;
// Strip password field before sending to renderer
const safeCred = { ...cred, password: undefined };
return safeCred;
return omitCredentialPassword(cred);
} catch (error) {
logError('[Credentials] Error getting credential:', error);
return undefined;
Expand All @@ -1646,7 +1653,7 @@ ipcMain.handle('credentials.getByType', (_event, type: UserCredential['type']) =
try {
const creds = credentialsStore.getByType(type);
// Strip password field before sending to renderer
return creds.map((c) => ({ ...c, password: undefined }));
return creds.map(omitCredentialPassword);
} catch (error) {
logError('[Credentials] Error getting credentials by type:', error);
return [];
Expand All @@ -1657,7 +1664,7 @@ ipcMain.handle('credentials.getByService', (_event, service: string) => {
try {
const creds = credentialsStore.getByService(service);
// Strip password field before sending to renderer
return creds.map((c) => ({ ...c, password: undefined }));
return creds.map(omitCredentialPassword);
} catch (error) {
logError('[Credentials] Error getting credentials by service:', error);
return [];
Expand All @@ -1669,8 +1676,7 @@ ipcMain.handle(
(_event, credential: Omit<UserCredential, 'id' | 'createdAt' | 'updatedAt'>) => {
try {
const saved = credentialsStore.save(credential);
const { password: _pw, ...safe } = saved;
return safe;
return omitCredentialPassword(saved);
} catch (error) {
logError('[Credentials] Error saving credential:', error);
throw error;
Expand All @@ -1688,8 +1694,7 @@ ipcMain.handle(
try {
const updated = credentialsStore.update(id, updates);
if (!updated) return undefined;
const { password: _pw, ...safe } = updated;
return safe;
return omitCredentialPassword(updated);
} catch (error) {
logError('[Credentials] Error updating credential:', error);
throw error;
Expand Down
2 changes: 1 addition & 1 deletion src/main/sandbox/lima-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -721,7 +721,7 @@ export class LimaBridge implements SandboxExecutor {
this.limaProcess = null;
this.isInitialized = false;

for (const [_id, pending] of this.pendingRequests) {
for (const pending of this.pendingRequests.values()) {
pending.reject(new Error('Lima agent process exited'));
clearTimeout(pending.timeout);
}
Expand Down
2 changes: 1 addition & 1 deletion src/main/sandbox/wsl-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -868,7 +868,7 @@ export class WSLBridge implements SandboxExecutor {
this.isInitialized = false;

// Reject all pending requests
for (const [_id, pending] of this.pendingRequests) {
for (const pending of this.pendingRequests.values()) {
pending.reject(new Error('WSL agent process exited'));
clearTimeout(pending.timeout);
}
Expand Down
105 changes: 67 additions & 38 deletions src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,18 @@ import type { GlobalNoticeAction } from './store';
// Check if running in Electron
const isElectronEnv = typeof window !== 'undefined' && window.electronAPI !== undefined;

const ChatView = lazy(() => import('./components/ChatView').then((module) => ({ default: module.ChatView })));
const ContextPanel = lazy(() => import('./components/ContextPanel').then((module) => ({ default: module.ContextPanel })));
const ConfigModal = lazy(() => import('./components/ConfigModal').then((module) => ({ default: module.ConfigModal })));
const SettingsPanel = lazy(() => import('./components/SettingsPanel').then((module) => ({ default: module.SettingsPanel })));
const ChatView = lazy(() =>
import('./components/ChatView').then((module) => ({ default: module.ChatView }))
);
const ContextPanel = lazy(() =>
import('./components/ContextPanel').then((module) => ({ default: module.ContextPanel }))
);
const ConfigModal = lazy(() =>
import('./components/ConfigModal').then((module) => ({ default: module.ConfigModal }))
);
const SettingsPanel = lazy(() =>
import('./components/SettingsPanel').then((module) => ({ default: module.SettingsPanel }))
);

function MainPanelFallback() {
return (
Expand All @@ -43,7 +51,12 @@ function MainPanelFallback() {
}

function ContextPanelFallback() {
return <div className="hidden xl:block w-[340px] shrink-0 border-l border-border-subtle bg-background/60" aria-hidden="true" />;
return (
<div
className="hidden xl:block w-[340px] shrink-0 border-l border-border-subtle bg-background/60"
aria-hidden="true"
/>
);
}

function App() {
Expand All @@ -55,7 +68,8 @@ function App() {
const { sidebarCollapsed } = useLayoutState();
const { showConfigModal, isConfigured, appConfig } = useConfigModalState();
const globalNotice = useGlobalNotice();
const { progress: sandboxSetupProgress, isComplete: isSandboxSetupComplete } = useSandboxSetupState();
const { progress: sandboxSetupProgress, isComplete: isSandboxSetupComplete } =
useSandboxSetupState();
const sandboxSyncStatus = useSandboxSyncStatus();
const { pendingPermission, pendingSudoPassword } = usePendingDialogs();

Expand All @@ -75,21 +89,18 @@ function App() {
const sidebarBeforeSettings = useRef(false);

useEffect(() => {
// Only run once on mount
if (initialized.current) return;
initialized.current = true;

if (isElectron) {
listSessions();
}
}, []); // Empty deps - run once
}, [isElectron, listSessions]);

// Apply theme to document root
useEffect(() => {
const effectiveTheme =
settings.theme === 'system'
? (systemDarkMode ? 'dark' : 'light')
: settings.theme;
settings.theme === 'system' ? (systemDarkMode ? 'dark' : 'light') : settings.theme;

if (effectiveTheme === 'light') {
document.documentElement.classList.add('light');
Expand All @@ -113,22 +124,25 @@ function App() {
setSidebarCollapsed(false);
sidebarBeforeSettings.current = false;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showSettings]);

// Handle config save
const handleConfigSave = useCallback(async (newConfig: Partial<AppConfig>) => {
if (!isElectronEnv) {
console.log('[App] Browser mode - config save simulated');
return;
}

const result = await window.electronAPI.config.save(newConfig);
if (result.success) {
setIsConfigured(Boolean(result.config?.isConfigured));
setAppConfig(result.config);
}
}, [setIsConfigured, setAppConfig]);
const handleConfigSave = useCallback(
async (newConfig: Partial<AppConfig>) => {
if (!isElectronEnv) {
console.log('[App] Browser mode - config save simulated');
return;
}

const result = await window.electronAPI.config.save(newConfig);
if (result.success) {
setIsConfigured(Boolean(result.config?.isConfigured));
setAppConfig(result.config);
}
},
[setIsConfigured, setAppConfig]
);

// Handle config modal close
const handleConfigClose = useCallback(() => {
Expand All @@ -140,12 +154,15 @@ function App() {
setSandboxSetupComplete(true);
}, [setSandboxSetupComplete]);

const handleGlobalNoticeAction = useCallback((action: GlobalNoticeAction) => {
if (action === 'open_api_settings') {
setShowConfigModal(true);
}
clearGlobalNotice();
}, [clearGlobalNotice, setShowConfigModal]);
const handleGlobalNoticeAction = useCallback(
(action: GlobalNoticeAction) => {
if (action === 'open_api_settings') {
setShowConfigModal(true);
}
clearGlobalNotice();
},
[clearGlobalNotice, setShowConfigModal]
);

// Determine if we should show the sandbox setup dialog
// Show if there's progress and setup is not complete
Expand All @@ -155,7 +172,7 @@ function App() {
<div className="h-full w-full min-h-0 flex flex-col overflow-hidden bg-background">
{/* Titlebar - draggable region */}
<Titlebar />

{/* Main Content */}
<div className="flex-1 min-h-0 flex overflow-hidden">
{/* Sidebar */}
Expand All @@ -166,13 +183,21 @@ function App() {
{/* Main Content Area */}
<main className="flex-1 min-h-0 min-w-0 flex flex-col overflow-hidden bg-background">
{showSettings ? (
<PanelErrorBoundary name="SettingsPanel" resetKey="settings" fallback={<MainPanelFallback />}>
<PanelErrorBoundary
name="SettingsPanel"
resetKey="settings"
fallback={<MainPanelFallback />}
>
<Suspense fallback={<MainPanelFallback />}>
<SettingsPanel onClose={() => setShowSettings(false)} />
</Suspense>
</PanelErrorBoundary>
) : activeSessionId ? (
<PanelErrorBoundary name="ChatView" resetKey={activeSessionId} fallback={<MainPanelFallback />}>
<PanelErrorBoundary
name="ChatView"
resetKey={activeSessionId}
fallback={<MainPanelFallback />}
>
<Suspense fallback={<MainPanelFallback />}>
<ChatView />
</Suspense>
Expand All @@ -184,20 +209,24 @@ function App() {

{/* Context Panel - only show when in session and not in settings */}
{activeSessionId && !showSettings && (
<PanelErrorBoundary name="ContextPanel" resetKey={activeSessionId} fallback={<ContextPanelFallback />}>
<PanelErrorBoundary
name="ContextPanel"
resetKey={activeSessionId}
fallback={<ContextPanelFallback />}
>
<Suspense fallback={<ContextPanelFallback />}>
<ContextPanel />
</Suspense>
</PanelErrorBoundary>
)}
</div>

{/* Permission Dialog */}
{pendingPermission && <PermissionDialog permission={pendingPermission} />}

{/* Sudo Password Dialog */}
{pendingSudoPassword && <SudoPasswordDialog request={pendingSudoPassword} />}

{/* Config Modal */}
<PanelErrorBoundary name="ConfigModal" fallback={null}>
<Suspense fallback={null}>
Expand All @@ -213,12 +242,12 @@ function App() {

{/* Sandbox Setup Dialog */}
{showSandboxSetup && (
<SandboxSetupDialog
<SandboxSetupDialog
progress={sandboxSetupProgress}
onComplete={handleSandboxSetupComplete}
/>
)}

{/* Sandbox Sync Toast */}
<SandboxSyncToast status={sandboxSyncStatus} />

Expand Down
72 changes: 39 additions & 33 deletions src/renderer/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,43 +132,46 @@ export function ChatView() {
const timerActive = Boolean(executionClock?.startAt && executionClock.endAt === null);

// Debounced scroll function to prevent scroll conflicts
const scrollToBottom = useRef((behavior: ScrollBehavior = 'auto', immediate: boolean = false) => {
// Cancel any pending scroll requests
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
scrollTimeoutRef.current = null;
}
if (scrollRequestRef.current) {
cancelAnimationFrame(scrollRequestRef.current);
scrollRequestRef.current = null;
}
const scrollToBottom = useCallback(
(behavior: ScrollBehavior = 'auto', immediate: boolean = false) => {
// Cancel any pending scroll requests
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
scrollTimeoutRef.current = null;
}
if (scrollRequestRef.current) {
cancelAnimationFrame(scrollRequestRef.current);
scrollRequestRef.current = null;
}

const performScroll = () => {
if (!isUserAtBottomRef.current) return;
const performScroll = () => {
if (!isUserAtBottomRef.current) return;

// Mark as scrolling to prevent concurrent scrolls
isScrollingRef.current = true;
// Mark as scrolling to prevent concurrent scrolls
isScrollingRef.current = true;

messagesEndRef.current?.scrollIntoView({ behavior });
messagesEndRef.current?.scrollIntoView({ behavior });

// Reset scrolling flag after a short delay
setTimeout(
() => {
isScrollingRef.current = false;
},
behavior === 'smooth' ? 300 : 50
);
};
// Reset scrolling flag after a short delay
setTimeout(
() => {
isScrollingRef.current = false;
},
behavior === 'smooth' ? 300 : 50
);
};

if (immediate) {
performScroll();
} else {
// Use RAF + timeout for debouncing
scrollRequestRef.current = requestAnimationFrame(() => {
scrollTimeoutRef.current = setTimeout(performScroll, 16); // ~1 frame delay
});
}
}).current;
if (immediate) {
performScroll();
} else {
// Use RAF + timeout for debouncing
scrollRequestRef.current = requestAnimationFrame(() => {
scrollTimeoutRef.current = setTimeout(performScroll, 16); // ~1 frame delay
});
}
},
[]
);

useEffect(() => {
const container = scrollContainerRef.current;
Expand Down Expand Up @@ -211,9 +214,11 @@ export function ChatView() {

prevMessageCountRef.current = messageCount;
prevPartialLengthRef.current = partialLength;
}, [messages.length, partialMessage.length, partialThinking.length]);
}, [messages.length, partialMessage.length, partialThinking.length, scrollToBottom]);

// Additional scroll trigger for content height changes (e.g., TodoWrite expand/collapse)
// scrollToBottom is stable via useCallback; recreating the observer is unnecessary.
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => {
const container = scrollContainerRef.current;
const messagesContainer = messagesContainerRef.current;
Expand All @@ -232,6 +237,7 @@ export function ChatView() {
return () => {
resizeObserver.disconnect();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // ResizeObserver is stable — no need to recreate on message count changes

// Cleanup scroll timeouts on unmount
Expand Down
Loading
Loading