feat(chat): delay session creation until first message

The app now opens to a new chat by default on startup
New chats no longer create a session until you send your first message
Fixed mobile and VSCode sessions handling for better consistency
This commit is contained in:
Bohdan Triapitsyn
2025-12-21 20:18:51 +02:00
parent 75ba954307
commit 7699aab123
12 changed files with 273 additions and 152 deletions
@@ -27,6 +27,7 @@ export const ChatContainer: React.FC = () => {
messageStreamStates,
trimToViewportWindow,
sessionActivityPhase,
newSessionDraft,
} = useSessionStore();
const streamingMessageId = React.useMemo(() => {
@@ -35,6 +36,7 @@ export const ChatContainer: React.FC = () => {
}, [currentSessionId, streamingMessageIds]);
const { isMobile } = useDeviceInfo();
const draftOpen = Boolean(newSessionDraft?.open);
const sessionMessages = React.useMemo(() => {
@@ -143,7 +145,7 @@ export const ChatContainer: React.FC = () => {
void load();
}, [currentSessionId, loadMessages, messages, scrollToBottom]);
if (!currentSessionId) {
if (!currentSessionId && !draftOpen) {
return (
<div
className="flex flex-col h-full bg-background"
@@ -156,6 +158,26 @@ export const ChatContainer: React.FC = () => {
);
}
if (!currentSessionId && draftOpen) {
return (
<div
className="flex flex-col h-full bg-background transform-gpu"
style={isMobile ? { paddingBottom: 'var(--oc-keyboard-inset, 0px)' } : undefined}
>
<div className="flex-1 flex items-center justify-center">
<OpenChamberLogo width={140} height={140} className="opacity-20" isAnimated />
</div>
<div className="relative bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 z-10">
<ChatInput scrollToBottom={scrollToBottom} />
</div>
</div>
);
}
if (!currentSessionId) {
return null;
}
if (isLoading && sessionMessages.length === 0 && !streamingMessageId) {
const hasMessagesEntry = messages.has(currentSessionId);
if (!hasMessagesEntry) {
+15 -10
View File
@@ -61,6 +61,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
const sendMessage = useSessionStore((state) => state.sendMessage);
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const newSessionDraftOpen = useSessionStore((state) => state.newSessionDraft?.open);
const abortCurrentOperation = useSessionStore((state) => state.abortCurrentOperation);
const acknowledgeSessionAbort = useSessionStore((state) => state.acknowledgeSessionAbort);
const abortPromptSessionId = useSessionStore((state) => state.abortPromptSessionId);
@@ -229,7 +230,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault();
if (!hasContent || !currentSessionId) return;
if (!hasContent || (!currentSessionId && !newSessionDraftOpen)) return;
const messageToSend = message.replace(/^\n+|\n+$/g, '');
@@ -542,7 +543,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
return;
}
if (!currentSessionId) {
if (!currentSessionId && !newSessionDraftOpen) {
return;
}
@@ -572,7 +573,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
if (attachedCount > 0) {
toast.success(`Attached ${attachedCount} image${attachedCount > 1 ? 's' : ''} from clipboard`);
}
}, [addAttachedFile, currentSessionId, insertTextAtSelection]);
}, [addAttachedFile, currentSessionId, newSessionDraftOpen, insertTextAtSelection]);
const handleFileSelect = (file: { name: string; path: string }) => {
@@ -660,7 +661,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (currentSessionId && !isDragging) {
if ((currentSessionId || newSessionDraftOpen) && !isDragging) {
setIsDragging(true);
}
};
@@ -678,7 +679,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
e.stopPropagation();
setIsDragging(false);
if (!currentSessionId) return;
if (!currentSessionId && !newSessionDraftOpen) return;
const files = Array.from(e.dataTransfer.files);
let attachedCount = 0;
@@ -838,13 +839,13 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
) : (
<button
type={isMobile ? 'button' : 'submit'}
disabled={!hasContent || !currentSessionId}
disabled={!hasContent || (!currentSessionId && !newSessionDraftOpen)}
onPointerDownCapture={(event) => {
if (!isMobile || event.pointerType !== 'touch') {
return;
}
if (!hasContent || !currentSessionId) {
if (!hasContent || (!currentSessionId && !newSessionDraftOpen)) {
return;
}
@@ -868,7 +869,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
}}
className={cn(
iconButtonBaseClass,
hasContent && currentSessionId
hasContent && (currentSessionId || newSessionDraftOpen)
? 'text-primary hover:text-primary'
: 'opacity-30'
)}
@@ -1080,8 +1081,12 @@ export const ChatInput: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBo
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onPointerDownCapture={handleTextareaPointerDownCapture}
placeholder={currentSessionId ? "# for agents; @ for files; / for commands" : "Select or create a session to start chatting"}
disabled={!currentSessionId}
placeholder={currentSessionId
? "# for agents; @ for files; / for commands"
: newSessionDraftOpen
? "Type your first message..."
: "Select or create a session to start chatting"}
disabled={!currentSessionId && !newSessionDraftOpen}
className={cn(
'min-h-[52px] resize-none border-0 px-3 shadow-none rounded-t-xl rounded-b-none appearance-none focus:shadow-none focus-visible:shadow-none focus-visible:border-transparent focus-visible:ring-0 focus-visible:ring-transparent hover:border-transparent bg-transparent',
@@ -12,11 +12,11 @@ import { SettingsPage } from '@/components/sections/settings/SettingsPage';
type VSCodeView = 'sessions' | 'chat' | 'settings';
export const VSCodeLayout: React.FC = () => {
const [currentView, setCurrentView] = React.useState<VSCodeView>('sessions');
const [currentView, setCurrentView] = React.useState<VSCodeView>('chat');
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const sessions = useSessionStore((state) => state.sessions);
const createSession = useSessionStore((state) => state.createSession);
const setCurrentSession = useSessionStore((state) => state.setCurrentSession);
const newSessionDraftOpen = useSessionStore((state) => Boolean(state.newSessionDraft?.open));
const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft);
const [connectionStatus, setConnectionStatus] = React.useState<'connecting' | 'connected' | 'error' | 'disconnected'>(
() => (typeof window !== 'undefined'
? (window as { __OPENCHAMBER_CONNECTION__?: { status?: string } }).__OPENCHAMBER_CONNECTION__?.status as
@@ -38,8 +38,6 @@ export const VSCodeLayout: React.FC = () => {
const messages = useSessionStore((state) => state.messages);
const [hasInitializedOnce, setHasInitializedOnce] = React.useState<boolean>(() => configInitialized);
const [isInitializing, setIsInitializing] = React.useState<boolean>(false);
const autoSelectedRef = React.useRef<boolean>(false);
const startedFreshSessionRef = React.useRef<boolean>(false);
// Navigate to chat when a session is selected
React.useEffect(() => {
@@ -50,21 +48,19 @@ export const VSCodeLayout: React.FC = () => {
// If the active session disappears (e.g., deleted), stay on the sessions list
React.useEffect(() => {
if (!currentSessionId && currentView === 'chat') {
if (!currentSessionId && !newSessionDraftOpen && currentView === 'chat') {
setCurrentView('sessions');
}
}, [currentSessionId, currentView]);
}, [currentSessionId, currentView, newSessionDraftOpen]);
const handleBackToSessions = React.useCallback(() => {
setCurrentView('sessions');
}, []);
const handleNewSession = React.useCallback(async () => {
const result = await createSession();
if (result?.id) {
setCurrentView('chat');
}
}, [createSession]);
const handleNewSession = React.useCallback(() => {
openNewSessionDraft();
setCurrentView('chat');
}, [openNewSessionDraft]);
React.useEffect(() => {
const handler = (event: Event) => {
@@ -137,65 +133,26 @@ export const VSCodeLayout: React.FC = () => {
React.useEffect(() => {
const hydrateMessages = async () => {
if (!hasInitializedOnce || connectionStatus !== 'connected' || currentView !== 'chat') {
if (!hasInitializedOnce || connectionStatus !== 'connected' || currentView !== 'chat' || newSessionDraftOpen) {
return;
}
const targetSessionId = currentSessionId || sessions[0]?.id;
if (!targetSessionId) return;
const hasMessages = messages.has(targetSessionId) && (messages.get(targetSessionId)?.length || 0) > 0;
if (!currentSessionId) {
return;
}
const hasMessages = messages.has(currentSessionId) && (messages.get(currentSessionId)?.length || 0) > 0;
if (!hasMessages) {
if (!currentSessionId) {
setCurrentSession(targetSessionId);
}
try {
await loadMessages(targetSessionId);
await loadMessages(currentSessionId);
} catch { /* ignored */ }
}
};
void hydrateMessages();
}, [connectionStatus, currentSessionId, currentView, hasInitializedOnce, loadMessages, messages, sessions, setCurrentSession]);
}, [connectionStatus, currentSessionId, currentView, hasInitializedOnce, loadMessages, messages, newSessionDraftOpen]);
React.useEffect(() => {
if (!hasInitializedOnce || autoSelectedRef.current) {
return;
}
if (!currentSessionId && sessions.length > 0) {
setCurrentSession(sessions[0].id);
autoSelectedRef.current = true;
}
}, [currentSessionId, hasInitializedOnce, sessions, setCurrentSession]);
React.useEffect(() => {
const ensureFreshSession = async () => {
if (connectionStatus !== 'connected' || !hasInitializedOnce || startedFreshSessionRef.current) {
return;
}
const current = sessions.find((s) => s.id === currentSessionId);
const isCurrentPlaceholder = current?.title?.toLowerCase()?.startsWith('new session');
if (current && isCurrentPlaceholder) {
setCurrentSession(current.id);
} else {
// Look for an existing empty session to reuse before creating a new one
const reusableSession = sessions.find((s) => s.title?.toLowerCase()?.startsWith('new session'));
if (reusableSession) {
setCurrentSession(reusableSession.id);
} else {
const newSession = await createSession();
if (newSession?.id) {
setCurrentSession(newSession.id);
}
}
}
startedFreshSessionRef.current = true;
};
void ensureFreshSession();
}, [connectionStatus, createSession, currentSessionId, hasInitializedOnce, sessions, setCurrentSession]);
return (
<div className="h-full w-full bg-background text-foreground flex flex-col">
@@ -229,7 +186,9 @@ export const VSCodeLayout: React.FC = () => {
) : (
<div className="flex flex-col h-full">
<VSCodeHeader
title={sessions.find(s => s.id === currentSessionId)?.title || 'Chat'}
title={newSessionDraftOpen && !currentSessionId
? 'New session'
: sessions.find(s => s.id === currentSessionId)?.title || 'Chat'}
showBack
onBack={handleBackToSessions}
showContextUsage
@@ -28,7 +28,7 @@ import { sessionEvents } from '@/lib/sessionEvents';
import { formatDirectoryName, formatPathForDisplay, cn } from '@/lib/utils';
import { useSessionStore } from '@/stores/useSessionStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useUIStore } from '@/stores/useUIStore';
import type { WorktreeMetadata } from '@/types/worktree';
import { opencodeClient } from '@/lib/opencode/client';
import { checkIsGitRepository } from '@/lib/gitApi';
@@ -126,7 +126,6 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
);
const checkingDirectories = React.useRef<Set<string>>(new Set());
const safeStorage = React.useMemo(() => getSafeStorage(), []);
const [isCreatingSession, setIsCreatingSession] = React.useState(false);
const [collapsedGroups, setCollapsedGroups] = React.useState<Set<string>>(new Set());
const [isGitRepo, setIsGitRepo] = React.useState<boolean | null>(null);
const [expandedSessionGroups, setExpandedSessionGroups] = React.useState<Set<string>>(new Set());
@@ -136,7 +135,8 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
const setDirectory = useDirectoryStore((state) => state.setDirectory);
const agents = useConfigStore((state) => state.agents);
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
const getSessionsByDirectory = useSessionStore((state) => state.getSessionsByDirectory);
const currentSessionId = useSessionStore((state) => state.currentSessionId);
@@ -148,9 +148,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
const sessionActivityPhase = useSessionStore((state) => state.sessionActivityPhase);
const worktreeMetadata = useSessionStore((state) => state.worktreeMetadata);
const availableWorktrees = useSessionStore((state) => state.availableWorktrees);
const createSession = useSessionStore((state) => state.createSession);
const initializeNewOpenChamberSession = useSessionStore((state) => state.initializeNewOpenChamberSession);
const setSessionDirectory = useSessionStore((state) => state.setSessionDirectory);
const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft);
const [isDesktopRuntime, setIsDesktopRuntime] = React.useState<boolean>(() => {
if (typeof window === 'undefined') {
@@ -342,6 +340,12 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
if (disabled) {
return;
}
if (mobileVariant) {
setActiveMainTab('chat');
setSessionSwitcherOpen(false);
}
if (!allowReselect && sessionId === currentSessionId) {
onSessionSelected?.(sessionId);
return;
@@ -349,7 +353,15 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
setCurrentSession(sessionId);
onSessionSelected?.(sessionId);
},
[allowReselect, currentSessionId, onSessionSelected, setCurrentSession],
[
allowReselect,
currentSessionId,
mobileVariant,
onSessionSelected,
setActiveMainTab,
setCurrentSession,
setSessionSwitcherOpen,
],
);
const handleSaveEdit = React.useCallback(async () => {
@@ -455,31 +467,14 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
);
const handleCreateSessionInGroup = React.useCallback(
async (directory: string | null) => {
if (!directory) {
toast.error('No directory available for session creation');
return;
}
if (isCreatingSession) {
return;
}
try {
setIsCreatingSession(true);
const session = await createSession(undefined, directory);
if (!session) {
toast.error('Failed to create session');
return;
}
initializeNewOpenChamberSession(session.id, agents);
setSessionDirectory(session.id, directory);
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to create session';
toast.error(message);
} finally {
setIsCreatingSession(false);
(directory: string | null) => {
setActiveMainTab('chat');
if (mobileVariant) {
setSessionSwitcherOpen(false);
}
openNewSessionDraft({ directoryOverride: directory ?? null });
},
[createSession, initializeNewOpenChamberSession, setSessionDirectory, agents, isCreatingSession],
[openNewSessionDraft, setActiveMainTab, setSessionSwitcherOpen, mobileVariant],
);
const handleOpenWorktreeManager = React.useCallback(() => {
@@ -1016,13 +1011,46 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
emptyState
) : hideDirectoryControls && groupedSessions.length === 1 && groupedSessions[0].isMain ? (
<div className="space-y-[0.6rem] py-1">
{groupedSessions[0].sessions.length === 0 ? (
<div className="py-1 text-left typography-micro text-muted-foreground">
No sessions yet.
</div>
) : (
groupedSessions[0].sessions.map((node) => renderSessionNode(node, 0, groupedSessions[0].directory))
)}
{(() => {
const group = groupedSessions[0];
const maxVisible = hideDirectoryControls ? 10 : 7;
const totalSessions = group.sessions.length;
const isExpanded = expandedSessionGroups.has(group.id);
const visibleSessions = isExpanded ? group.sessions : group.sessions.slice(0, maxVisible);
const remainingCount = totalSessions - visibleSessions.length;
if (totalSessions === 0) {
return (
<div className="py-1 text-left typography-micro text-muted-foreground">
No sessions yet.
</div>
);
}
return (
<>
{visibleSessions.map((node) => renderSessionNode(node, 0, group.directory))}
{remainingCount > 0 && !isExpanded ? (
<button
type="button"
onClick={() => toggleGroupSessionLimit(group.id)}
className="mt-0.5 flex items-center justify-start rounded-md px-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
>
Show {remainingCount} more {remainingCount === 1 ? 'session' : 'sessions'}
</button>
) : null}
{isExpanded && totalSessions > maxVisible ? (
<button
type="button"
onClick={() => toggleGroupSessionLimit(group.id)}
className="mt-0.5 flex items-center justify-start rounded-md px-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
>
Show fewer sessions
</button>
) : null}
</>
);
})()}
</div>
) : (
groupedSessions.map((group) => (
@@ -1054,19 +1082,15 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
<span
role="button"
tabIndex={0}
aria-disabled={isCreatingSession}
className={cn(
'inline-flex h-5 w-5 items-center justify-center rounded-md text-muted-foreground/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 hover:text-foreground',
isCreatingSession && 'opacity-40 cursor-default',
)}
aria-label="Create session in this group"
onClick={(e) => {
if (isCreatingSession) return;
e.stopPropagation();
handleCreateSessionInGroup(group.directory);
}}
onKeyDown={(e) => {
if (isCreatingSession) return;
if (e.key === 'Enter' || e.key === ' ') {
e.stopPropagation();
handleCreateSessionInGroup(group.directory);
@@ -1084,7 +1108,7 @@ export const SessionSidebar: React.FC<SessionSidebarProps> = ({
<div className="space-y-[0.6rem] py-1">
{(() => {
const isExpanded = expandedSessionGroups.has(group.id);
const maxVisible = 7;
const maxVisible = hideDirectoryControls ? 10 : 7;
const totalSessions = group.sessions.length;
const visibleSessions = isExpanded ? group.sessions : group.sessions.slice(0, maxVisible);
const remainingCount = totalSessions - visibleSessions.length;
@@ -12,7 +12,6 @@ import {
import { useUIStore } from '@/stores/useUIStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { useDeviceInfo } from '@/lib/device';
import { RiAddLine, RiChatAi3Line, RiCheckLine, RiCodeLine, RiComputerLine, RiGitBranchLine, RiLayoutLeftLine, RiMoonLine, RiQuestionLine, RiRestartLine, RiSettings3Line, RiSunLine, RiTerminalBoxLine } from '@remixicon/react';
@@ -31,14 +30,12 @@ export const CommandPalette: React.FC = () => {
} = useUIStore();
const {
createSession,
openNewSessionDraft,
setCurrentSession,
getSessionsByDirectory,
initializeNewOpenChamberSession,
} = useSessionStore();
const { currentDirectory } = useDirectoryStore();
const { agents } = useConfigStore();
const { themeMode, setThemeMode } = useThemeSystem();
const handleClose = () => {
@@ -46,10 +43,9 @@ export const CommandPalette: React.FC = () => {
};
const handleCreateSession = async () => {
const session = await createSession();
if (session) {
initializeNewOpenChamberSession(session.id, agents);
}
setActiveMainTab('chat');
setSessionSwitcherOpen(false);
openNewSessionDraft();
handleClose();
};