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
@@ -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,
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user