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) {