From e1ff21bc0a0fcc4203839abe1c8309308eba4027 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 8 May 2026 12:22:59 +0300 Subject: [PATCH] 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. --- packages/electron/main.mjs | 181 ++++++++++++ packages/ui/src/App.tsx | 32 ++- packages/ui/src/apps/AppEffects.tsx | 53 +++- packages/ui/src/apps/ElectronMiniChatApp.tsx | 270 ++++++++++++++++++ .../ui/src/apps/renderElectronMiniChatApp.tsx | 58 ++++ .../ui/src/components/chat/ChatContainer.tsx | 10 +- packages/ui/src/components/chat/ChatInput.tsx | 9 +- .../components/chat/ChatSurfaceContext.tsx | 6 + .../chat/chatSurfaceContextValue.ts | 5 + .../components/chat/message/MessageBody.tsx | 30 +- .../src/components/chat/useChatSurfaceMode.ts | 4 + packages/ui/src/components/layout/Header.tsx | 51 +++- .../components/mini-chat/MiniChatLayout.tsx | 232 +++++++++++++++ .../session/sidebar/SessionNodeItem.tsx | 25 +- .../ui/src/components/ui/CommandPalette.tsx | 24 +- packages/ui/src/hooks/useKeyboardShortcuts.ts | 20 +- .../src/hooks/useMiniChatKeyboardShortcuts.ts | 94 ++++++ packages/ui/src/lib/desktop.ts | 15 + .../ui/src/lib/i18n/messages/en.settings.ts | 1 + packages/ui/src/lib/i18n/messages/en.ts | 21 ++ .../ui/src/lib/i18n/messages/es.settings.ts | 1 + packages/ui/src/lib/i18n/messages/es.ts | 21 ++ .../ui/src/lib/i18n/messages/ko.settings.ts | 1 + packages/ui/src/lib/i18n/messages/ko.ts | 21 ++ .../ui/src/lib/i18n/messages/pl.settings.ts | 1 + packages/ui/src/lib/i18n/messages/pl.ts | 21 ++ .../src/lib/i18n/messages/pt-BR.settings.ts | 1 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 21 ++ .../ui/src/lib/i18n/messages/uk.settings.ts | 1 + packages/ui/src/lib/i18n/messages/uk.ts | 21 ++ .../src/lib/i18n/messages/zh-CN.settings.ts | 1 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 21 ++ packages/ui/src/lib/shortcuts.ts | 6 + packages/ui/src/sync/sync-context.tsx | 29 +- packages/web/mini-chat.html | 12 + packages/web/src/mini-chat-main.tsx | 17 ++ packages/web/vite.config.ts | 4 + 37 files changed, 1312 insertions(+), 29 deletions(-) create mode 100644 packages/ui/src/apps/ElectronMiniChatApp.tsx create mode 100644 packages/ui/src/apps/renderElectronMiniChatApp.tsx create mode 100644 packages/ui/src/components/chat/ChatSurfaceContext.tsx create mode 100644 packages/ui/src/components/chat/chatSurfaceContextValue.ts create mode 100644 packages/ui/src/components/chat/useChatSurfaceMode.ts create mode 100644 packages/ui/src/components/mini-chat/MiniChatLayout.tsx create mode 100644 packages/ui/src/hooks/useMiniChatKeyboardShortcuts.ts create mode 100644 packages/web/mini-chat.html create mode 100644 packages/web/src/mini-chat-main.tsx diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index 4f6d5166..3a09a0a9 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -104,6 +104,10 @@ const MIN_WINDOW_WIDTH = 800; const MIN_WINDOW_HEIGHT = 520; const MIN_RESTORE_WINDOW_WIDTH = 900; const MIN_RESTORE_WINDOW_HEIGHT = 560; +const MINI_CHAT_WINDOW_WIDTH = 520; +const MINI_CHAT_WINDOW_HEIGHT = 760; +const MINI_CHAT_MIN_WINDOW_WIDTH = 360; +const MINI_CHAT_MIN_WINDOW_HEIGHT = 480; const MAX_CAPTURE_PAGE_RECT_AREA = 4_000_000; const LOCAL_HOST_ID = 'local'; const ENV_OVERRIDE_HOST_ID = '__env'; @@ -133,6 +137,7 @@ const state = { windowCounter: 1, focusedWindowIds: new Set(), windowGeometryRevisions: new Map(), + miniChatWindowsBySession: new Map(), sshStatuses: new Map(), sshLogs: new Map(), }; @@ -1362,6 +1367,137 @@ const createAdditionalWindow = async (url) => { return browserWindow; }; +const buildMiniChatUrl = ({ mode, sessionId, directory, projectId }) => { + const base = state.localOrigin || state.sidecarUrl; + if (!base) { + throw new Error('Local UI is not available'); + } + + const url = new URL('/mini-chat.html', base); + url.searchParams.set('mode', mode === 'session' ? 'session' : 'draft'); + if (sessionId) url.searchParams.set('sessionId', sessionId); + if (directory) url.searchParams.set('directory', directory); + if (projectId) url.searchParams.set('projectId', projectId); + return url.toString(); +}; + +const createMiniChatWindow = async ({ mode, sessionId = '', directory = '', projectId = '' } = {}) => { + if (mode === 'session' && sessionId) { + const existing = state.miniChatWindowsBySession.get(sessionId); + if (existing && !existing.isDestroyed()) { + if (existing.isMinimized()) existing.restore(); + existing.show(); + existing.focus(); + return existing; + } + state.miniChatWindowsBySession.delete(sessionId); + } + + const desktopLocalOrigin = state.localOrigin || ''; + const desktopHome = os.homedir() || ''; + const desktopMacosMajor = String(macosMajorVersion()); + const browserWindow = new BrowserWindow({ + title: 'OpenChamber Mini Chat', + width: MINI_CHAT_WINDOW_WIDTH, + height: MINI_CHAT_WINDOW_HEIGHT, + minWidth: MINI_CHAT_MIN_WINDOW_WIDTH, + minHeight: MINI_CHAT_MIN_WINDOW_HEIGHT, + show: false, + backgroundColor: '#151313', + titleBarStyle: process.platform === 'darwin' ? 'hidden' : 'default', + trafficLightPosition: process.platform === 'darwin' ? { x: 16, y: 17 } : undefined, + webPreferences: { + additionalArguments: [ + `--openchamber-local-origin=${desktopLocalOrigin}`, + `--openchamber-home=${desktopHome}`, + `--openchamber-macos-major=${desktopMacosMajor}`, + ], + preload: isDev ? path.join(__dirname, 'preload.mjs') : path.join(app.getAppPath(), 'preload.mjs'), + backgroundThrottling: true, + contextIsolation: true, + nodeIntegration: false, + sandbox: false, + }, + }); + browserWindow.__ocLabel = nextWindowLabel(); + browserWindow.__ocMiniChat = true; + browserWindow.__ocMiniChatSessionId = mode === 'session' ? sessionId : ''; + browserWindow.__ocPinned = false; + + if (mode === 'session' && sessionId) { + state.miniChatWindowsBySession.set(sessionId, browserWindow); + } + + browserWindow.on('closed', () => { + if (browserWindow.__ocMiniChatSessionId) { + const existing = state.miniChatWindowsBySession.get(browserWindow.__ocMiniChatSessionId); + if (existing?.id === browserWindow.id) { + state.miniChatWindowsBySession.delete(browserWindow.__ocMiniChatSessionId); + } + } + }); + + if (process.platform === 'darwin') { + const refreshTrafficLights = () => { + if (browserWindow.isDestroyed()) return; + try { + browserWindow.setWindowButtonVisibility(true); + browserWindow.setTrafficLightPosition({ x: 16, y: 17 }); + } catch {} + }; + browserWindow.on('show', refreshTrafficLights); + browserWindow.on('focus', refreshTrafficLights); + } + + browserWindow.once('ready-to-show', () => { + browserWindow.show(); + browserWindow.focus(); + }); + + browserWindow.webContents.setWindowOpenHandler(({ url }) => { + void shell.openExternal(url).catch(() => {}); + return { action: 'deny' }; + }); + browserWindow.webContents.on('will-navigate', (event, url) => { + try { + const target = new URL(url); + const local = new URL(state.localOrigin || state.sidecarUrl || ''); + if (target.origin === local.origin) return; + } catch { + } + event.preventDefault(); + void shell.openExternal(url).catch(() => {}); + }); + browserWindow.webContents.on('dom-ready', () => { + if (state.initScript) { + void browserWindow.webContents.executeJavaScript(state.initScript).catch(() => {}); + } + }); + + await navigateWindow(browserWindow, buildMiniChatUrl({ mode, sessionId, directory, projectId })); + return browserWindow; +}; + +const setMiniChatPinned = (browserWindow, pinned) => { + if (!browserWindow || browserWindow.isDestroyed()) { + throw new Error('Window is not available'); + } + if (browserWindow.__ocMiniChat !== true) { + throw new Error('Pinning is only available for Mini Chat windows'); + } + const nextPinned = pinned === true; + browserWindow.__ocPinned = nextPinned; + if (nextPinned) { + browserWindow.setAlwaysOnTop(true, 'floating'); + } else { + browserWindow.setAlwaysOnTop(false); + if (process.platform === 'darwin') { + browserWindow.setVisibleOnAllWorkspaces(false); + } + } + return { pinned: nextPinned }; +}; + const resolveInitialUrl = async () => { const localUrl = isDev && await waitForHealth('http://127.0.0.1:3901', 5_000, 100) ? 'http://127.0.0.1:3901' @@ -2150,6 +2286,51 @@ const handleInvoke = async (browserWindow, command, args = {}) => { return null; } + case 'desktop_open_session_mini_chat_window': { + const sessionId = typeof args.sessionId === 'string' ? args.sessionId.trim() : ''; + if (!sessionId) throw new Error('Session id is required'); + const directory = typeof args.directory === 'string' ? args.directory.trim() : ''; + await createMiniChatWindow({ mode: 'session', sessionId, directory }); + return null; + } + + case 'desktop_open_draft_mini_chat_window': { + const directory = typeof args.directory === 'string' ? args.directory.trim() : ''; + const projectId = typeof args.projectId === 'string' ? args.projectId.trim() : ''; + await createMiniChatWindow({ mode: 'draft', directory, projectId }); + return null; + } + + case 'desktop_set_window_pinned': + return setMiniChatPinned(browserWindow, args.pinned === true); + + case 'desktop_get_window_pinned': + return { pinned: Boolean(browserWindow?.__ocPinned) }; + + case 'desktop_focus_main_window': + if (state.mainWindow && !state.mainWindow.isDestroyed()) { + if (state.mainWindow.isMinimized()) state.mainWindow.restore(); + state.mainWindow.show(); + state.mainWindow.focus(); + const sessionId = typeof args.sessionId === 'string' ? args.sessionId.trim() : ''; + const directory = typeof args.directory === 'string' ? args.directory.trim() : ''; + const mode = typeof args.mode === 'string' ? args.mode.trim() : ''; + if (sessionId) { + emitToWindow(state.mainWindow, 'openchamber:open-session', { sessionId, directory }); + } else if (mode === 'draft') { + const projectId = typeof args.projectId === 'string' ? args.projectId.trim() : ''; + emitToWindow(state.mainWindow, 'openchamber:open-draft-session', { directory, projectId }); + } + return { focused: true }; + } + return { focused: false }; + + case 'desktop_close_current_window': + if (browserWindow && !browserWindow.isDestroyed()) { + browserWindow.close(); + } + return null; + case 'desktop_ssh_instances_get': return sshManager.readInstances(); diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 351fa229..7cc2d419 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -516,16 +516,44 @@ function App({ apis }: AppProps) { if (typeof window === 'undefined') return; const handler = (event: Event) => { - const detail = (event as CustomEvent<{ sessionId?: string }>).detail; + const detail = (event as CustomEvent<{ sessionId?: string; directory?: string }>).detail; const sessionId = typeof detail?.sessionId === 'string' ? detail.sessionId.trim() : ''; if (!sessionId) return; - void useSessionUIStore.getState().setCurrentSession(sessionId); + const directory = typeof detail?.directory === 'string' && detail.directory.trim().length > 0 + ? detail.directory.trim() + : null; + useUIStore.getState().setActiveMainTab('chat'); + void useSessionUIStore.getState().setCurrentSession(sessionId, directory); }; window.addEventListener('openchamber:open-session', handler as EventListener); return () => window.removeEventListener('openchamber:open-session', handler as EventListener); }, []); + React.useEffect(() => { + if (typeof window === 'undefined') return; + + const handler = (event: Event) => { + const detail = (event as CustomEvent<{ directory?: string; projectId?: string }>).detail; + const directory = typeof detail?.directory === 'string' && detail.directory.trim().length > 0 + ? detail.directory.trim() + : null; + const projectId = typeof detail?.projectId === 'string' && detail.projectId.trim().length > 0 + ? detail.projectId.trim() + : null; + useUIStore.getState().setActiveMainTab('chat'); + useUIStore.getState().setSessionSwitcherOpen(false); + useSessionUIStore.getState().openNewSessionDraft({ + selectedProjectId: projectId, + directoryOverride: directory, + preserveDirectoryOverride: Boolean(directory), + }); + }; + + window.addEventListener('openchamber:open-draft-session', handler as EventListener); + return () => window.removeEventListener('openchamber:open-draft-session', handler as EventListener); + }, []); + React.useEffect(() => { if (typeof window === 'undefined') return; diff --git a/packages/ui/src/apps/AppEffects.tsx b/packages/ui/src/apps/AppEffects.tsx index e0db7d61..10775a9d 100644 --- a/packages/ui/src/apps/AppEffects.tsx +++ b/packages/ui/src/apps/AppEffects.tsx @@ -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 ; +} + export function SyncAppEffects({ embeddedBackgroundWorkEnabled }: { embeddedBackgroundWorkEnabled: boolean; }) { usePwaManifestSync(); useWindowControlsOverlayLayout(); - useSessionAutoCleanup(embeddedBackgroundWorkEnabled); - useQueuedMessageAutoSend(embeddedBackgroundWorkEnabled); useKeyboardShortcuts(); - return ; + return ( + <> + + + + ); } diff --git a/packages/ui/src/apps/ElectronMiniChatApp.tsx b/packages/ui/src/apps/ElectronMiniChatApp.tsx new file mode 100644 index 00000000..e951946c --- /dev/null +++ b/packages/ui/src/apps/ElectronMiniChatApp.tsx @@ -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(); + 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 ( + + + + +
+ + +
+
+
+
+
+ ); +} + +const ElectronMiniChatContent: React.FC<{ config: MiniChatConfig }> = ({ config }) => { + const sessionUnavailable = useSessionUnavailable(config); + + return ( + <> + + + + + + ); +}; diff --git a/packages/ui/src/apps/renderElectronMiniChatApp.tsx b/packages/ui/src/apps/renderElectronMiniChatApp.tsx new file mode 100644 index 00000000..61fba4e8 --- /dev/null +++ b/packages/ui/src/apps/renderElectronMiniChatApp.tsx @@ -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( + + + + + + + + + + + , + ); +} diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index 3c23483d..22320a7d 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -317,7 +317,11 @@ const HYDRATING_SKELETON_ITEMS: Array<{ }, ]; -export const ChatContainer: React.FC = () => { +type ChatContainerProps = { + autoOpenDraft?: boolean; +}; + +export const ChatContainer: React.FC = ({ autoOpenDraft = true }) => { const { t } = useI18n(); // Session UI state const currentSessionId = useSessionUIStore((s) => s.currentSessionId); @@ -529,10 +533,10 @@ export const ChatContainer: React.FC = () => { ) : null; React.useEffect(() => { - if (!currentSessionId && !draftOpen) { + if (autoOpenDraft && !currentSessionId && !draftOpen) { openNewSessionDraft(); } - }, [currentSessionId, draftOpen, openNewSessionDraft]); + }, [autoOpenDraft, currentSessionId, draftOpen, openNewSessionDraft]); const sessionBlockingCards = React.useMemo(() => { return [...sessionPermissions, ...sessionQuestions]; diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 6c9becb7..26c4f7c2 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -39,6 +39,7 @@ import { ModelControls } from './ModelControls'; import { parseAgentMentions } from '@/lib/messages/agentMentions'; import { StatusRow } from './StatusRow'; import { PendingChangesBar } from './PendingChangesBar'; +import { useChatSurfaceMode } from './useChatSurfaceMode'; import { MobileAgentButton } from './MobileAgentButton'; import { MobileModelButton } from './MobileModelButton'; import { MobileSessionStatusBar } from './MobileSessionStatusBar'; @@ -3142,12 +3143,18 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo return draftBranchItems.find((item) => item.value === selectedValue)?.label ?? formatDirectoryName(selectedValue); }, [draftBranchItems, selectedDraftDirectory]); + const chatSurfaceMode = useChatSurfaceMode(); + const isMiniChatSurface = chatSurfaceMode === 'mini-chat'; + const hasPendingChanges = React.useMemo(() => { + if (isMiniChatSurface) { + return false; + } if (isGitRepo !== true || !currentGitStatus || currentGitStatus.isClean) { return false; } return extractGitChangedFiles(currentGitStatus.files, currentGitStatus.diffStats, currentDirectory).length > 0; - }, [currentDirectory, currentGitStatus, isGitRepo]); + }, [currentDirectory, currentGitStatus, isGitRepo, isMiniChatSurface]); const selectedDraftBranchIsKnown = React.useMemo(() => { if (!selectedDraftDirectory) { diff --git a/packages/ui/src/components/chat/ChatSurfaceContext.tsx b/packages/ui/src/components/chat/ChatSurfaceContext.tsx new file mode 100644 index 00000000..cbb30f5a --- /dev/null +++ b/packages/ui/src/components/chat/ChatSurfaceContext.tsx @@ -0,0 +1,6 @@ +import React from 'react'; +import { ChatSurfaceContext, type ChatSurfaceMode } from './chatSurfaceContextValue'; + +export const ChatSurfaceProvider: React.FC<{ mode: ChatSurfaceMode; children: React.ReactNode }> = ({ mode, children }) => { + return {children}; +}; diff --git a/packages/ui/src/components/chat/chatSurfaceContextValue.ts b/packages/ui/src/components/chat/chatSurfaceContextValue.ts new file mode 100644 index 00000000..30065ad0 --- /dev/null +++ b/packages/ui/src/components/chat/chatSurfaceContextValue.ts @@ -0,0 +1,5 @@ +import React from 'react'; + +export type ChatSurfaceMode = 'default' | 'mini-chat'; + +export const ChatSurfaceContext = React.createContext('default'); diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index c2aec1bb..b7ac3e62 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -30,6 +30,7 @@ import { useConfigStore } from '@/stores/useConfigStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { TextSelectionMenu } from './TextSelectionMenu'; import { copyTextToClipboard } from '@/lib/clipboard'; +import { useChatSurfaceMode } from '@/components/chat/useChatSurfaceMode'; import { isVSCodeRuntime } from '@/lib/desktop'; import { toPng } from 'html-to-image'; import { toast } from '@/components/ui'; @@ -344,6 +345,7 @@ const UserMessageBody = React.memo(({ messageId, parts, isMobile, alwaysShowActi stickyUserHeaderEnabled?: boolean; }) => { const { t } = useI18n(); + const chatSurfaceMode = useChatSurfaceMode(); const [copyHintVisible, setCopyHintVisible] = React.useState(false); const copyHintTimeoutRef = React.useRef(null); @@ -417,7 +419,8 @@ const UserMessageBody = React.memo(({ messageId, parts, isMobile, alwaysShowActi [hasCopyableText, isTouchContext, onCopyMessage, revealCopyHint] ); - const actionsBlock = ((canCopyMessage && hasCopyableText) || onRevert || onFork) && showUserActions ? ( + const effectiveOnFork = chatSurfaceMode === 'mini-chat' ? undefined : onFork; + const actionsBlock = ((canCopyMessage && hasCopyableText) || onRevert || effectiveOnFork) && showUserActions ? (
{t('chat.messageBody.actions.revert')} )} - {onFork && ( + {effectiveOnFork && (
diff --git a/packages/ui/src/components/chat/useChatSurfaceMode.ts b/packages/ui/src/components/chat/useChatSurfaceMode.ts new file mode 100644 index 00000000..3615e051 --- /dev/null +++ b/packages/ui/src/components/chat/useChatSurfaceMode.ts @@ -0,0 +1,4 @@ +import React from 'react'; +import { ChatSurfaceContext, type ChatSurfaceMode } from './chatSurfaceContextValue'; + +export const useChatSurfaceMode = (): ChatSurfaceMode => React.useContext(ChatSurfaceContext); diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index c15badc2..7506c80b 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -16,7 +16,7 @@ import { } from '@/components/ui/dropdown-menu'; import { SortableTabsStrip, type SortableTabsStripItem } from '@/components/ui/sortable-tabs-strip'; -import { RiArrowLeftSLine, RiChat4Line, RiChatNewLine, RiCheckLine, RiCloseLine, RiCommandLine, RiFileTextLine, RiFolder6Line, RiGitBranchLine, RiGithubFill, RiLayoutLeftLine, RiLayoutRightLine, RiPlayListAddLine, RiRefreshLine, RiServerLine, RiStackLine, RiTerminalBoxLine, RiTimerLine, RiAlertLine, type RemixiconComponentType } from '@remixicon/react'; +import { RiArrowLeftSLine, RiChat4Line, RiChatNewLine, RiCheckLine, RiCloseLine, RiCommandLine, RiFileTextLine, RiFolder6Line, RiGitBranchLine, RiGithubFill, RiLayoutLeftLine, RiLayoutRightLine, RiPictureInPicture2Line, RiPlayListAddLine, RiRefreshLine, RiServerLine, RiStackLine, RiTerminalBoxLine, RiTimerLine, RiAlertLine, RiWindowLine, type RemixiconComponentType } from '@remixicon/react'; import { DiffIcon } from '@/components/icons/DiffIcon'; import { useUIStore, type MainTab } from '@/stores/useUIStore'; import { useConfigStore } from '@/stores/useConfigStore'; @@ -65,7 +65,7 @@ import { OpenInAppButton } from '@/components/desktop/OpenInAppButton'; import { forceKillTerminal } from '@/lib/terminalApi'; import { useTerminalStore } from '@/stores/useTerminalStore'; import { ProjectActionsButton } from '@/components/layout/ProjectActionsButton'; -import { isDesktopShell, isVSCodeRuntime, startDesktopWindowDrag } from '@/lib/desktop'; +import { canUseElectronDesktopIPC, invokeDesktop, isDesktopShell, isVSCodeRuntime, startDesktopWindowDrag } from '@/lib/desktop'; import { desktopHostsGet, locationMatchesHost, redactSensitiveUrl } from '@/lib/desktopHosts'; import { resolveSessionDiffStats } from '@/components/session/sidebar/utils'; import { useI18n } from '@/lib/i18n'; @@ -722,6 +722,7 @@ export const Header: React.FC = ({ } return isDesktopShell(); }); + const hasElectronDesktopIPC = React.useMemo(() => canUseElectronDesktopIPC(), []); const isTabletStandalonePwa = useTabletStandalonePwaRuntime(); const [isDesktopWindowFullscreen, setIsDesktopWindowFullscreen] = React.useState(false); @@ -1279,6 +1280,27 @@ export const Header: React.FC = ({ openNewSessionDraft(); }, [openNewSessionDraft, setActiveMainTab, setSessionSwitcherOpen]); + const handleOpenDraftMiniChat = React.useCallback(() => { + void invokeDesktop('desktop_open_draft_mini_chat_window', { + directory: normalize(openDirectory || activeProject?.path || ''), + projectId: activeProject?.id ?? null, + }).catch((error) => { + console.warn('[header] failed to open draft mini chat window', error); + }); + }, [activeProject?.id, activeProject?.path, openDirectory]); + + const handleOpenCurrentSessionMiniChat = React.useCallback(() => { + if (!currentSessionId) { + return; + } + void invokeDesktop('desktop_open_session_mini_chat_window', { + sessionId: currentSessionId, + directory: normalize(openDirectory || activeProject?.path || ''), + }).catch((error) => { + console.warn('[header] failed to open session mini chat window', error); + }); + }, [activeProject?.path, currentSessionId, openDirectory]); + const handleOpenContextPanel = React.useCallback(() => { const directory = normalize(openDirectory || ''); if (!directory) { @@ -1841,6 +1863,23 @@ export const Header: React.FC = ({ ) : null} + {hasElectronDesktopIPC && !isLeftSidebarOpen ? ( + + + + + +

{t('header.actions.newMiniChat')}

+
+
+ ) : null} {projectActionsContext && ( = ({
+ {showDesktopHeaderContextUsage && stableDesktopContextUsage ? ( { + const path = typeof value === 'string' ? value.trim() : ''; + if (!path) return ''; + const home = typeof window !== 'undefined' ? window.__OPENCHAMBER_HOME__ : ''; + if (home && path === home) return '~'; + if (home && path.startsWith(`${home}/`)) return `~/${path.slice(home.length + 1)}`; + const segments = path.split('/').filter(Boolean); + if (segments.length <= 3) return path; + return `.../${segments.slice(-3).join('/')}`; +}; + +const normalizePath = (value: string | null | undefined): string => { + const raw = typeof value === 'string' ? value.trim() : ''; + if (!raw) return ''; + const normalized = raw.replace(/\\/g, '/'); + return normalized === '/' ? '/' : normalized.replace(/\/+$/, ''); +}; + +const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => { + const { t } = useI18n(); + const currentSessionId = useSessionUIStore((state) => state.currentSessionId); + const draftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open)); + const draftProjectId = useSessionUIStore((state) => state.newSessionDraft?.selectedProjectId ?? null); + const currentDirectory = useDirectoryStore((state) => state.currentDirectory); + const projects = useProjectsStore((state) => state.projects); + const activeProject = useProjectsStore((state) => state.getActiveProject()); + const sessions = useSessions(); + const runtimeApis = useRuntimeAPIs(); + const ensureGitStatus = useGitStore((state) => state.ensureStatus); + const worktreePath = useSessionUIStore((state) => currentSessionId ? state.worktreeMetadata.get(currentSessionId)?.path ?? '' : ''); + const worktreeMetadataBranch = useSessionUIStore((state) => currentSessionId ? state.worktreeMetadata.get(currentSessionId)?.branch?.trim() ?? null : null); + const worktreeAttachment = useSessionWorktreeStore((state) => currentSessionId ? state.getAttachment(currentSessionId) : undefined); + const draftDirectory = useSessionUIStore((state) => { + if (!state.newSessionDraft?.open) return ''; + return normalizePath(state.newSessionDraft.bootstrapPendingDirectory ?? state.newSessionDraft.directoryOverride ?? ''); + }); + const [pinned, setPinned] = React.useState(false); + const macosMajor = typeof window !== 'undefined' ? window.__OPENCHAMBER_MACOS_MAJOR__ ?? 0 : 0; + const hasMacTrafficLights = Number.isFinite(macosMajor) && macosMajor > 0; + const macosHeaderSizeClass = hasMacTrafficLights + ? macosMajor >= 26 + ? 'h-12' + : macosMajor <= 15 + ? 'h-14' + : '' + : ''; + + const session = React.useMemo( + () => currentSessionId ? sessions.find((entry) => entry.id === currentSessionId) ?? null : null, + [currentSessionId, sessions], + ); + const sessionWorktreeMetadata = (session as { worktreeMetadata?: { path?: string | null; branch?: string | null; projectDirectory?: string | null } } | null)?.worktreeMetadata ?? null; + + React.useEffect(() => { + if (!isElectronShell()) return; + void invokeDesktop<{ pinned?: boolean }>('desktop_get_window_pinned').then((result) => { + if (typeof result?.pinned === 'boolean') setPinned(result.pinned); + }); + }, []); + + const title = session?.title?.trim() + || (draftOpen || mode === 'draft' ? t('miniChat.header.newSession') : t('miniChat.header.session')); + const sessionDirectory = normalizePath((session as { directory?: string | null } | null)?.directory ?? null); + const worktreeDirectory = normalizePath(worktreePath || sessionWorktreeMetadata?.path || worktreeAttachment?.cwd || worktreeAttachment?.worktreeRoot || ''); + const currentDirectoryNormalized = normalizePath(currentDirectory); + const openDirectory = worktreeDirectory || sessionDirectory || draftDirectory || currentDirectoryNormalized; + const directoryLabel = compactPath(openDirectory); + const catalogWorktreeBranch = useSessionUIStore((state) => { + const candidateDirectory = normalizePath(worktreeDirectory || sessionDirectory || ''); + if (!candidateDirectory) return null; + for (const worktrees of state.availableWorktreesByProject.values()) { + const match = worktrees.find((worktree) => normalizePath(worktree.path) === candidateDirectory); + const branch = match?.branch?.trim(); + if (branch) return branch; + } + return null; + }); + React.useEffect(() => { + if (!openDirectory) return; + void ensureGitStatus(openDirectory, runtimeApis.git).catch(() => {}); + }, [ensureGitStatus, openDirectory, runtimeApis.git]); + + const pathMatchedProject = React.useMemo(() => { + const projectDirectory = normalizePath(sessionWorktreeMetadata?.projectDirectory ?? worktreeAttachment?.worktreeRoot ?? null); + const candidateDirectory = projectDirectory || openDirectory; + if (!candidateDirectory) return null; + return projects + .map((entry) => ({ ...entry, normalizedPath: normalizePath(entry.path) })) + .filter((entry) => entry.normalizedPath && (entry.normalizedPath === candidateDirectory || candidateDirectory.startsWith(`${entry.normalizedPath}/`))) + .sort((left, right) => right.path.length - left.path.length)[0] ?? null; + }, [openDirectory, projects, sessionWorktreeMetadata?.projectDirectory, worktreeAttachment?.worktreeRoot]); + const projectLabel = React.useMemo(() => { + const project = pathMatchedProject ?? activeProject; + if (!project) return directoryLabel || 'OpenChamber'; + const label = project.label?.trim(); + if (label) return label; + const segments = project.path.split(/[\\/]/).filter(Boolean); + return segments.at(-1) ?? project.path; + }, [activeProject, directoryLabel, pathMatchedProject]); + const gitBranchForDirectory = useGitBranchLabel(openDirectory || null); + const branchLabel = gitBranchForDirectory || worktreeMetadataBranch || sessionWorktreeMetadata?.branch?.trim() || worktreeAttachment?.branch?.trim() || catalogWorktreeBranch; + const diffStats = React.useMemo(() => { + return resolveSessionDiffStats(session?.summary as Parameters[0]); + }, [session?.summary]); + const changes = diffStats ?? { additions: 0, deletions: 0 }; + const hasChanges = changes.additions > 0 || changes.deletions > 0; + const dragRegionStyle = { WebkitAppRegion: 'drag' } as React.CSSProperties; + const noDragRegionStyle = { WebkitAppRegion: 'no-drag' } as React.CSSProperties; + + const handleTogglePinned = React.useCallback(() => { + const nextPinned = !pinned; + setPinned(nextPinned); + void invokeDesktop('desktop_set_window_pinned', { pinned: nextPinned }).catch(() => { + setPinned(!nextPinned); + }); + }, [pinned]); + + const handleOpenMainApp = React.useCallback(() => { + const payload = currentSessionId + ? { sessionId: currentSessionId, directory: (session as { directory?: string | null } | null)?.directory ?? currentDirectory ?? '' } + : { mode: 'draft', directory: openDirectory || currentDirectory || '', projectId: draftProjectId }; + void invokeDesktop<{ focused?: boolean }>('desktop_focus_main_window', payload) + .then((result) => { + if (result?.focused === true) { + return invokeDesktop('desktop_close_current_window'); + } + return null; + }); + }, [currentDirectory, currentSessionId, draftProjectId, openDirectory, session]); + + return ( +
+
+
{title}
+
+ {projectLabel} + {branchLabel ? ( + + + {branchLabel} + + ) : null} + {hasChanges ? ( + + +{changes.additions} + / + -{changes.deletions} + + ) : null} +
+
+ + +
+ ); +}; + +export const MiniChatLayout: React.FC = ({ mode, autoOpenDraft = false, unavailable = false }) => { + const { t } = useI18n(); + + return ( +
+ +
+ {unavailable ? ( +
+
+
{t('miniChat.unavailable.title')}
+
{t('miniChat.unavailable.description')}
+
+
+ ) : ( + + + + )} +
+
+ ); +}; diff --git a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx index bca7c47f..305d5ad8 100644 --- a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx +++ b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx @@ -32,9 +32,10 @@ import { RiShieldLine, RiUnpinLine, RiGitBranchLine, + RiWindowLine, } from '@remixicon/react'; import { cn } from '@/lib/utils'; -import { isVSCodeRuntime } from '@/lib/desktop'; +import { canUseElectronDesktopIPC, invokeDesktop, isVSCodeRuntime } from '@/lib/desktop'; import { toast } from '@/components/ui'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; @@ -267,6 +268,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { const displayMode = useSessionDisplayStore((state) => state.displayMode); const isMinimalMode = displayMode === 'minimal'; const isVSCode = React.useMemo(() => isVSCodeRuntime(), []); + const isElectron = React.useMemo(() => canUseElectronDesktopIPC(), []); const revealOnHoverClass = isVSCode ? 'group-hover:opacity-100 group-hover:pointer-events-auto' : 'group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto'; @@ -429,6 +431,16 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { await doExportSession(false); }, [doExportSession, node.children.length]); + const handleOpenMiniChatWindow = React.useCallback(() => { + if (!sessionDirectory) return; + void invokeDesktop('desktop_open_session_mini_chat_window', { + sessionId: session.id, + directory: sessionDirectory, + }).catch((error) => { + console.warn('[session-sidebar] failed to open mini chat window', error); + }); + }, [session.id, sessionDirectory]); + if (editingId === session.id) { return (
) : null} + {isElectron ? ( + + + {t('sessions.sidebar.session.menu.openMiniChatWindow')} + + ) : null} + handleDeleteSession(session, { archivedBucket })}> diff --git a/packages/ui/src/components/ui/CommandPalette.tsx b/packages/ui/src/components/ui/CommandPalette.tsx index 072a1916..47beb26d 100644 --- a/packages/ui/src/components/ui/CommandPalette.tsx +++ b/packages/ui/src/components/ui/CommandPalette.tsx @@ -36,19 +36,21 @@ import { RiLayoutLeftLine, RiLayoutRightLine, RiPieChartLine, + RiWindowLine, RiSettings3Line, RiTerminalBoxLine, } from '@remixicon/react'; import type { Session } from '@opencode-ai/sdk/v2'; import { createWorktreeSession } from '@/lib/worktreeSessionCreator'; import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts'; -import { isDesktopShell, isVSCodeRuntime, isWebRuntime } from '@/lib/desktop'; +import { canUseElectronDesktopIPC, invokeDesktop, isDesktopShell, isVSCodeRuntime, isWebRuntime } from '@/lib/desktop'; import { SETTINGS_PAGE_METADATA, type SettingsRuntimeContext } from '@/lib/settings/metadata'; import { getSettingsNavIcon } from '@/components/views/SettingsView'; import { scoreByFuzzyQuery } from '@/lib/search/fuzzySearch'; import { truncatePathMiddle } from '@/lib/utils'; import { useI18n } from '@/lib/i18n'; import { sessionEvents } from '@/lib/sessionEvents'; +import { useProjectsStore } from '@/stores/useProjectsStore'; type CommandEntry = { id: string; @@ -94,6 +96,7 @@ export const CommandPalette: React.FC = () => { const activeSessions = useGlobalSessionsStore((s) => s.activeSessions); const currentDirectory = useDirectoryStore((s) => s.currentDirectory); + const activeProject = useProjectsStore((s) => s.getActiveProject()); const effectiveDirectory = useEffectiveDirectory(); const searchFiles = useFileSearchStore((s) => s.searchFiles); const { files: filesApi, git: gitApi } = useRuntimeAPIs(); @@ -232,6 +235,23 @@ export const CommandPalette: React.FC = () => { onSelect: run(() => setSettingsDialogOpen(true)), }, ]; + if (canUseElectronDesktopIPC()) { + list.splice(1, 0, { + id: 'new-mini-chat', + title: t('commandPalette.item.newMiniChat'), + icon: , + shortcutId: 'new_mini_chat', + searchText: t('commandPalette.item.newMiniChat'), + onSelect: run(() => { + void invokeDesktop('desktop_open_draft_mini_chat_window', { + directory: normalizePath(currentDirectory || activeProject?.path || ''), + projectId: activeProject?.id ?? null, + }).catch((error) => { + console.warn('[command-palette] failed to open draft mini chat window', error); + }); + }), + }); + } return list; }, [ t, @@ -246,6 +266,8 @@ export const CommandPalette: React.FC = () => { currentDirectory, openContextOverview, setSettingsDialogOpen, + activeProject?.id, + activeProject?.path, ]); // --------------------------------------------------------------------------- diff --git a/packages/ui/src/hooks/useKeyboardShortcuts.ts b/packages/ui/src/hooks/useKeyboardShortcuts.ts index e1ee756d..29277a5b 100644 --- a/packages/ui/src/hooks/useKeyboardShortcuts.ts +++ b/packages/ui/src/hooks/useKeyboardShortcuts.ts @@ -7,9 +7,11 @@ import { useThemeSystem } from '@/contexts/useThemeSystem'; import { useAssistantStatus } from '@/hooks/useAssistantStatus'; import { createWorktreeSession } from '@/lib/worktreeSessionCreator'; import { useConfigStore } from '@/stores/useConfigStore'; -import { isVSCodeRuntime } from '@/lib/desktop'; +import { canUseElectronDesktopIPC, invokeDesktop, isVSCodeRuntime } from '@/lib/desktop'; import { showOpenCodeStatus } from '@/lib/openCodeStatus'; import { eventMatchesShortcut, getEffectiveShortcutCombo } from '@/lib/shortcuts'; +import { useDirectoryStore } from '@/stores/useDirectoryStore'; +import { useProjectsStore } from '@/stores/useProjectsStore'; export const useKeyboardShortcuts = () => { const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft); @@ -33,6 +35,8 @@ export const useKeyboardShortcuts = () => { const setTimelineDialogOpen = useUIStore((s) => s.setTimelineDialogOpen); const toggleExpandedInput = useUIStore((s) => s.toggleExpandedInput); const shortcutOverrides = useUIStore((s) => s.shortcutOverrides); + const currentDirectory = useDirectoryStore((s) => s.currentDirectory); + const activeProject = useProjectsStore((s) => s.getActiveProject()); const { themeMode, setThemeMode } = useThemeSystem(); const { working } = useAssistantStatus(); const abortPrimedUntilRef = React.useRef(null); @@ -121,6 +125,17 @@ export const useKeyboardShortcuts = () => { return; } + if (canUseElectronDesktopIPC() && eventMatchesShortcut(e, combo('new_mini_chat'))) { + e.preventDefault(); + void invokeDesktop('desktop_open_draft_mini_chat_window', { + directory: currentDirectory || activeProject?.path || '', + projectId: activeProject?.id ?? null, + }).catch((error) => { + console.warn('[keyboard-shortcuts] failed to open draft mini chat window', error); + }); + return; + } + const matchedNewSessionShortcut = eventMatchesShortcut(e, combo('new_chat')); const matchedWorktreeShortcut = eventMatchesShortcut(e, combo('new_chat_worktree')); @@ -487,6 +502,9 @@ export const useKeyboardShortcuts = () => { armAbortPrompt, resetAbortPriming, currentSessionId, + currentDirectory, + activeProject?.id, + activeProject?.path, shortcutOverrides, ]); diff --git a/packages/ui/src/hooks/useMiniChatKeyboardShortcuts.ts b/packages/ui/src/hooks/useMiniChatKeyboardShortcuts.ts new file mode 100644 index 00000000..fdb8a815 --- /dev/null +++ b/packages/ui/src/hooks/useMiniChatKeyboardShortcuts.ts @@ -0,0 +1,94 @@ +import React from 'react'; +import { canUseElectronDesktopIPC, invokeDesktop } from '@/lib/desktop'; +import { eventMatchesShortcut, getEffectiveShortcutCombo } from '@/lib/shortcuts'; +import { useConfigStore } from '@/stores/useConfigStore'; +import { useDirectoryStore } from '@/stores/useDirectoryStore'; +import { useProjectsStore } from '@/stores/useProjectsStore'; +import { useUIStore } from '@/stores/useUIStore'; +import { useSelectionStore } from '@/sync/selection-store'; +import { useSessionUIStore } from '@/sync/session-ui-store'; + +const focusChatInput = () => { + const textarea = document.querySelector('textarea[data-chat-input="true"]'); + textarea?.focus(); +}; + +export const useMiniChatKeyboardShortcuts = () => { + const shortcutOverrides = useUIStore((state) => state.shortcutOverrides); + const currentDirectory = useDirectoryStore((state) => state.currentDirectory); + const activeProject = useProjectsStore((state) => state.getActiveProject()); + + React.useEffect(() => { + const combo = (actionId: string) => getEffectiveShortcutCombo(actionId, shortcutOverrides); + + const handleKeyDown = (event: KeyboardEvent) => { + if (eventMatchesShortcut(event, combo('focus_input'))) { + event.preventDefault(); + focusChatInput(); + return; + } + + if (canUseElectronDesktopIPC() && eventMatchesShortcut(event, combo('new_mini_chat'))) { + event.preventDefault(); + void invokeDesktop('desktop_open_draft_mini_chat_window', { + directory: currentDirectory || activeProject?.path || '', + projectId: activeProject?.id ?? null, + })?.catch((error) => { + console.warn('[mini-chat-shortcuts] failed to open draft mini chat window', error); + }); + return; + } + + if (eventMatchesShortcut(event, combo('open_model_selector'))) { + event.preventDefault(); + const { isModelSelectorOpen, setModelSelectorOpen } = useUIStore.getState(); + setModelSelectorOpen(!isModelSelectorOpen); + return; + } + + if (eventMatchesShortcut(event, combo('cycle_thinking_variant'))) { + const configState = useConfigStore.getState(); + const variants = configState.getCurrentModelVariants(); + if (variants.length === 0) { + return; + } + + event.preventDefault(); + configState.cycleCurrentVariant(); + + const nextVariant = useConfigStore.getState().currentVariant; + const sessionId = useSessionUIStore.getState().currentSessionId; + const agentName = useConfigStore.getState().currentAgentName; + const providerId = useConfigStore.getState().currentProviderId; + const modelId = useConfigStore.getState().currentModelId; + + if (sessionId && agentName && providerId && modelId) { + useSelectionStore.getState().saveAgentModelVariantForSession(sessionId, agentName, providerId, modelId, nextVariant); + } + return; + } + + const cyclesForward = eventMatchesShortcut(event, combo('cycle_favorite_model_forward')); + const cyclesBackward = eventMatchesShortcut(event, combo('cycle_favorite_model_backward')); + if (cyclesForward || cyclesBackward) { + const { favoriteModels, addRecentModel } = useUIStore.getState(); + if (favoriteModels.length === 0) { + return; + } + + event.preventDefault(); + const { currentProviderId, currentModelId, setProvider, setModel } = useConfigStore.getState(); + const currentIndex = favoriteModels.findIndex((favorite) => favorite.providerID === currentProviderId && favorite.modelID === currentModelId); + const delta = cyclesForward ? 1 : -1; + const next = favoriteModels[(currentIndex + delta + favoriteModels.length) % favoriteModels.length]; + + setProvider(next.providerID); + setModel(next.modelID); + addRecentModel(next.providerID, next.modelID); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [activeProject?.id, activeProject?.path, currentDirectory, shortcutOverrides]); +}; diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 7a1450fd..e80571d9 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -200,6 +200,21 @@ export const isTauriShell = (): boolean => { export const isElectronShell = (): boolean => getElectronRuntime()?.runtime === 'electron'; +export const hasDesktopInvoke = (): boolean => { + if (typeof window === 'undefined') return false; + const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__; + return typeof tauri?.core?.invoke === 'function'; +}; + +export const canUseElectronDesktopIPC = (): boolean => isElectronShell() && hasDesktopInvoke(); + +export const invokeDesktop = async (command: string, args?: Record): Promise => { + if (typeof window === 'undefined') return null; + const tauri = (window as unknown as { __TAURI__?: TauriGlobal }).__TAURI__; + if (typeof tauri?.core?.invoke !== 'function') return null; + return tauri.core.invoke(command, args ?? {}) as Promise; +}; + const normalizeOrigin = (raw: string): string | null => { const trimmed = raw.trim(); if (!trimmed) return null; diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 30db1dd6..5c80ca14 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -753,6 +753,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.cycle_right_sidebar_tab.label': 'Cycle right sidebar tab', 'settings.openchamber.keyboardShortcuts.action.new_chat.label': 'New session', 'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': 'New worktree draft', + 'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': 'New Mini Chat window', 'settings.openchamber.keyboardShortcuts.action.open_help.label': 'Open keyboard shortcuts', 'settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label': 'Toggle plan context panel', 'settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label': 'Toggle services menu', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index cef075dd..c353d00c 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -1651,6 +1651,7 @@ export const dict = { 'commandPalette.empty.noResults': 'No results found.', 'commandPalette.empty.searchingFiles': 'Searching files...', 'commandPalette.item.newSession': 'New Session', + 'commandPalette.item.newMiniChat': 'New Mini Chat Window', 'commandPalette.item.newWorktreeDraft': 'New Worktree Draft', 'commandPalette.item.addProject': 'Add Project', 'commandPalette.item.showSessionSwitcher': 'Show Session Switcher', @@ -1956,6 +1957,26 @@ export const dict = { 'desktopHostSwitcher.toast.sshConnected': 'SSH instance "{host}" connected', 'desktopHostSwitcher.toast.sshFailedToConnect': 'SSH instance "{host}" failed to connect', 'desktopHostSwitcher.toast.instanceUnreachable': 'Instance "{host}" is unreachable', + 'miniChat.header.newSession': 'New session', + 'miniChat.header.session': 'Session', + 'miniChat.header.defaultAgent': 'Default agent', + 'miniChat.header.noModel': 'No model', + 'miniChat.status.busy': 'Running', + 'miniChat.status.retry': 'Retrying', + 'miniChat.status.idle': 'Idle', + 'miniChat.actions.pin': 'Pin above other windows', + 'miniChat.actions.unpin': 'Unpin window', + 'miniChat.actions.pinAria': 'Pin Mini Chat window', + 'miniChat.actions.unpinAria': 'Unpin Mini Chat window', + 'miniChat.actions.openMain': 'Open in main window', + 'miniChat.actions.openMainAria': 'Open session in main window', + 'miniChat.unavailable.title': 'Session unavailable', + 'miniChat.unavailable.description': 'This session could not be loaded. It may have been deleted, archived, or opened from a different project context.', + 'sessions.sidebar.session.menu.openMiniChatWindow': 'Open in Mini Chat Window', + 'header.actions.newMiniChat': 'New Mini Chat Window', + 'header.actions.newMiniChatAria': 'Open a new Mini Chat window', + 'header.actions.openSessionMiniChat': 'Open Session in Mini Chat', + 'header.actions.openSessionMiniChatAria': 'Open current session in Mini Chat', 'errorBoundary.title': 'Something went wrong', 'errorBoundary.description': 'The application encountered an unexpected error. This has been logged for debugging.', 'errorBoundary.state.unknownError': 'Unknown error', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 305676b0..c2fd1527 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -753,6 +753,7 @@ export const settingsDict = { "settings.openchamber.keyboardShortcuts.action.cycle_right_sidebar_tab.label": "Cambiar pestaña de la barra lateral derecha", "settings.openchamber.keyboardShortcuts.action.new_chat.label": "Nueva sesión", "settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label": "Nuevo borrador de worktree", + "settings.openchamber.keyboardShortcuts.action.new_mini_chat.label": "Nueva ventana Mini Chat", "settings.openchamber.keyboardShortcuts.action.open_help.label": "Abrir atajos de teclado", "settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label": "Alternar panel de plan de contexto", "settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label": "Mostrar u ocultar menú de servicios", diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 0c795709..4ae14555 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1617,6 +1617,7 @@ export const dict: Record = { "commandPalette.empty.noResults": "No se encontraron resultados.", "commandPalette.empty.searchingFiles": "Buscando archivos...", "commandPalette.item.newSession": "Nueva sesión", + "commandPalette.item.newMiniChat": "Nueva ventana Mini Chat", "commandPalette.item.newWorktreeDraft": "Nuevo borrador de worktree", "commandPalette.item.addProject": "Añadir proyecto", "commandPalette.item.showSessionSwitcher": "Mostrar cambiador de sesiones", @@ -1922,6 +1923,26 @@ export const dict: Record = { "desktopHostSwitcher.toast.sshConnected": "Instancia SSH \"{host}\" conectada", "desktopHostSwitcher.toast.sshFailedToConnect": "No se pudo conectar con la instancia SSH \"{host}\"", "desktopHostSwitcher.toast.instanceUnreachable": "Instancia \"{host}\" no está disponible", + "miniChat.header.newSession": "Nueva sesión", + "miniChat.header.session": "Sesión", + "miniChat.header.defaultAgent": "Agente predeterminado", + "miniChat.header.noModel": "Sin modelo", + "miniChat.status.busy": "En ejecución", + "miniChat.status.retry": "Reintentando", + "miniChat.status.idle": "Inactivo", + "miniChat.actions.pin": "Fijar sobre otras ventanas", + "miniChat.actions.unpin": "Desfijar ventana", + "miniChat.actions.pinAria": "Fijar ventana Mini Chat", + "miniChat.actions.unpinAria": "Desfijar ventana Mini Chat", + "miniChat.actions.openMain": "Abrir en la ventana principal", + "miniChat.actions.openMainAria": "Abrir sesión en la ventana principal", + "miniChat.unavailable.title": "Sesión no disponible", + "miniChat.unavailable.description": "No se pudo cargar esta sesión. Puede haber sido eliminada, archivada o abierta desde otro contexto de proyecto.", + "sessions.sidebar.session.menu.openMiniChatWindow": "Abrir en ventana Mini Chat", + "header.actions.newMiniChat": "Nueva ventana Mini Chat", + "header.actions.newMiniChatAria": "Abrir una nueva ventana Mini Chat", + "header.actions.openSessionMiniChat": "Abrir sesión en Mini Chat", + "header.actions.openSessionMiniChatAria": "Abrir la sesión actual en Mini Chat", "errorBoundary.title": "Algo salió mal", "errorBoundary.description": "La aplicación encontró un error inesperado. Esto se ha registrado para depuración.", "errorBoundary.state.unknownError": "Error desconocido", diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index 96f1b462..ddcede71 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -753,6 +753,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.cycle_right_sidebar_tab.label': '오른쪽 사이드바 탭 순환', 'settings.openchamber.keyboardShortcuts.action.new_chat.label': '새 세션', 'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '새 worktree 초안', + 'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '새 Mini Chat 창', 'settings.openchamber.keyboardShortcuts.action.open_help.label': '키보드 단축키 열기', 'settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label': '계획 컨텍스트 패널 토글', 'settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label': '서비스 메뉴 토글', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 0f61234d..dee46a18 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1651,6 +1651,7 @@ export const dict: Record = { 'commandPalette.empty.noResults': '결과 없음', 'commandPalette.empty.searchingFiles': '파일 검색 중…', 'commandPalette.item.newSession': '새 세션', + 'commandPalette.item.newMiniChat': '새 Mini Chat 창', 'commandPalette.item.newWorktreeDraft': '새 워크트리 드래프트', 'commandPalette.item.addProject': '프로젝트 추가', 'commandPalette.item.showSessionSwitcher': '세션 전환기 표시', @@ -1956,6 +1957,26 @@ export const dict: Record = { 'desktopHostSwitcher.toast.sshConnected': 'SSH 인스턴스 "{host}"에 연결했습니다', 'desktopHostSwitcher.toast.sshFailedToConnect': 'SSH 인스턴스 "{host}" 연결에 실패했습니다', 'desktopHostSwitcher.toast.instanceUnreachable': '인스턴스 "{host}"에 연결할 수 없음', + 'miniChat.header.newSession': '새 세션', + 'miniChat.header.session': '세션', + 'miniChat.header.defaultAgent': '기본 에이전트', + 'miniChat.header.noModel': '모델 없음', + 'miniChat.status.busy': '실행 중', + 'miniChat.status.retry': '재시도 중', + 'miniChat.status.idle': '유휴', + 'miniChat.actions.pin': '다른 창 위에 고정', + 'miniChat.actions.unpin': '창 고정 해제', + 'miniChat.actions.pinAria': 'Mini Chat 창 고정', + 'miniChat.actions.unpinAria': 'Mini Chat 창 고정 해제', + 'miniChat.actions.openMain': '메인 창에서 열기', + 'miniChat.actions.openMainAria': '메인 창에서 세션 열기', + 'miniChat.unavailable.title': '세션을 사용할 수 없음', + 'miniChat.unavailable.description': '이 세션을 불러올 수 없습니다. 삭제, 보관되었거나 다른 프로젝트 컨텍스트에서 열렸을 수 있습니다.', + 'sessions.sidebar.session.menu.openMiniChatWindow': 'Mini Chat 창에서 열기', + 'header.actions.newMiniChat': '새 Mini Chat 창', + 'header.actions.newMiniChatAria': '새 Mini Chat 창 열기', + 'header.actions.openSessionMiniChat': 'Mini Chat에서 세션 열기', + 'header.actions.openSessionMiniChatAria': '현재 세션을 Mini Chat에서 열기', 'errorBoundary.title': '문제가 발생했습니다', 'errorBoundary.description': '애플리케이션에서 예상치 못한 오류가 발생했습니다. 디버깅을 위해 기록되었습니다.', 'errorBoundary.state.unknownError': '알 수 없음 오류', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index cef8d1c9..50d82870 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -602,6 +602,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.focus_input.label': 'Skup pole wprowadzania', 'settings.openchamber.keyboardShortcuts.action.new_chat.label': 'Nowa sesja', 'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': 'Nowy szkic obszaru roboczego', + 'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': 'Nowe okno Mini Chat', 'settings.openchamber.keyboardShortcuts.action.open_command_palette.label': 'Otwórz paletę poleceń', 'settings.openchamber.keyboardShortcuts.action.open_go_to_line.label': 'Przejdź do linii (edytor plików)', 'settings.openchamber.keyboardShortcuts.action.open_help.label': 'Otwórz skróty klawiszowe', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 848834b5..9ac6c423 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -521,6 +521,26 @@ export const dict: Record = { 'chat.messageBody.toast.planSaved': 'Plan zapisany', 'chat.messageBody.toast.imageSaved': 'Obraz zapisany', 'chat.messageBody.toast.generateImageFailed': 'Nie udało się wygenerować obrazu', + 'miniChat.header.newSession': 'Nowa sesja', + 'miniChat.header.session': 'Sesja', + 'miniChat.header.defaultAgent': 'Domyślny agent', + 'miniChat.header.noModel': 'Brak modelu', + 'miniChat.status.busy': 'Działa', + 'miniChat.status.retry': 'Ponawianie', + 'miniChat.status.idle': 'Bezczynny', + 'miniChat.actions.pin': 'Przypnij nad innymi oknami', + 'miniChat.actions.unpin': 'Odepnij okno', + 'miniChat.actions.pinAria': 'Przypnij okno Mini Chat', + 'miniChat.actions.unpinAria': 'Odepnij okno Mini Chat', + 'miniChat.actions.openMain': 'Otwórz w głównym oknie', + 'miniChat.actions.openMainAria': 'Otwórz sesję w głównym oknie', + 'miniChat.unavailable.title': 'Sesja niedostępna', + 'miniChat.unavailable.description': 'Nie udało się załadować tej sesji. Mogła zostać usunięta, zarchiwizowana albo otwarta z innego kontekstu projektu.', + 'sessions.sidebar.session.menu.openMiniChatWindow': 'Otwórz w oknie Mini Chat', + 'header.actions.newMiniChat': 'Nowe okno Mini Chat', + 'header.actions.newMiniChatAria': 'Otwórz nowe okno Mini Chat', + 'header.actions.openSessionMiniChat': 'Otwórz sesję w Mini Chat', + 'header.actions.openSessionMiniChatAria': 'Otwórz bieżącą sesję w Mini Chat', 'errorBoundary.title': 'Coś poszło nie tak', 'errorBoundary.description': 'Aplikacja napotkała nieoczekiwany błąd. Zostało to zalogowane do celów debugowania.', 'errorBoundary.state.unknownError': 'Nieznany błąd', @@ -961,6 +981,7 @@ export const dict: Record = { 'commandPalette.empty.searchingFiles': 'Wyszukiwanie plików...', 'commandPalette.input.placeholder': 'Szukaj plików, sesji i poleceń...', 'commandPalette.item.newSession': 'Nowa sesja', + 'commandPalette.item.newMiniChat': 'Nowe okno Mini Chat', 'commandPalette.item.newWorktreeDraft': 'Nowy szkic drzewa pracy', 'commandPalette.item.addProject': 'Dodaj projekt', 'commandPalette.item.openSettings': 'Otwórz ustawienia...', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index 6d2b912d..c5f37d7f 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -753,6 +753,7 @@ export const settingsDict = { "settings.openchamber.keyboardShortcuts.action.cycle_right_sidebar_tab.label": "Alternar aba da barra lateral direita", "settings.openchamber.keyboardShortcuts.action.new_chat.label": "Nova sessão", "settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label": "Novo rascunho de worktree", + "settings.openchamber.keyboardShortcuts.action.new_mini_chat.label": "Nova janela Mini Chat", "settings.openchamber.keyboardShortcuts.action.open_help.label": "Abrir atalhos de teclado", "settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label": "Alternar painel de plano de contexto", "settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label": "Mostrar ou ocultar menu de serviços", diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 91477e6d..366a1262 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1617,6 +1617,7 @@ export const dict: Record = { "commandPalette.empty.noResults": "Nenhum resultado encontrado.", "commandPalette.empty.searchingFiles": "Buscando arquivos...", "commandPalette.item.newSession": "Nova sessão", + "commandPalette.item.newMiniChat": "Nova janela Mini Chat", "commandPalette.item.newWorktreeDraft": "Novo rascunho de worktree", "commandPalette.item.addProject": "Adicionar projeto", "commandPalette.item.showSessionSwitcher": "Mostrar seletor de sessões", @@ -1922,6 +1923,26 @@ export const dict: Record = { "desktopHostSwitcher.toast.sshConnected": "Instância SSH \"{host}\" conectada", "desktopHostSwitcher.toast.sshFailedToConnect": "Não foi possível conectar com a instância SSH \"{host}\"", "desktopHostSwitcher.toast.instanceUnreachable": "Instância \"{host}\" não está disponível", + "miniChat.header.newSession": "Nova sessão", + "miniChat.header.session": "Sessão", + "miniChat.header.defaultAgent": "Agente padrão", + "miniChat.header.noModel": "Sem modelo", + "miniChat.status.busy": "Executando", + "miniChat.status.retry": "Tentando novamente", + "miniChat.status.idle": "Ocioso", + "miniChat.actions.pin": "Fixar acima de outras janelas", + "miniChat.actions.unpin": "Desafixar janela", + "miniChat.actions.pinAria": "Fixar janela Mini Chat", + "miniChat.actions.unpinAria": "Desafixar janela Mini Chat", + "miniChat.actions.openMain": "Abrir na janela principal", + "miniChat.actions.openMainAria": "Abrir sessão na janela principal", + "miniChat.unavailable.title": "Sessão indisponível", + "miniChat.unavailable.description": "Não foi possível carregar esta sessão. Ela pode ter sido excluída, arquivada ou aberta a partir de outro contexto de projeto.", + "sessions.sidebar.session.menu.openMiniChatWindow": "Abrir na janela Mini Chat", + "header.actions.newMiniChat": "Nova janela Mini Chat", + "header.actions.newMiniChatAria": "Abrir uma nova janela Mini Chat", + "header.actions.openSessionMiniChat": "Abrir sessão no Mini Chat", + "header.actions.openSessionMiniChatAria": "Abrir a sessão atual no Mini Chat", "errorBoundary.title": "Algo deu errado", "errorBoundary.description": "O aplicativo encontrou um erro inesperado. Isso foi registrado para depuração.", "errorBoundary.state.unknownError": "Erro desconhecido", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 8c8c24e2..5655c138 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -753,6 +753,7 @@ export const settingsDict = { "settings.openchamber.keyboardShortcuts.action.cycle_right_sidebar_tab.label": "Перемкнути вкладку правої бічної панелі", "settings.openchamber.keyboardShortcuts.action.new_chat.label": "Нова сесія", "settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label": "Нова чернетка worktree", + "settings.openchamber.keyboardShortcuts.action.new_mini_chat.label": "Нове вікно Mini Chat", "settings.openchamber.keyboardShortcuts.action.open_help.label": "Відкрити комбінації клавіш", "settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label": "Перемкнути контекстну панель плану", "settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label": "Перемкнути меню сервісів", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 5d465875..23bd7f04 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1617,6 +1617,7 @@ export const dict: Record = { "commandPalette.empty.noResults": "Результатів не знайдено.", "commandPalette.empty.searchingFiles": "Пошук файлів...", "commandPalette.item.newSession": "Нова сесія", + "commandPalette.item.newMiniChat": "Нове вікно Mini Chat", "commandPalette.item.newWorktreeDraft": "Чернетка нового worktree", "commandPalette.item.addProject": "Додати проєкт", "commandPalette.item.showSessionSwitcher": "Показати перемикач сесій", @@ -1922,6 +1923,26 @@ export const dict: Record = { "desktopHostSwitcher.toast.sshConnected": "Інстанс SSH \"{host}\" підключено", "desktopHostSwitcher.toast.sshFailedToConnect": "Не вдалося підключитися до інстанса SSH \"{host}\".", "desktopHostSwitcher.toast.instanceUnreachable": "Інстанс \"{host}\" недоступний", + "miniChat.header.newSession": "Нова сесія", + "miniChat.header.session": "Сесія", + "miniChat.header.defaultAgent": "Агент за замовчуванням", + "miniChat.header.noModel": "Модель не вибрано", + "miniChat.status.busy": "Виконується", + "miniChat.status.retry": "Повторна спроба", + "miniChat.status.idle": "Очікує", + "miniChat.actions.pin": "Закріпити над іншими вікнами", + "miniChat.actions.unpin": "Відкріпити вікно", + "miniChat.actions.pinAria": "Закріпити вікно Mini Chat", + "miniChat.actions.unpinAria": "Відкріпити вікно Mini Chat", + "miniChat.actions.openMain": "Відкрити в головному вікні", + "miniChat.actions.openMainAria": "Відкрити сесію в головному вікні", + "miniChat.unavailable.title": "Сесія недоступна", + "miniChat.unavailable.description": "Не вдалося завантажити цю сесію. Її могли видалити, заархівувати або відкрити з іншого контексту проєкту.", + "sessions.sidebar.session.menu.openMiniChatWindow": "Відкрити у вікні Mini Chat", + "header.actions.newMiniChat": "Нове вікно Mini Chat", + "header.actions.newMiniChatAria": "Відкрити нове вікно Mini Chat", + "header.actions.openSessionMiniChat": "Відкрити сесію в Mini Chat", + "header.actions.openSessionMiniChatAria": "Відкрити поточну сесію в Mini Chat", "errorBoundary.title": "Щось пішло не так", "errorBoundary.description": "У програмі сталася неочікувана помилка. Це було зареєстровано для налагодження.", "errorBoundary.state.unknownError": "Невідома помилка", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index fbdba8e2..34bdc06f 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -753,6 +753,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.cycle_right_sidebar_tab.label': '轮换右侧边栏标签', 'settings.openchamber.keyboardShortcuts.action.new_chat.label': '新建会话', 'settings.openchamber.keyboardShortcuts.action.new_chat_worktree.label': '新建工作树草稿', + 'settings.openchamber.keyboardShortcuts.action.new_mini_chat.label': '新建 Mini Chat 窗口', 'settings.openchamber.keyboardShortcuts.action.open_help.label': '打开键盘快捷键', 'settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label': '切换上下文面板中的计划', 'settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label': '切换服务菜单', diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 245b30cd..e121369b 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1617,6 +1617,7 @@ export const dict: Record = { 'commandPalette.empty.noResults': '未找到结果。', 'commandPalette.empty.searchingFiles': '正在搜索文件...', 'commandPalette.item.newSession': '新建会话', + 'commandPalette.item.newMiniChat': '新建 Mini Chat 窗口', 'commandPalette.item.newWorktreeDraft': '新建工作树草稿', 'commandPalette.item.addProject': '添加项目', 'commandPalette.item.showSessionSwitcher': '显示会话切换器', @@ -1922,6 +1923,26 @@ export const dict: Record = { 'desktopHostSwitcher.toast.sshConnected': 'SSH 实例“{host}”已连接', 'desktopHostSwitcher.toast.sshFailedToConnect': 'SSH 实例“{host}”连接失败', 'desktopHostSwitcher.toast.instanceUnreachable': '实例“{host}”不可达', + 'miniChat.header.newSession': '新会话', + 'miniChat.header.session': '会话', + 'miniChat.header.defaultAgent': '默认智能体', + 'miniChat.header.noModel': '未选择模型', + 'miniChat.status.busy': '运行中', + 'miniChat.status.retry': '重试中', + 'miniChat.status.idle': '空闲', + 'miniChat.actions.pin': '固定在其他窗口上方', + 'miniChat.actions.unpin': '取消固定窗口', + 'miniChat.actions.pinAria': '固定 Mini Chat 窗口', + 'miniChat.actions.unpinAria': '取消固定 Mini Chat 窗口', + 'miniChat.actions.openMain': '在主窗口中打开', + 'miniChat.actions.openMainAria': '在主窗口中打开会话', + 'miniChat.unavailable.title': '会话不可用', + 'miniChat.unavailable.description': '无法加载此会话。它可能已被删除、归档,或从其他项目上下文打开。', + 'sessions.sidebar.session.menu.openMiniChatWindow': '在 Mini Chat 窗口中打开', + 'header.actions.newMiniChat': '新建 Mini Chat 窗口', + 'header.actions.newMiniChatAria': '打开新的 Mini Chat 窗口', + 'header.actions.openSessionMiniChat': '在 Mini Chat 中打开会话', + 'header.actions.openSessionMiniChatAria': '在 Mini Chat 中打开当前会话', 'errorBoundary.title': '发生错误', 'errorBoundary.description': '应用遇到意外错误,已记录用于调试。', 'errorBoundary.state.unknownError': '未知错误', diff --git a/packages/ui/src/lib/shortcuts.ts b/packages/ui/src/lib/shortcuts.ts index 1cfd764b..813cae46 100644 --- a/packages/ui/src/lib/shortcuts.ts +++ b/packages/ui/src/lib/shortcuts.ts @@ -214,6 +214,12 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ description: 'Create a new worktree and open a draft in it', customizable: true, }, + { + id: 'new_mini_chat', + defaultCombo: 'mod+alt+n', + label: 'New Mini Chat window', + description: 'Open a new Mini Chat draft window', + }, { id: 'submit_message', defaultCombo: 'mod+enter', diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index 428b6e3b..0d63ac70 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -213,6 +213,18 @@ async function materializeSessionFromServer( // Used to determine if user is currently viewing the session when a notification arrives. let _activeDirectory = "" let _activeSession = "" +const externallyViewedSessions = new Map() +const EXTERNAL_VIEW_TTL_MS = 15_000 + +const viewedSessionKey = (directory: string, sessionId: string) => `${directory}\n${sessionId}` + +function pruneExternallyViewedSessions(now = Date.now()) { + for (const [key, expiresAt] of externallyViewedSessions.entries()) { + if (expiresAt <= now) { + externallyViewedSessions.delete(key) + } + } +} const pendingQuestionToastIds = new Set() const pendingPermissionToastIds = new Set() @@ -239,10 +251,21 @@ export function setActiveSession(directory: string, sessionId: string) { _activeSession = sessionId } +export function setExternallyViewedSession(directory: string, sessionId: string, viewed: boolean) { + if (!directory || !sessionId) return + const key = viewedSessionKey(directory, sessionId) + if (!viewed) { + externallyViewedSessions.delete(key) + return + } + externallyViewedSessions.set(key, Date.now() + EXTERNAL_VIEW_TTL_MS) +} + function isViewedInCurrentSession(directory: string, sessionId?: string): boolean { - if (!_activeDirectory || !_activeSession || !sessionId) return false - if (directory !== _activeDirectory) return false - return sessionId === _activeSession + if (!sessionId) return false + if (_activeDirectory && _activeSession && directory === _activeDirectory && sessionId === _activeSession) return true + pruneExternallyViewedSessions() + return externallyViewedSessions.has(viewedSessionKey(directory, sessionId)) } function isRecentBoot() { diff --git a/packages/web/mini-chat.html b/packages/web/mini-chat.html new file mode 100644 index 00000000..ee84d145 --- /dev/null +++ b/packages/web/mini-chat.html @@ -0,0 +1,12 @@ + + + + + + OpenChamber Mini Chat + + + +
+ + diff --git a/packages/web/src/mini-chat-main.tsx b/packages/web/src/mini-chat-main.tsx new file mode 100644 index 00000000..590f7b72 --- /dev/null +++ b/packages/web/src/mini-chat-main.tsx @@ -0,0 +1,17 @@ +import { createWebAPIs } from './api'; +import type { RuntimeAPIs } from '@openchamber/ui/lib/api/types'; +import '@openchamber/ui/index.css'; +import '@openchamber/ui/styles/fonts'; + +declare global { + interface Window { + __OPENCHAMBER_RUNTIME_APIS__?: RuntimeAPIs; + } +} + +window.__OPENCHAMBER_RUNTIME_APIS__ = createWebAPIs(); + +void import('@openchamber/ui/apps/renderElectronMiniChatApp') + .then(({ renderElectronMiniChatApp }) => { + renderElectronMiniChatApp(window.__OPENCHAMBER_RUNTIME_APIS__ ?? createWebAPIs()); + }); diff --git a/packages/web/vite.config.ts b/packages/web/vite.config.ts index 27560c95..5bfb3cee 100644 --- a/packages/web/vite.config.ts +++ b/packages/web/vite.config.ts @@ -101,6 +101,10 @@ export default defineConfig({ emptyOutDir: true, chunkSizeWarningLimit: 500, rollupOptions: { + input: { + main: path.resolve(__dirname, 'index.html'), + miniChat: path.resolve(__dirname, 'mini-chat.html'), + }, external: ['node:child_process', 'node:fs', 'node:path', 'node:url'], output: { manualChunks(id) {