feat: add Electron Mini Chat windows (#1161)

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

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

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

Harden Electron integration by gating Mini Chat controls on desktop IPC availability, restricting pin/unpin IPC to Mini Chat windows, and only closing Mini Chat after the main window handoff succeeds.
This commit is contained in:
Bohdan Triapitsyn
2026-05-08 12:22:59 +03:00
committed by GitHub
parent 8410c41b01
commit e1ff21bc0a
37 changed files with 1312 additions and 29 deletions
@@ -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);