feat: add Electron Mini Chat windows (#1161)

Add dedicated Electron Mini Chat windows for focused chat sessions without the full desktop shell. Mini Chat can open existing sessions or draft sessions, supports pinning above other windows, transfers sessions or drafts back to the main window, and deduplicates existing-session windows.

Expose Mini Chat entry points from the main header, session sidebar, command palette, and `mod+alt+n`. Add a dedicated Vite entry and React runtime so the compact surface can stay isolated from full-app chrome while still sharing chat, sync, theme, locale, model, agent, and worktree behavior.

Keep Mini Chat behavior scoped to the compact surface:
- limit assistant/user message actions to the appropriate Mini Chat set
- hide workspace changed-files UI in Mini Chat
- keep draft worktree selection and streaming directory state in sync
- mark sessions viewed while they are open in Mini Chat
- support Mini Chat-specific keyboard shortcuts for input focus, model selection, thinking variant cycling, favorite model cycling, and opening new Mini Chat drafts

Harden Electron integration by gating Mini Chat controls on desktop IPC availability, restricting pin/unpin IPC to Mini Chat windows, and only closing Mini Chat after the main window handoff succeeds.
This commit is contained in:
Bohdan Triapitsyn
2026-05-08 12:22:59 +03:00
committed by GitHub
parent 8410c41b01
commit e1ff21bc0a
37 changed files with 1312 additions and 29 deletions
+50 -3
View File
@@ -5,8 +5,19 @@ import { useQueuedMessageAutoSend } from '@/hooks/useQueuedMessageAutoSend';
import { useSessionAutoCleanup } from '@/hooks/useSessionAutoCleanup';
import { useWindowControlsOverlayLayout } from '@/hooks/useWindowControlsOverlayLayout';
import { setOptimisticRefs } from '@/sync/session-actions';
import { markSessionViewed } from '@/sync/notification-store';
import { setExternallyViewedSession } from '@/sync/sync-context';
import { useSync } from '@/sync/use-sync';
const MINI_CHAT_PRESENCE_CHANNEL = 'openchamber:mini-chat-presence';
type MiniChatPresenceMessage = {
type?: string;
sessionId?: string;
directory?: string;
viewed?: boolean;
};
const SyncOptimisticBridge: React.FC = () => {
const sync = useSync();
const addRef = React.useRef(sync.optimistic.add);
@@ -24,14 +35,50 @@ const SyncOptimisticBridge: React.FC = () => {
return null;
};
const MiniChatPresenceBridge: React.FC = () => {
React.useEffect(() => {
if (typeof BroadcastChannel === 'undefined') return;
const channel = new BroadcastChannel(MINI_CHAT_PRESENCE_CHANNEL);
channel.onmessage = (event) => {
const data = event.data as MiniChatPresenceMessage | null;
if (data?.type !== 'mini-chat-session-presence' || !data.sessionId || !data.directory) {
return;
}
const viewed = data.viewed !== false;
setExternallyViewedSession(data.directory, data.sessionId, viewed);
if (viewed) {
markSessionViewed(data.sessionId);
}
};
return () => channel.close();
}, []);
return null;
};
export function SyncRuntimeEffects({ embeddedBackgroundWorkEnabled }: {
embeddedBackgroundWorkEnabled: boolean;
}) {
useSessionAutoCleanup(embeddedBackgroundWorkEnabled);
useQueuedMessageAutoSend(embeddedBackgroundWorkEnabled);
return <SyncOptimisticBridge />;
}
export function SyncAppEffects({ embeddedBackgroundWorkEnabled }: {
embeddedBackgroundWorkEnabled: boolean;
}) {
usePwaManifestSync();
useWindowControlsOverlayLayout();
useSessionAutoCleanup(embeddedBackgroundWorkEnabled);
useQueuedMessageAutoSend(embeddedBackgroundWorkEnabled);
useKeyboardShortcuts();
return <SyncOptimisticBridge />;
return (
<>
<SyncRuntimeEffects embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled} />
<MiniChatPresenceBridge />
</>
);
}
@@ -0,0 +1,270 @@
import React from 'react';
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider';
import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { TooltipProvider } from '@/components/ui/tooltip';
import { Toaster } from '@/components/ui/sonner';
import { MiniChatLayout } from '@/components/mini-chat/MiniChatLayout';
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
import { useWindowTitle } from '@/hooks/useWindowTitle';
import { opencodeClient } from '@/lib/opencode/client';
import type { RuntimeAPIs } from '@/lib/api/types';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useGitStore } from '@/stores/useGitStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { SyncProvider, useSessions } from '@/sync/sync-context';
import { SyncRuntimeEffects } from './AppEffects';
import { useAppFontEffects } from './useAppFontEffects';
import { useMiniChatKeyboardShortcuts } from '@/hooks/useMiniChatKeyboardShortcuts';
import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager';
import type { WorktreeMetadata } from '@/types/worktree';
const MINI_CHAT_PRESENCE_CHANNEL = 'openchamber:mini-chat-presence';
type MiniChatMode = 'session' | 'draft';
type MiniChatConfig = {
mode: MiniChatMode;
sessionId: string | null;
directory: string | null;
projectId: string | null;
};
type ElectronMiniChatAppProps = {
apis: RuntimeAPIs;
};
const readMiniChatConfig = (): MiniChatConfig => {
const params = typeof window !== 'undefined' ? new URLSearchParams(window.location.search) : new URLSearchParams();
const mode = params.get('mode') === 'session' ? 'session' : 'draft';
const sessionId = params.get('sessionId')?.trim() || null;
const directory = params.get('directory')?.trim() || null;
const projectId = params.get('projectId')?.trim() || null;
return { mode, sessionId, directory, projectId };
};
const MiniChatBootstrap: React.FC<{ config: MiniChatConfig }> = ({ config }) => {
const sessions = useSessions();
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
const setDirectory = useDirectoryStore((state) => state.setDirectory);
const projects = useProjectsStore((state) => state.projects);
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const draftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
const draftDirectory = useSessionUIStore((state) => {
if (!state.newSessionDraft?.open) return '';
return state.newSessionDraft.bootstrapPendingDirectory ?? state.newSessionDraft.directoryOverride ?? '';
});
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
const initializeApp = useConfigStore((state) => state.initializeApp);
const isInitialized = useConfigStore((state) => state.isInitialized);
const isConnected = useConfigStore((state) => state.isConnected);
const loadProviders = useConfigStore((state) => state.loadProviders);
const loadAgents = useConfigStore((state) => state.loadAgents);
const providersCount = useConfigStore((state) => state.providers.length);
const agentsCount = useConfigStore((state) => state.agents.length);
React.useEffect(() => {
void initializeApp();
}, [initializeApp]);
React.useEffect(() => {
if (isInitialized) return;
let active = true;
let retryCount = 0;
const id = window.setInterval(() => {
if (!active) return;
retryCount += 1;
if (retryCount > 10) {
window.clearInterval(id);
return;
}
if (!useConfigStore.getState().isInitialized) {
void useConfigStore.getState().initializeApp();
}
}, 1000);
return () => {
active = false;
window.clearInterval(id);
};
}, [isInitialized]);
React.useEffect(() => {
if (config.mode !== 'session') return;
if (!config.directory || currentDirectory === config.directory) return;
setDirectory(config.directory, { showOverlay: false });
}, [config.directory, config.mode, currentDirectory, setDirectory]);
React.useEffect(() => {
if (config.mode !== 'draft' || !draftOpen || currentSessionId) return;
if (!draftDirectory || currentDirectory === draftDirectory) return;
setDirectory(draftDirectory, { showOverlay: false });
}, [config.mode, currentDirectory, currentSessionId, draftDirectory, draftOpen, setDirectory]);
React.useEffect(() => {
if (!isConnected) return;
if (providersCount === 0) void loadProviders();
if (agentsCount === 0) void loadAgents();
}, [agentsCount, isConnected, loadAgents, loadProviders, providersCount]);
React.useEffect(() => {
if (config.mode !== 'session' || !config.sessionId) return;
if (currentSessionId === config.sessionId) return;
const session = sessions.find((entry) => entry.id === config.sessionId);
if (!session) return;
const directory = (session as { directory?: string | null }).directory ?? config.directory;
setCurrentSession(config.sessionId, directory);
}, [config, currentSessionId, sessions, setCurrentSession]);
React.useEffect(() => {
if (config.mode !== 'draft' || draftOpen || currentSessionId) return;
openNewSessionDraft({
selectedProjectId: config.projectId,
directoryOverride: config.directory,
preserveDirectoryOverride: Boolean(config.directory),
});
}, [config, currentSessionId, draftOpen, openNewSessionDraft]);
React.useEffect(() => {
if (projects.length === 0) return;
let cancelled = false;
const discoverWorktrees = async () => {
const worktreesByProject = new Map<string, WorktreeMetadata[]>();
const allWorktrees: WorktreeMetadata[] = [];
await Promise.all(projects.map(async (project) => {
const projectPath = project.path.replace(/\\/g, '/').replace(/\/+$/, '');
if (!projectPath) return;
try {
const cachedIsGitRepo = useGitStore.getState().directories.get(projectPath)?.isGitRepo;
const isGitRepo = cachedIsGitRepo ?? await import('@/lib/gitApi').then((m) => m.checkIsGitRepository(projectPath));
if (!isGitRepo) return;
const worktrees = await listProjectWorktrees({ id: project.id, path: projectPath });
if (cancelled || worktrees.length === 0) return;
worktreesByProject.set(projectPath, worktrees);
allWorktrees.push(...worktrees);
} catch {
// Worktree discovery is best-effort; draft selector falls back to the project root.
}
}));
if (cancelled) return;
useSessionUIStore.setState({
availableWorktrees: allWorktrees,
availableWorktreesByProject: worktreesByProject,
});
};
void discoverWorktrees();
return () => {
cancelled = true;
};
}, [projects]);
return null;
};
const MiniChatPresencePublisher: React.FC = () => {
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
React.useEffect(() => {
if (!currentSessionId || !currentDirectory || typeof BroadcastChannel === 'undefined') return;
const channel = new BroadcastChannel(MINI_CHAT_PRESENCE_CHANNEL);
const postPresence = (viewed: boolean) => {
channel.postMessage({
type: 'mini-chat-session-presence',
sessionId: currentSessionId,
directory: currentDirectory,
viewed,
});
};
postPresence(true);
const interval = window.setInterval(() => postPresence(true), 5_000);
const handleBeforeUnload = () => postPresence(false);
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
window.clearInterval(interval);
window.removeEventListener('beforeunload', handleBeforeUnload);
postPresence(false);
channel.close();
};
}, [currentDirectory, currentSessionId]);
return null;
};
const useSessionUnavailable = (config: MiniChatConfig): boolean => {
const sessions = useSessions();
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const [timedOut, setTimedOut] = React.useState(false);
React.useEffect(() => {
if (config.mode !== 'session' || !config.sessionId || currentSessionId === config.sessionId) {
setTimedOut(false);
return;
}
if (sessions.some((entry) => entry.id === config.sessionId)) {
setTimedOut(false);
return;
}
const timeout = window.setTimeout(() => setTimedOut(true), 5000);
return () => window.clearTimeout(timeout);
}, [config.mode, config.sessionId, currentSessionId, sessions]);
return timedOut;
};
export function ElectronMiniChatApp({ apis }: ElectronMiniChatAppProps) {
const config = React.useMemo(() => readMiniChatConfig(), []);
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
React.useEffect(() => {
opencodeClient.setDirectory(currentDirectory || config.directory || undefined);
}, [config.directory, currentDirectory]);
React.useEffect(() => {
registerRuntimeAPIs(apis);
return () => registerRuntimeAPIs(null);
}, [apis]);
useAppFontEffects();
useMiniChatKeyboardShortcuts();
usePushVisibilityBeacon({ enabled: true });
useWindowTitle();
return (
<ErrorBoundary>
<SyncProvider sdk={opencodeClient.getSdkClient()} directory={currentDirectory || config.directory || ''}>
<RuntimeAPIProvider apis={apis}>
<TooltipProvider delayDuration={300} skipDelayDuration={150}>
<div className="h-full text-foreground bg-background">
<ElectronMiniChatContent config={config} />
<Toaster />
</div>
</TooltipProvider>
</RuntimeAPIProvider>
</SyncProvider>
</ErrorBoundary>
);
}
const ElectronMiniChatContent: React.FC<{ config: MiniChatConfig }> = ({ config }) => {
const sessionUnavailable = useSessionUnavailable(config);
return (
<>
<MiniChatBootstrap config={config} />
<MiniChatPresencePublisher />
<SyncRuntimeEffects embeddedBackgroundWorkEnabled={true} />
<MiniChatLayout mode={config.mode} autoOpenDraft={config.mode === 'draft'} unavailable={sessionUnavailable} />
</>
);
};
@@ -0,0 +1,58 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import '@/styles/fonts';
import '@/index.css';
import '@/lib/debug';
import { SessionAuthGate } from '@/components/auth/SessionAuthGate';
import { ThemeProvider } from '@/components/providers/ThemeProvider';
import { ThemeSystemProvider } from '@/contexts/ThemeSystemContext';
import type { RuntimeAPIs } from '@/lib/api/types';
import { startAppearanceAutoSave } from '@/lib/appearanceAutoSave';
import { applyPersistedDirectoryPreferences } from '@/lib/directoryPersistence';
import { initializeLocale, I18nProvider } from '@/lib/i18n';
import { initializeAppearancePreferences, syncDesktopSettings } from '@/lib/persistence';
import { startModelPrefsAutoSave } from '@/lib/modelPrefsAutoSave';
import { startTypographyWatcher } from '@/lib/typographyWatcher';
import { ElectronMiniChatApp } from './ElectronMiniChatApp';
const initializeSharedPreferences = () => {
initializeLocale();
void initializeAppearancePreferences().then(() => {
void Promise.all([
syncDesktopSettings(),
applyPersistedDirectoryPreferences(),
]).catch((err) => {
console.error('[mini-chat-main] settings init failed:', err);
});
startAppearanceAutoSave();
startModelPrefsAutoSave();
startTypographyWatcher();
}).catch((err) => {
console.error('[mini-chat-main] appearance init failed:', err);
});
};
export function renderElectronMiniChatApp(apis: RuntimeAPIs) {
initializeSharedPreferences();
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error('Root element not found');
}
createRoot(rootElement).render(
<StrictMode>
<I18nProvider>
<ThemeSystemProvider>
<ThemeProvider>
<SessionAuthGate>
<ElectronMiniChatApp apis={apis} />
</SessionAuthGate>
</ThemeProvider>
</ThemeSystemProvider>
</I18nProvider>
</StrictMode>,
);
}