feat: add Electron Mini Chat windows (#1161)
Add dedicated Electron Mini Chat windows for focused chat sessions without the full desktop shell. Mini Chat can open existing sessions or draft sessions, supports pinning above other windows, transfers sessions or drafts back to the main window, and deduplicates existing-session windows. Expose Mini Chat entry points from the main header, session sidebar, command palette, and `mod+alt+n`. Add a dedicated Vite entry and React runtime so the compact surface can stay isolated from full-app chrome while still sharing chat, sync, theme, locale, model, agent, and worktree behavior. Keep Mini Chat behavior scoped to the compact surface: - limit assistant/user message actions to the appropriate Mini Chat set - hide workspace changed-files UI in Mini Chat - keep draft worktree selection and streaming directory state in sync - mark sessions viewed while they are open in Mini Chat - support Mini Chat-specific keyboard shortcuts for input focus, model selection, thinking variant cycling, favorite model cycling, and opening new Mini Chat drafts Harden Electron integration by gating Mini Chat controls on desktop IPC availability, restricting pin/unpin IPC to Mini Chat windows, and only closing Mini Chat after the main window handoff succeeds.
This commit is contained in:
committed by
GitHub
parent
8410c41b01
commit
e1ff21bc0a
@@ -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();
|
||||
|
||||
|
||||
+30
-2
@@ -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;
|
||||
|
||||
|
||||
@@ -5,8 +5,19 @@ import { useQueuedMessageAutoSend } from '@/hooks/useQueuedMessageAutoSend';
|
||||
import { useSessionAutoCleanup } from '@/hooks/useSessionAutoCleanup';
|
||||
import { useWindowControlsOverlayLayout } from '@/hooks/useWindowControlsOverlayLayout';
|
||||
import { setOptimisticRefs } from '@/sync/session-actions';
|
||||
import { markSessionViewed } from '@/sync/notification-store';
|
||||
import { setExternallyViewedSession } from '@/sync/sync-context';
|
||||
import { useSync } from '@/sync/use-sync';
|
||||
|
||||
const MINI_CHAT_PRESENCE_CHANNEL = 'openchamber:mini-chat-presence';
|
||||
|
||||
type MiniChatPresenceMessage = {
|
||||
type?: string;
|
||||
sessionId?: string;
|
||||
directory?: string;
|
||||
viewed?: boolean;
|
||||
};
|
||||
|
||||
const SyncOptimisticBridge: React.FC = () => {
|
||||
const sync = useSync();
|
||||
const addRef = React.useRef(sync.optimistic.add);
|
||||
@@ -24,14 +35,50 @@ const SyncOptimisticBridge: React.FC = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const MiniChatPresenceBridge: React.FC = () => {
|
||||
React.useEffect(() => {
|
||||
if (typeof BroadcastChannel === 'undefined') return;
|
||||
|
||||
const channel = new BroadcastChannel(MINI_CHAT_PRESENCE_CHANNEL);
|
||||
channel.onmessage = (event) => {
|
||||
const data = event.data as MiniChatPresenceMessage | null;
|
||||
if (data?.type !== 'mini-chat-session-presence' || !data.sessionId || !data.directory) {
|
||||
return;
|
||||
}
|
||||
|
||||
const viewed = data.viewed !== false;
|
||||
setExternallyViewedSession(data.directory, data.sessionId, viewed);
|
||||
if (viewed) {
|
||||
markSessionViewed(data.sessionId);
|
||||
}
|
||||
};
|
||||
|
||||
return () => channel.close();
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export function SyncRuntimeEffects({ embeddedBackgroundWorkEnabled }: {
|
||||
embeddedBackgroundWorkEnabled: boolean;
|
||||
}) {
|
||||
useSessionAutoCleanup(embeddedBackgroundWorkEnabled);
|
||||
useQueuedMessageAutoSend(embeddedBackgroundWorkEnabled);
|
||||
|
||||
return <SyncOptimisticBridge />;
|
||||
}
|
||||
|
||||
export function SyncAppEffects({ embeddedBackgroundWorkEnabled }: {
|
||||
embeddedBackgroundWorkEnabled: boolean;
|
||||
}) {
|
||||
usePwaManifestSync();
|
||||
useWindowControlsOverlayLayout();
|
||||
useSessionAutoCleanup(embeddedBackgroundWorkEnabled);
|
||||
useQueuedMessageAutoSend(embeddedBackgroundWorkEnabled);
|
||||
useKeyboardShortcuts();
|
||||
|
||||
return <SyncOptimisticBridge />;
|
||||
return (
|
||||
<>
|
||||
<SyncRuntimeEffects embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled} />
|
||||
<MiniChatPresenceBridge />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import React from 'react';
|
||||
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
|
||||
import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider';
|
||||
import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { MiniChatLayout } from '@/components/mini-chat/MiniChatLayout';
|
||||
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
|
||||
import { useWindowTitle } from '@/hooks/useWindowTitle';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import type { RuntimeAPIs } from '@/lib/api/types';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useGitStore } from '@/stores/useGitStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { SyncProvider, useSessions } from '@/sync/sync-context';
|
||||
import { SyncRuntimeEffects } from './AppEffects';
|
||||
import { useAppFontEffects } from './useAppFontEffects';
|
||||
import { useMiniChatKeyboardShortcuts } from '@/hooks/useMiniChatKeyboardShortcuts';
|
||||
import { listProjectWorktrees } from '@/lib/worktrees/worktreeManager';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
|
||||
const MINI_CHAT_PRESENCE_CHANNEL = 'openchamber:mini-chat-presence';
|
||||
|
||||
type MiniChatMode = 'session' | 'draft';
|
||||
|
||||
type MiniChatConfig = {
|
||||
mode: MiniChatMode;
|
||||
sessionId: string | null;
|
||||
directory: string | null;
|
||||
projectId: string | null;
|
||||
};
|
||||
|
||||
type ElectronMiniChatAppProps = {
|
||||
apis: RuntimeAPIs;
|
||||
};
|
||||
|
||||
const readMiniChatConfig = (): MiniChatConfig => {
|
||||
const params = typeof window !== 'undefined' ? new URLSearchParams(window.location.search) : new URLSearchParams();
|
||||
const mode = params.get('mode') === 'session' ? 'session' : 'draft';
|
||||
const sessionId = params.get('sessionId')?.trim() || null;
|
||||
const directory = params.get('directory')?.trim() || null;
|
||||
const projectId = params.get('projectId')?.trim() || null;
|
||||
return { mode, sessionId, directory, projectId };
|
||||
};
|
||||
|
||||
const MiniChatBootstrap: React.FC<{ config: MiniChatConfig }> = ({ config }) => {
|
||||
const sessions = useSessions();
|
||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||
const setDirectory = useDirectoryStore((state) => state.setDirectory);
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const draftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
|
||||
const draftDirectory = useSessionUIStore((state) => {
|
||||
if (!state.newSessionDraft?.open) return '';
|
||||
return state.newSessionDraft.bootstrapPendingDirectory ?? state.newSessionDraft.directoryOverride ?? '';
|
||||
});
|
||||
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
|
||||
const openNewSessionDraft = useSessionUIStore((state) => state.openNewSessionDraft);
|
||||
const initializeApp = useConfigStore((state) => state.initializeApp);
|
||||
const isInitialized = useConfigStore((state) => state.isInitialized);
|
||||
const isConnected = useConfigStore((state) => state.isConnected);
|
||||
const loadProviders = useConfigStore((state) => state.loadProviders);
|
||||
const loadAgents = useConfigStore((state) => state.loadAgents);
|
||||
const providersCount = useConfigStore((state) => state.providers.length);
|
||||
const agentsCount = useConfigStore((state) => state.agents.length);
|
||||
|
||||
React.useEffect(() => {
|
||||
void initializeApp();
|
||||
}, [initializeApp]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isInitialized) return;
|
||||
let active = true;
|
||||
let retryCount = 0;
|
||||
const id = window.setInterval(() => {
|
||||
if (!active) return;
|
||||
retryCount += 1;
|
||||
if (retryCount > 10) {
|
||||
window.clearInterval(id);
|
||||
return;
|
||||
}
|
||||
if (!useConfigStore.getState().isInitialized) {
|
||||
void useConfigStore.getState().initializeApp();
|
||||
}
|
||||
}, 1000);
|
||||
return () => {
|
||||
active = false;
|
||||
window.clearInterval(id);
|
||||
};
|
||||
}, [isInitialized]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (config.mode !== 'session') return;
|
||||
if (!config.directory || currentDirectory === config.directory) return;
|
||||
setDirectory(config.directory, { showOverlay: false });
|
||||
}, [config.directory, config.mode, currentDirectory, setDirectory]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (config.mode !== 'draft' || !draftOpen || currentSessionId) return;
|
||||
if (!draftDirectory || currentDirectory === draftDirectory) return;
|
||||
setDirectory(draftDirectory, { showOverlay: false });
|
||||
}, [config.mode, currentDirectory, currentSessionId, draftDirectory, draftOpen, setDirectory]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isConnected) return;
|
||||
if (providersCount === 0) void loadProviders();
|
||||
if (agentsCount === 0) void loadAgents();
|
||||
}, [agentsCount, isConnected, loadAgents, loadProviders, providersCount]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (config.mode !== 'session' || !config.sessionId) return;
|
||||
if (currentSessionId === config.sessionId) return;
|
||||
const session = sessions.find((entry) => entry.id === config.sessionId);
|
||||
if (!session) return;
|
||||
const directory = (session as { directory?: string | null }).directory ?? config.directory;
|
||||
setCurrentSession(config.sessionId, directory);
|
||||
}, [config, currentSessionId, sessions, setCurrentSession]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (config.mode !== 'draft' || draftOpen || currentSessionId) return;
|
||||
openNewSessionDraft({
|
||||
selectedProjectId: config.projectId,
|
||||
directoryOverride: config.directory,
|
||||
preserveDirectoryOverride: Boolean(config.directory),
|
||||
});
|
||||
}, [config, currentSessionId, draftOpen, openNewSessionDraft]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (projects.length === 0) return;
|
||||
let cancelled = false;
|
||||
|
||||
const discoverWorktrees = async () => {
|
||||
const worktreesByProject = new Map<string, WorktreeMetadata[]>();
|
||||
const allWorktrees: WorktreeMetadata[] = [];
|
||||
|
||||
await Promise.all(projects.map(async (project) => {
|
||||
const projectPath = project.path.replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
if (!projectPath) return;
|
||||
try {
|
||||
const cachedIsGitRepo = useGitStore.getState().directories.get(projectPath)?.isGitRepo;
|
||||
const isGitRepo = cachedIsGitRepo ?? await import('@/lib/gitApi').then((m) => m.checkIsGitRepository(projectPath));
|
||||
if (!isGitRepo) return;
|
||||
const worktrees = await listProjectWorktrees({ id: project.id, path: projectPath });
|
||||
if (cancelled || worktrees.length === 0) return;
|
||||
worktreesByProject.set(projectPath, worktrees);
|
||||
allWorktrees.push(...worktrees);
|
||||
} catch {
|
||||
// Worktree discovery is best-effort; draft selector falls back to the project root.
|
||||
}
|
||||
}));
|
||||
|
||||
if (cancelled) return;
|
||||
useSessionUIStore.setState({
|
||||
availableWorktrees: allWorktrees,
|
||||
availableWorktreesByProject: worktreesByProject,
|
||||
});
|
||||
};
|
||||
|
||||
void discoverWorktrees();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projects]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const MiniChatPresencePublisher: React.FC = () => {
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!currentSessionId || !currentDirectory || typeof BroadcastChannel === 'undefined') return;
|
||||
|
||||
const channel = new BroadcastChannel(MINI_CHAT_PRESENCE_CHANNEL);
|
||||
const postPresence = (viewed: boolean) => {
|
||||
channel.postMessage({
|
||||
type: 'mini-chat-session-presence',
|
||||
sessionId: currentSessionId,
|
||||
directory: currentDirectory,
|
||||
viewed,
|
||||
});
|
||||
};
|
||||
|
||||
postPresence(true);
|
||||
const interval = window.setInterval(() => postPresence(true), 5_000);
|
||||
const handleBeforeUnload = () => postPresence(false);
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(interval);
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
postPresence(false);
|
||||
channel.close();
|
||||
};
|
||||
}, [currentDirectory, currentSessionId]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const useSessionUnavailable = (config: MiniChatConfig): boolean => {
|
||||
const sessions = useSessions();
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const [timedOut, setTimedOut] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (config.mode !== 'session' || !config.sessionId || currentSessionId === config.sessionId) {
|
||||
setTimedOut(false);
|
||||
return;
|
||||
}
|
||||
if (sessions.some((entry) => entry.id === config.sessionId)) {
|
||||
setTimedOut(false);
|
||||
return;
|
||||
}
|
||||
const timeout = window.setTimeout(() => setTimedOut(true), 5000);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [config.mode, config.sessionId, currentSessionId, sessions]);
|
||||
|
||||
return timedOut;
|
||||
};
|
||||
|
||||
export function ElectronMiniChatApp({ apis }: ElectronMiniChatAppProps) {
|
||||
const config = React.useMemo(() => readMiniChatConfig(), []);
|
||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||
|
||||
React.useEffect(() => {
|
||||
opencodeClient.setDirectory(currentDirectory || config.directory || undefined);
|
||||
}, [config.directory, currentDirectory]);
|
||||
|
||||
React.useEffect(() => {
|
||||
registerRuntimeAPIs(apis);
|
||||
return () => registerRuntimeAPIs(null);
|
||||
}, [apis]);
|
||||
|
||||
useAppFontEffects();
|
||||
useMiniChatKeyboardShortcuts();
|
||||
usePushVisibilityBeacon({ enabled: true });
|
||||
useWindowTitle();
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<SyncProvider sdk={opencodeClient.getSdkClient()} directory={currentDirectory || config.directory || ''}>
|
||||
<RuntimeAPIProvider apis={apis}>
|
||||
<TooltipProvider delayDuration={300} skipDelayDuration={150}>
|
||||
<div className="h-full text-foreground bg-background">
|
||||
<ElectronMiniChatContent config={config} />
|
||||
<Toaster />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</RuntimeAPIProvider>
|
||||
</SyncProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
const ElectronMiniChatContent: React.FC<{ config: MiniChatConfig }> = ({ config }) => {
|
||||
const sessionUnavailable = useSessionUnavailable(config);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MiniChatBootstrap config={config} />
|
||||
<MiniChatPresencePublisher />
|
||||
<SyncRuntimeEffects embeddedBackgroundWorkEnabled={true} />
|
||||
<MiniChatLayout mode={config.mode} autoOpenDraft={config.mode === 'draft'} unavailable={sessionUnavailable} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import '@/styles/fonts';
|
||||
import '@/index.css';
|
||||
import '@/lib/debug';
|
||||
import { SessionAuthGate } from '@/components/auth/SessionAuthGate';
|
||||
import { ThemeProvider } from '@/components/providers/ThemeProvider';
|
||||
import { ThemeSystemProvider } from '@/contexts/ThemeSystemContext';
|
||||
import type { RuntimeAPIs } from '@/lib/api/types';
|
||||
import { startAppearanceAutoSave } from '@/lib/appearanceAutoSave';
|
||||
import { applyPersistedDirectoryPreferences } from '@/lib/directoryPersistence';
|
||||
import { initializeLocale, I18nProvider } from '@/lib/i18n';
|
||||
import { initializeAppearancePreferences, syncDesktopSettings } from '@/lib/persistence';
|
||||
import { startModelPrefsAutoSave } from '@/lib/modelPrefsAutoSave';
|
||||
import { startTypographyWatcher } from '@/lib/typographyWatcher';
|
||||
import { ElectronMiniChatApp } from './ElectronMiniChatApp';
|
||||
|
||||
const initializeSharedPreferences = () => {
|
||||
initializeLocale();
|
||||
|
||||
void initializeAppearancePreferences().then(() => {
|
||||
void Promise.all([
|
||||
syncDesktopSettings(),
|
||||
applyPersistedDirectoryPreferences(),
|
||||
]).catch((err) => {
|
||||
console.error('[mini-chat-main] settings init failed:', err);
|
||||
});
|
||||
|
||||
startAppearanceAutoSave();
|
||||
startModelPrefsAutoSave();
|
||||
startTypographyWatcher();
|
||||
}).catch((err) => {
|
||||
console.error('[mini-chat-main] appearance init failed:', err);
|
||||
});
|
||||
};
|
||||
|
||||
export function renderElectronMiniChatApp(apis: RuntimeAPIs) {
|
||||
initializeSharedPreferences();
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
throw new Error('Root element not found');
|
||||
}
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<I18nProvider>
|
||||
<ThemeSystemProvider>
|
||||
<ThemeProvider>
|
||||
<SessionAuthGate>
|
||||
<ElectronMiniChatApp apis={apis} />
|
||||
</SessionAuthGate>
|
||||
</ThemeProvider>
|
||||
</ThemeSystemProvider>
|
||||
</I18nProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
}
|
||||
@@ -317,7 +317,11 @@ const HYDRATING_SKELETON_ITEMS: Array<{
|
||||
},
|
||||
];
|
||||
|
||||
export const ChatContainer: React.FC = () => {
|
||||
type ChatContainerProps = {
|
||||
autoOpenDraft?: boolean;
|
||||
};
|
||||
|
||||
export const ChatContainer: React.FC<ChatContainerProps> = ({ 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];
|
||||
|
||||
@@ -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<ChatInputProps> = ({ 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) {
|
||||
|
||||
@@ -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 <ChatSurfaceContext.Provider value={mode}>{children}</ChatSurfaceContext.Provider>;
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
export type ChatSurfaceMode = 'default' | 'mini-chat';
|
||||
|
||||
export const ChatSurfaceContext = React.createContext<ChatSurfaceMode>('default');
|
||||
@@ -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<number | null>(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 ? (
|
||||
<div className={cn(
|
||||
'group/user-actions',
|
||||
isMobile
|
||||
@@ -466,7 +469,7 @@ const UserMessageBody = React.memo(({ messageId, parts, isMobile, alwaysShowActi
|
||||
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.revert')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onFork && (
|
||||
{effectiveOnFork && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -478,7 +481,7 @@ const UserMessageBody = React.memo(({ messageId, parts, isMobile, alwaysShowActi
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onFork();
|
||||
effectiveOnFork();
|
||||
}}
|
||||
>
|
||||
<RiGitBranchLine className="h-3 w-3" />
|
||||
@@ -598,6 +601,7 @@ const AssistantMessageActionButtons = React.memo(({
|
||||
ttsText,
|
||||
}: AssistantMessageActionButtonsProps) => {
|
||||
const { t } = useI18n();
|
||||
const chatSurfaceMode = useChatSurfaceMode();
|
||||
const { isPlaying: isTTSPlaying, play: playTTS, stop: stopTTS } = useMessageTTS();
|
||||
const showMessageTTSButtons = useConfigStore((state) => state.showMessageTTSButtons);
|
||||
const voiceProvider = useConfigStore((state) => state.voiceProvider);
|
||||
@@ -775,7 +779,7 @@ const AssistantMessageActionButtons = React.memo(({
|
||||
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.copyAnswer')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
{chatSurfaceMode !== 'mini-chat' ? <Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -799,8 +803,8 @@ const AssistantMessageActionButtons = React.memo(({
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={6}>{isSharing ? t('chat.messageBody.actions.savingImage') : t('chat.messageBody.actions.saveAsImage')}</TooltipContent>
|
||||
</Tooltip>
|
||||
{showMessageTTSButtons && hasCopyableText && (
|
||||
</Tooltip> : null}
|
||||
{chatSurfaceMode !== 'mini-chat' && showMessageTTSButtons && hasCopyableText && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -857,6 +861,7 @@ const AssistantMessageBody = React.memo(({
|
||||
errorVariant = 'error',
|
||||
}: Omit<MessageBodyProps, 'isUser'>) => {
|
||||
const { t } = useI18n();
|
||||
const chatSurfaceMode = useChatSurfaceMode();
|
||||
const streamPhase = _streamPhase;
|
||||
void _allowAnimation;
|
||||
const messageContentRef = React.useRef<HTMLDivElement>(null);
|
||||
@@ -1008,6 +1013,7 @@ const AssistantMessageBody = React.memo(({
|
||||
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
|
||||
const showSplitAssistantMessageActions = useUIStore((state) => state.showSplitAssistantMessageActions);
|
||||
const isSortedRenderMode = chatRenderMode === 'sorted';
|
||||
const isMiniChatSurface = chatSurfaceMode === 'mini-chat';
|
||||
const collapsedPreviewCount = 7;
|
||||
const isLastAssistantInTurn = turnGroupingContext?.isLastAssistantInTurn ?? false;
|
||||
const hasStopFinish = messageFinish === 'stop';
|
||||
@@ -1700,7 +1706,7 @@ const AssistantMessageBody = React.memo(({
|
||||
|
||||
const footerTimestampClassName = 'text-sm text-muted-foreground/60 tabular-nums flex items-center gap-1';
|
||||
const isVSCode = isVSCodeRuntime();
|
||||
const canOpenMessagePreview = !isMobile && !isVSCode;
|
||||
const canOpenMessagePreview = !isMiniChatSurface && !isMobile && !isVSCode;
|
||||
|
||||
const finalTurnActionButtons = (
|
||||
<>
|
||||
@@ -1729,7 +1735,7 @@ const AssistantMessageBody = React.memo(({
|
||||
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.openPreview')}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{!isVSCode ? (
|
||||
{!isMiniChatSurface && !isVSCode ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -1750,7 +1756,7 @@ const AssistantMessageBody = React.memo(({
|
||||
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.saveAsPlan')}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<Tooltip>
|
||||
{!isMiniChatSurface ? <Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -1764,8 +1770,8 @@ const AssistantMessageBody = React.memo(({
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={6}>{t('chat.messageBody.actions.startNewSession')}</TooltipContent>
|
||||
</Tooltip>
|
||||
{!isVSCode ? (
|
||||
</Tooltip> : null}
|
||||
{!isMiniChatSurface && !isVSCode ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -1877,7 +1883,7 @@ const AssistantMessageBody = React.memo(({
|
||||
<TooltipContent>{footerTimestamp}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{isLastAssistantInTurn && hasStopFinish ? (
|
||||
{!isMiniChatSurface && isLastAssistantInTurn && hasStopFinish ? (
|
||||
<TurnChangedFilesDropdown activityParts={turnGroupingContext?.activityParts} />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import React from 'react';
|
||||
import { ChatSurfaceContext, type ChatSurfaceMode } from './chatSurfaceContextValue';
|
||||
|
||||
export const useChatSurfaceMode = (): ChatSurfaceMode => React.useContext(ChatSurfaceContext);
|
||||
@@ -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<HeaderProps> = ({
|
||||
}
|
||||
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<HeaderProps> = ({
|
||||
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<HeaderProps> = ({
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{hasElectronDesktopIPC && !isLeftSidebarOpen ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('header.actions.newMiniChatAria')}
|
||||
onClick={handleOpenDraftMiniChat}
|
||||
className={cn(desktopHeaderIconButtonClass, 'mr-6 shrink-0')}
|
||||
>
|
||||
<RiWindowLine className="h-[18px] w-[18px]" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t('header.actions.newMiniChat')}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{projectActionsContext && (
|
||||
<ProjectActionsButton
|
||||
projectRef={projectActionsContext.projectRef}
|
||||
@@ -1892,6 +1931,14 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<HeaderIconActionButton
|
||||
visible={hasElectronDesktopIPC && !isNewSessionDraftOpen && Boolean(currentSessionId)}
|
||||
title={t('header.actions.openSessionMiniChat')}
|
||||
ariaLabel={t('header.actions.openSessionMiniChatAria')}
|
||||
onClick={handleOpenCurrentSessionMiniChat}
|
||||
className={`${desktopHeaderIconButtonClass} mr-1`}
|
||||
Icon={RiPictureInPicture2Line}
|
||||
/>
|
||||
{showDesktopHeaderContextUsage && stableDesktopContextUsage ? (
|
||||
<ContextUsageDisplay
|
||||
totalTokens={stableDesktopContextUsage.totalTokens}
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import React from 'react';
|
||||
import { RiExternalLinkLine, RiGitBranchLine, RiPushpin2Fill, RiPushpin2Line } from '@remixicon/react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ChatContainer } from '@/components/chat/ChatContainer';
|
||||
import { ChatSurfaceProvider } from '@/components/chat/ChatSurfaceContext';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { invokeDesktop, isElectronShell } from '@/lib/desktop';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useSessionWorktreeStore } from '@/sync/session-worktree-store';
|
||||
import { useSessions } from '@/sync/sync-context';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useGitBranchLabel, useGitStore } from '@/stores/useGitStore';
|
||||
import { resolveSessionDiffStats } from '@/components/session/sidebar/utils';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
|
||||
type MiniChatMode = 'session' | 'draft';
|
||||
|
||||
type MiniChatLayoutProps = {
|
||||
mode: MiniChatMode;
|
||||
autoOpenDraft?: boolean;
|
||||
unavailable?: boolean;
|
||||
};
|
||||
|
||||
const compactPath = (value: string | null | undefined): string => {
|
||||
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<typeof resolveSessionDiffStats>[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 (
|
||||
<header
|
||||
className={cn(
|
||||
'flex items-center gap-3 border-b border-[var(--interactive-border)] bg-[var(--surface-background)] pr-3',
|
||||
hasMacTrafficLights ? 'pl-[5.5rem]' : 'pl-3',
|
||||
macosHeaderSizeClass || 'min-h-14',
|
||||
)}
|
||||
style={dragRegionStyle}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate pl-1 typography-ui-label text-[14px] font-normal leading-tight text-foreground">{title}</div>
|
||||
<div className="flex min-w-0 items-center gap-1.5 truncate pl-1 typography-micro text-[10.5px] font-normal leading-tight text-muted-foreground/75">
|
||||
<span className="truncate">{projectLabel}</span>
|
||||
{branchLabel ? (
|
||||
<span className="inline-flex min-w-0 items-center gap-0.5">
|
||||
<RiGitBranchLine className="h-3 w-3 flex-shrink-0 text-muted-foreground/70" />
|
||||
<span className="truncate">{branchLabel}</span>
|
||||
</span>
|
||||
) : null}
|
||||
{hasChanges ? (
|
||||
<span className="inline-flex flex-shrink-0 items-center gap-0 text-[0.92em]">
|
||||
<span className="text-status-success/80">+{changes.additions}</span>
|
||||
<span className="text-muted-foreground/60">/</span>
|
||||
<span className="text-status-error/65">-{changes.deletions}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleTogglePinned}
|
||||
aria-label={pinned ? t('miniChat.actions.unpinAria') : t('miniChat.actions.pinAria')}
|
||||
title={pinned ? t('miniChat.actions.unpin') : t('miniChat.actions.pin')}
|
||||
style={noDragRegionStyle}
|
||||
>
|
||||
{pinned ? <RiPushpin2Fill className="h-4 w-4" /> : <RiPushpin2Line className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleOpenMainApp}
|
||||
aria-label={t('miniChat.actions.openMainAria')}
|
||||
title={t('miniChat.actions.openMain')}
|
||||
style={noDragRegionStyle}
|
||||
>
|
||||
<RiExternalLinkLine className="h-4 w-4" />
|
||||
</Button>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export const MiniChatLayout: React.FC<MiniChatLayoutProps> = ({ mode, autoOpenDraft = false, unavailable = false }) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-background text-foreground">
|
||||
<MiniChatHeader mode={mode} />
|
||||
<main className="min-h-0 flex-1">
|
||||
{unavailable ? (
|
||||
<div className="flex h-full items-center justify-center px-6 text-center typography-ui-label text-muted-foreground">
|
||||
<div className="max-w-sm rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)] px-4 py-3">
|
||||
<div className="font-medium text-foreground">{t('miniChat.unavailable.title')}</div>
|
||||
<div className="mt-1 typography-small text-muted-foreground">{t('miniChat.unavailable.description')}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ChatSurfaceProvider mode="mini-chat">
|
||||
<ChatContainer autoOpenDraft={autoOpenDraft} />
|
||||
</ChatSurfaceProvider>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<div
|
||||
@@ -721,6 +733,17 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
|
||||
{isElectron ? (
|
||||
<DropdownMenuItem
|
||||
disabled={!sessionDirectory}
|
||||
onClick={handleOpenMiniChatWindow}
|
||||
className="[&>svg]:mr-1"
|
||||
>
|
||||
<RiWindowLine className="mr-1 h-4 w-4" />
|
||||
<span className="truncate">{t('sessions.sidebar.session.menu.openMiniChatWindow')}</span>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-destructive focus:text-destructive [&>svg]:mr-1" onClick={() => handleDeleteSession(session, { archivedBucket })}>
|
||||
<RiDeleteBinLine className="mr-1 h-4 w-4" />
|
||||
|
||||
@@ -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: <RiWindowLine className="mr-2 h-4 w-4" />,
|
||||
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,
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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<number | null>(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,
|
||||
]);
|
||||
|
||||
|
||||
@@ -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<HTMLTextAreaElement>('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]);
|
||||
};
|
||||
@@ -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 <T = unknown>(command: string, args?: Record<string, unknown>): Promise<T | null> => {
|
||||
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<T>;
|
||||
};
|
||||
|
||||
const normalizeOrigin = (raw: string): string | null => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1617,6 +1617,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"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<I18nKey, string> = {
|
||||
"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",
|
||||
|
||||
@@ -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': '서비스 메뉴 토글',
|
||||
|
||||
@@ -1651,6 +1651,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'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<I18nKey, string> = {
|
||||
'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': '알 수 없음 오류',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -521,6 +521,26 @@ export const dict: Record<I18nKey, string> = {
|
||||
'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<I18nKey, string> = {
|
||||
'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...',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1617,6 +1617,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"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<I18nKey, string> = {
|
||||
"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",
|
||||
|
||||
@@ -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": "Перемкнути меню сервісів",
|
||||
|
||||
@@ -1617,6 +1617,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
"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<I18nKey, string> = {
|
||||
"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": "Невідома помилка",
|
||||
|
||||
@@ -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': '切换服务菜单',
|
||||
|
||||
@@ -1617,6 +1617,7 @@ export const dict: Record<I18nKey, string> = {
|
||||
'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<I18nKey, string> = {
|
||||
'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': '未知错误',
|
||||
|
||||
@@ -214,6 +214,12 @@ const SHORTCUT_ACTIONS: ReadonlyArray<ShortcutAction> = [
|
||||
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',
|
||||
|
||||
@@ -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<string, number>()
|
||||
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<string>()
|
||||
const pendingPermissionToastIds = new Set<string>()
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="h-full">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<title>OpenChamber Mini Chat</title>
|
||||
<script type="module" src="/src/mini-chat-main.tsx"></script>
|
||||
</head>
|
||||
<body class="h-full bg-background text-foreground">
|
||||
<div id="root" class="h-full"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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());
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user