mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-04 19:58:48 +00:00
* fix: reset-state-on-new-session-click * fix(chat): preserve continuity while session ids settle New conversations were crossing a short but important consistency gap. The route could already point at a newly created session id while the projects payload had not refreshed yet, and realtime/optimistic messages could still be keyed under a provisional id. In that window the UI could stop reading the active session store, briefly render the conversation as missing, and then repopulate it a moment later. That same gap also made duplication more likely. Optimistic local user messages could survive long enough to appear beside the persisted copy, and finalized assistant streaming rows could sit directly next to the server-backed assistant message with the same content before realtime state was cleared. The result was a chat view that felt unstable exactly when a new session was being created. This commit makes session-id reconciliation a first-class part of the chat flow instead of assuming every layer will agree immediately. The session store now understands canonical session aliases and can migrate one conversation from a provisional id to the real id without dropping its in-memory state. The route navigation path can replace the provisional URL entry instead of stacking it in history, and the project/session selection logic keeps a synthetic selected session alive long enough for the sidebar and project payloads to catch up. The practical goal is to keep one visible conversation throughout the whole creation lifecycle: no dead window between websocket events and project refresh, no stale provisional URL after the real id is known, and no extra optimistic/local bubbles when server history catches up. * fix(cli): resolve executable path for Claude CLI on Windows * fix(session-synchronizer): improve session name extraction for Claude and Codex
213 lines
7.2 KiB
TypeScript
213 lines
7.2 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
|
import { useNavigate, useParams } from 'react-router-dom';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
import Sidebar from '../sidebar/view/Sidebar';
|
|
import MainContent from '../main-content/view/MainContent';
|
|
import CommandPalette from '../command-palette/CommandPalette';
|
|
import { useWebSocket } from '../../contexts/WebSocketContext';
|
|
import { PaletteOpsProvider, usePaletteOpsRegister } from '../../contexts/PaletteOpsContext';
|
|
import { useDeviceSettings } from '../../hooks/useDeviceSettings';
|
|
import { useSessionProtection } from '../../hooks/useSessionProtection';
|
|
import { useProjectsState } from '../../hooks/useProjectsState';
|
|
|
|
export default function AppContent() {
|
|
return (
|
|
<PaletteOpsProvider>
|
|
<AppContentInner />
|
|
</PaletteOpsProvider>
|
|
);
|
|
}
|
|
|
|
function AppContentInner() {
|
|
const navigate = useNavigate();
|
|
const { sessionId } = useParams<{ sessionId?: string }>();
|
|
const { t } = useTranslation('common');
|
|
const { isMobile } = useDeviceSettings({ trackPWA: false });
|
|
const { ws, sendMessage, latestMessage, isConnected } = useWebSocket();
|
|
const wasConnectedRef = useRef(false);
|
|
|
|
const {
|
|
activeSessions,
|
|
processingSessions,
|
|
markSessionAsActive,
|
|
markSessionAsInactive,
|
|
markSessionAsProcessing,
|
|
markSessionAsNotProcessing,
|
|
replaceTemporarySession,
|
|
} = useSessionProtection();
|
|
|
|
const {
|
|
selectedProject,
|
|
selectedSession,
|
|
activeTab,
|
|
sidebarOpen,
|
|
isLoadingProjects,
|
|
externalMessageUpdate,
|
|
newSessionTrigger,
|
|
setActiveTab,
|
|
setSidebarOpen,
|
|
setIsInputFocused,
|
|
setShowSettings,
|
|
openSettings,
|
|
refreshProjectsSilently,
|
|
sidebarSharedProps,
|
|
handleNewSession,
|
|
} = useProjectsState({
|
|
sessionId,
|
|
navigate,
|
|
latestMessage,
|
|
isMobile,
|
|
activeSessions,
|
|
});
|
|
|
|
usePaletteOpsRegister({
|
|
openSettings,
|
|
refreshProjects: refreshProjectsSilently,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {
|
|
return undefined;
|
|
}
|
|
|
|
const handleServiceWorkerMessage = (event: MessageEvent) => {
|
|
const message = event.data;
|
|
if (!message || message.type !== 'notification:navigate') {
|
|
return;
|
|
}
|
|
|
|
if (typeof message.provider === 'string' && message.provider.trim()) {
|
|
localStorage.setItem('selected-provider', message.provider);
|
|
}
|
|
|
|
setActiveTab('chat');
|
|
setSidebarOpen(false);
|
|
void refreshProjectsSilently();
|
|
|
|
if (typeof message.sessionId === 'string' && message.sessionId) {
|
|
navigate(`/session/${message.sessionId}`);
|
|
return;
|
|
}
|
|
|
|
navigate('/');
|
|
};
|
|
|
|
navigator.serviceWorker.addEventListener('message', handleServiceWorkerMessage);
|
|
|
|
return () => {
|
|
navigator.serviceWorker.removeEventListener('message', handleServiceWorkerMessage);
|
|
};
|
|
}, [navigate, refreshProjectsSilently, setActiveTab, setSidebarOpen]);
|
|
|
|
// Permission recovery: query pending permissions on WebSocket reconnect or session change
|
|
useEffect(() => {
|
|
const isReconnect = isConnected && !wasConnectedRef.current;
|
|
|
|
if (isReconnect) {
|
|
wasConnectedRef.current = true;
|
|
} else if (!isConnected) {
|
|
wasConnectedRef.current = false;
|
|
}
|
|
|
|
if (isConnected && selectedSession?.id) {
|
|
sendMessage({
|
|
type: 'get-pending-permissions',
|
|
sessionId: selectedSession.id
|
|
});
|
|
}
|
|
}, [isConnected, selectedSession?.id, sendMessage]);
|
|
|
|
// Adjust the app container to stay above the virtual keyboard on iOS Safari.
|
|
// On Chrome for Android the layout viewport already shrinks when the keyboard opens,
|
|
// so inset-0 adjusts automatically. On iOS the layout viewport stays full-height and
|
|
// the keyboard overlays it — we use the Visual Viewport API to track keyboard height
|
|
// and apply it as a CSS variable that shifts the container's bottom edge up.
|
|
useEffect(() => {
|
|
const vv = window.visualViewport;
|
|
if (!vv) return;
|
|
const update = () => {
|
|
// Only resize matters — keyboard open/close changes vv.height.
|
|
// Do NOT listen to scroll: on iOS Safari, scrolling content changes
|
|
// vv.offsetTop which would make --keyboard-height fluctuate during
|
|
// normal scrolling, causing the container to bounce up and down.
|
|
const kb = Math.max(0, window.innerHeight - vv.height);
|
|
document.documentElement.style.setProperty('--keyboard-height', `${kb}px`);
|
|
};
|
|
vv.addEventListener('resize', update);
|
|
return () => vv.removeEventListener('resize', update);
|
|
}, []);
|
|
|
|
return (
|
|
<div className="fixed inset-0 flex bg-background" style={{ bottom: 'var(--keyboard-height, 0px)' }}>
|
|
{!isMobile ? (
|
|
<div className="h-full flex-shrink-0 border-r border-border/50">
|
|
<Sidebar {...sidebarSharedProps} />
|
|
</div>
|
|
) : (
|
|
<div
|
|
className={`fixed inset-0 z-50 flex transition-all duration-150 ease-out ${sidebarOpen ? 'visible opacity-100' : 'invisible opacity-0'
|
|
}`}
|
|
>
|
|
<button
|
|
className="fixed inset-0 bg-background/60 backdrop-blur-sm transition-opacity duration-150 ease-out"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
setSidebarOpen(false);
|
|
}}
|
|
onTouchStart={(event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
setSidebarOpen(false);
|
|
}}
|
|
aria-label={t('versionUpdate.ariaLabels.closeSidebar')}
|
|
/>
|
|
<div
|
|
className={`relative h-full w-[85vw] max-w-sm transform border-r border-border/40 bg-card transition-transform duration-150 ease-out sm:w-80 ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
|
}`}
|
|
onClick={(event) => event.stopPropagation()}
|
|
onTouchStart={(event) => event.stopPropagation()}
|
|
>
|
|
<Sidebar {...sidebarSharedProps} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex min-w-0 flex-1 flex-col">
|
|
<MainContent
|
|
selectedProject={selectedProject}
|
|
selectedSession={selectedSession}
|
|
activeTab={activeTab}
|
|
setActiveTab={setActiveTab}
|
|
ws={ws}
|
|
sendMessage={sendMessage}
|
|
latestMessage={latestMessage}
|
|
isMobile={isMobile}
|
|
onMenuClick={() => setSidebarOpen(true)}
|
|
isLoading={isLoadingProjects}
|
|
onInputFocusChange={setIsInputFocused}
|
|
onSessionActive={markSessionAsActive}
|
|
onSessionInactive={markSessionAsInactive}
|
|
onSessionProcessing={markSessionAsProcessing}
|
|
onSessionNotProcessing={markSessionAsNotProcessing}
|
|
processingSessions={processingSessions}
|
|
onReplaceTemporarySession={replaceTemporarySession}
|
|
onNavigateToSession={(targetSessionId: string, options) =>
|
|
navigate(`/session/${targetSessionId}`, { replace: Boolean(options?.replace) })
|
|
}
|
|
onShowSettings={() => setShowSettings(true)}
|
|
externalMessageUpdate={externalMessageUpdate}
|
|
newSessionTrigger={newSessionTrigger}
|
|
/>
|
|
</div>
|
|
|
|
<CommandPalette
|
|
selectedProject={selectedProject}
|
|
onStartNewChat={handleNewSession}
|
|
onOpenSettings={() => openSettings()}
|
|
onShowTab={setActiveTab}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|