import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Sidebar } from "@/features/sidebar/ui/Sidebar"; import { StatusBar } from "@/features/status/ui/StatusBar"; import { CreateProjectDialog } from "@/features/projects/ui/CreateProjectDialog"; import { archiveProject } from "@/features/projects/api/projects"; import type { ProjectInfo } from "@/features/projects/api/projects"; import { SettingsModal } from "@/features/settings/ui/SettingsModal"; import type { SectionId } from "@/features/settings/ui/SettingsModal"; import { TopBar } from "./ui/TopBar"; import { useChatStore } from "@/features/chat/stores/chatStore"; import { type ChatSession, useChatSessionStore, } from "@/features/chat/stores/chatSessionStore"; import { useAgentStore } from "@/features/agents/stores/agentStore"; import { useProjectStore } from "@/features/projects/stores/projectStore"; import { findExistingDraft } from "@/features/chat/lib/newChat"; import { DEFAULT_CHAT_TITLE } from "@/features/chat/lib/sessionTitle"; import { useAppStartup } from "./hooks/useAppStartup"; import { useHomeSessionStateSync } from "./hooks/useHomeSessionStateSync"; import { loadStoredHomeSessionId } from "./lib/homeSessionStorage"; import { resolveSupportedSessionModelPreference } from "./lib/resolveSupportedSessionModelPreference"; import { useCreatePersonaNavigation } from "./hooks/useCreatePersonaNavigation"; import { AppShellContent } from "./ui/AppShellContent"; import { acpPrepareSession, acpSetModel } from "@/shared/api/acp"; import { clearReplayBuffer, getAndDeleteReplayBuffer, } from "@/features/chat/hooks/replayBuffer"; import { resolveSessionCwd } from "@/features/projects/lib/sessionCwdSelection"; import { perfLog } from "@/shared/lib/perfLog"; import { useProviderInventoryStore } from "@/features/providers/stores/providerInventoryStore"; export type AppView = | "home" | "chat" | "skills" | "agents" | "projects" | "session-history"; const SIDEBAR_DEFAULT_WIDTH = 240; const SIDEBAR_MIN_WIDTH = 180; const SIDEBAR_MAX_WIDTH = 380; const SIDEBAR_SNAP_COLLAPSE_THRESHOLD = 100; const SIDEBAR_COLLAPSED_WIDTH = 48; export function AppShell({ children }: { children?: React.ReactNode }) { const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [sidebarWidth, setSidebarWidth] = useState(SIDEBAR_DEFAULT_WIDTH); const [isResizing, setIsResizing] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [settingsInitialSection, setSettingsInitialSection] = useState("appearance"); const [createProjectOpen, setCreateProjectOpen] = useState(false); const [createProjectInitialWorkingDir, setCreateProjectInitialWorkingDir] = useState(null); const [editingProject, setEditingProject] = useState( null, ); const [activeView, setActiveView] = useState("home"); const [homeSessionId, setHomeSessionId] = useState(() => loadStoredHomeSessionId(), ); const chatStore = useChatStore(); const sessionStore = useChatSessionStore(); const agentStore = useAgentStore(); const projectStore = useProjectStore(); const providerInventoryEntries = useProviderInventoryStore((s) => s.entries); const pendingProjectCreatedRef = useRef<((projectId: string) => void) | null>( null, ); const homeSessionRequestRef = useRef | null>( null, ); const loadSessionMessages = useCallback(async (sessionId: string) => { const sid = sessionId.slice(0, 8); const existingMsgs = useChatStore.getState().messagesBySession[sessionId]; if ((existingMsgs?.length ?? 0) > 0) { perfLog(`[perf:load] ${sid} skip — has messages`); return; } const t0 = performance.now(); perfLog(`[perf:load] ${sid} start`); useChatStore.getState().setSessionLoading(sessionId, true); try { const [{ acpLoadSession }, { getReplayPerf, clearReplayPerf }] = await Promise.all([ import("@/shared/api/acp"), import("@/shared/api/acpNotificationHandler"), ]); const t1 = performance.now(); perfLog(`[perf:load] ${sid} import in ${(t1 - t0).toFixed(1)}ms`); const session = useChatSessionStore.getState().getSession(sessionId); const gooseSessionId = session?.acpSessionId ?? sessionId; const project = session?.projectId ? (useProjectStore .getState() .projects.find((p) => p.id === session.projectId) ?? null) : null; const workingDir = await resolveSessionCwd(project); await acpLoadSession(sessionId, gooseSessionId, workingDir); const tFlush = performance.now(); useChatStore.getState().setSessionLoading(sessionId, false); const buffer = getAndDeleteReplayBuffer(sessionId); const replayStats = getReplayPerf(sessionId); clearReplayPerf(sessionId); if (buffer && buffer.length > 0) { useChatStore.getState().setMessages(sessionId, buffer); } const t2 = performance.now(); perfLog( `[perf:load] ${sid} replay: notifs=${replayStats?.count ?? 0} span=${replayStats?.spanMs.toFixed(1) ?? "0"}ms msgs=${buffer?.length ?? 0} flush=${(t2 - tFlush).toFixed(1)}ms total=${(t2 - t0).toFixed(1)}ms`, ); } catch (err) { console.error("Failed to load session messages:", err); clearReplayBuffer(sessionId); useChatStore.getState().setSessionLoading(sessionId, false); } }, []); useAppStartup(); useEffect(() => { projectStore.fetchProjects(); }, [projectStore.fetchProjects]); const { activeSessionId } = sessionStore; useEffect(() => { if (activeView === "chat" && activeSessionId) { useChatStore.getState().markSessionRead(activeSessionId); } }, [activeSessionId, activeView]); const isHome = activeView === "home"; const activeSession = activeSessionId ? sessionStore.getSession(activeSessionId) : undefined; const modelName = activeView === "chat" ? activeSession?.modelName : undefined; const tokenCount = activeView === "chat" && activeSessionId ? chatStore.getSessionRuntime(activeSessionId).tokenState.totalTokens : 0; const homeSession = homeSessionId ? sessionStore.getSession(homeSessionId) : undefined; useHomeSessionStateSync({ homeSessionId, homeSession, messagesBySession: chatStore.messagesBySession, hasHydratedSessions: sessionStore.hasHydratedSessions, isLoading: sessionStore.isLoading, setHomeSessionId, }); const ensureHomeSession = useCallback(async () => { if (!sessionStore.hasHydratedSessions || sessionStore.isLoading) { return null; } if (homeSessionRequestRef.current) { return homeSessionRequestRef.current; } const request = (async () => { if ( homeSession && !homeSession.archivedAt && homeSession.messageCount === 0 ) { const sessionModelPreference = await resolveSupportedSessionModelPreference( agentStore.selectedProvider ?? "goose", providerInventoryEntries, ); const project = homeSession.projectId ? (projectStore.projects.find( (candidate) => candidate.id === homeSession.projectId, ) ?? null) : null; const workingDir = await resolveSessionCwd(project); await acpPrepareSession( homeSession.id, sessionModelPreference.providerId, workingDir, { personaId: homeSession.personaId, }, ); const shouldClearHomeModel = sessionModelPreference.providerId !== homeSession.providerId || !sessionModelPreference.modelId; sessionStore.updateSession(homeSession.id, { providerId: sessionModelPreference.providerId, modelId: shouldClearHomeModel ? undefined : homeSession.modelId, modelName: shouldClearHomeModel ? undefined : homeSession.modelName, }); if (sessionModelPreference.modelId) { await acpSetModel(homeSession.id, sessionModelPreference.modelId); sessionStore.updateSession(homeSession.id, { modelId: sessionModelPreference.modelId, modelName: sessionModelPreference.modelName, }); } return homeSession; } const workingDir = await resolveSessionCwd(null); const sessionModelPreference = await resolveSupportedSessionModelPreference( agentStore.selectedProvider ?? "goose", providerInventoryEntries, ); const session = await sessionStore.createSession({ title: DEFAULT_CHAT_TITLE, providerId: sessionModelPreference.providerId, workingDir, modelId: sessionModelPreference.modelId, modelName: sessionModelPreference.modelName, }); setHomeSessionId(session.id); return session; })(); homeSessionRequestRef.current = request; try { return await request; } finally { if (homeSessionRequestRef.current === request) { homeSessionRequestRef.current = null; } } }, [ agentStore.selectedProvider, homeSession, providerInventoryEntries, projectStore.projects, sessionStore.hasHydratedSessions, sessionStore, sessionStore.isLoading, ]); useEffect(() => { if (activeView !== "home") { return; } void ensureHomeSession().catch((error) => { console.error("Failed to ensure Home session:", error); }); }, [activeView, ensureHomeSession]); const createNewTab = useCallback( async (title = DEFAULT_CHAT_TITLE, project?: ProjectInfo) => { const tStart = performance.now(); perfLog( `[perf:newtab] createNewTab start (project=${project?.id ?? "none"})`, ); const agentId = agentStore.activeAgentId ?? undefined; const providerId = project?.preferredProvider ?? agentStore.selectedProvider ?? "goose"; const sessionModelPreference = await resolveSupportedSessionModelPreference( providerId, providerInventoryEntries, project?.preferredModel ?? undefined, ); const sessionState = useChatSessionStore.getState(); const chatState = useChatStore.getState(); const existingDraft = findExistingDraft({ sessions: sessionState.sessions, activeSessionId: sessionState.activeSessionId, draftsBySession: chatState.draftsBySession, messagesBySession: chatState.messagesBySession, request: { title, projectId: project?.id, }, }); if (existingDraft) { sessionStore.setActiveSession(existingDraft.id); setActiveView("chat"); chatStore.setActiveSession(existingDraft.id); perfLog( `[perf:newtab] ${existingDraft.id.slice(0, 8)} reused draft in ${(performance.now() - tStart).toFixed(1)}ms`, ); return existingDraft; } const workingDir = await resolveSessionCwd(project); const session = await sessionStore.createSession({ title, projectId: project?.id, agentId, providerId: sessionModelPreference.providerId, workingDir, modelId: sessionModelPreference.modelId, modelName: sessionModelPreference.modelName, }); sessionStore.setActiveSession(session.id); setActiveView("chat"); chatStore.setActiveSession(session.id); perfLog( `[perf:newtab] ${session.id.slice(0, 8)} created session in ${(performance.now() - tStart).toFixed(1)}ms`, ); return session; }, [ agentStore.activeAgentId, agentStore.selectedProvider, chatStore, providerInventoryEntries, sessionStore, ], ); const handleStartChatFromProject = useCallback( (project: ProjectInfo) => { void createNewTab(DEFAULT_CHAT_TITLE, project); }, [createNewTab], ); const handleNewChatInProject = useCallback( (projectId: string) => { const project = projectStore.projects.find((p) => p.id === projectId); if (project) { void createNewTab(DEFAULT_CHAT_TITLE, project); } }, [createNewTab, projectStore.projects], ); const handleArchiveProject = useCallback( async (projectId: string) => { try { await archiveProject(projectId); projectStore.fetchProjects(); } catch { // best-effort } }, [projectStore.fetchProjects], ); const clearActiveSession = useCallback( (sessionId: string) => { chatStore.cleanupSession(sessionId); sessionStore.setActiveSession(null); setActiveView("home"); }, [chatStore, sessionStore], ); const openSettings = useCallback((section: SectionId = "appearance") => { setSettingsInitialSection(section); setSettingsOpen(true); }, []); const handleArchiveChat = useCallback( async (sessionId: string) => { const { activeSessionId: currentActiveSessionId } = useChatSessionStore.getState(); const wasActiveSession = currentActiveSessionId === sessionId; try { await sessionStore.archiveSession(sessionId); chatStore.cleanupSession(sessionId); if (!wasActiveSession) { return; } sessionStore.setActiveSession(null); setActiveView("home"); } catch { // best-effort } }, [chatStore, sessionStore], ); const handleEditProject = useCallback( (projectId: string) => { const project = projectStore.projects.find((p) => p.id === projectId); if (project) { setEditingProject(project); setCreateProjectOpen(true); } }, [projectStore.projects], ); const handleMoveToProject = useCallback( (sessionId: string, projectId: string | null) => { sessionStore.updateSession(sessionId, { projectId }); const session = useChatSessionStore.getState().getSession(sessionId); if (!session) { return; } void (async () => { const nextProject = projectId == null ? null : (useProjectStore .getState() .projects.find((project) => project.id === projectId) ?? null); const workingDir = await resolveSessionCwd(nextProject); if (!workingDir) { return; } await acpPrepareSession( sessionId, session.providerId ?? agentStore.selectedProvider ?? "goose", workingDir, { personaId: session.personaId, }, ); })().catch((error) => { console.error( "Failed to update ACP session project working directory:", error, ); }); }, [agentStore.selectedProvider, sessionStore], ); const handleRenameChat = useCallback( (sessionId: string, nextTitle: string) => { sessionStore.updateSession(sessionId, { title: nextTitle, userSetName: true, }); }, [sessionStore], ); const openCreateProjectDialog = useCallback( (options?: { initialWorkingDir?: string | null; onCreated?: (projectId: string) => void; }) => { setEditingProject(null); setCreateProjectInitialWorkingDir(options?.initialWorkingDir ?? null); pendingProjectCreatedRef.current = options?.onCreated ?? null; setCreateProjectOpen(true); }, [], ); const activateHomeSession = useCallback( (sessionId: string) => { if (homeSessionId === sessionId) { setHomeSessionId(null); } sessionStore.setActiveSession(sessionId); setActiveView("chat"); chatStore.setActiveSession(sessionId); useChatStore.getState().markSessionRead(sessionId); }, [chatStore, homeSessionId, sessionStore], ); const handleSelectSession = useCallback( (id: string) => { sessionStore.setActiveSession(id); setActiveView("chat"); chatStore.setActiveSession(id); useChatStore.getState().markSessionRead(id); loadSessionMessages(id); }, [sessionStore, chatStore, loadSessionMessages], ); const handleSelectSearchResult = useCallback( (sessionId: string, messageId?: string, query?: string) => { if (messageId) { useChatStore .getState() .setScrollTargetMessage(sessionId, messageId, query); } handleSelectSession(sessionId); }, [handleSelectSession], ); const handleNavigate = useCallback( (view: AppView) => { if (view !== "chat") { sessionStore.setActiveSession(null); } setActiveView(view); }, [sessionStore], ); const handleCreatePersona = useCreatePersonaNavigation(() => handleNavigate("agents"), ); const toggleSidebar = () => setSidebarCollapsed((prev) => !prev); const handleResizeStart = useCallback( (e: React.MouseEvent) => { e.preventDefault(); setIsResizing(true); const startX = e.clientX; const startWidth = sidebarCollapsed ? SIDEBAR_COLLAPSED_WIDTH : sidebarWidth; let shouldCollapse = false; const onMouseMove = (moveEvent: MouseEvent) => { const delta = moveEvent.clientX - startX; const newWidth = startWidth + delta; if (newWidth < SIDEBAR_SNAP_COLLAPSE_THRESHOLD) { shouldCollapse = true; setSidebarWidth(SIDEBAR_MIN_WIDTH); } else { shouldCollapse = false; setSidebarCollapsed(false); setSidebarWidth( Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_MIN_WIDTH, newWidth)), ); } }; const cleanup = () => { setIsResizing(false); if (shouldCollapse) setSidebarCollapsed(true); document.removeEventListener("mousemove", onMouseMove); document.removeEventListener("mouseup", cleanup); window.removeEventListener("blur", cleanup); document.body.style.cursor = ""; document.body.style.userSelect = ""; }; document.body.style.cursor = "col-resize"; document.body.style.userSelect = "none"; document.addEventListener("mousemove", onMouseMove); document.addEventListener("mouseup", cleanup); window.addEventListener("blur", cleanup); }, [sidebarCollapsed, sidebarWidth], ); const handleResizeDoubleClick = useCallback(() => { setSidebarCollapsed(false); setSidebarWidth(SIDEBAR_DEFAULT_WIDTH); }, []); useEffect(() => { const handler = (e: KeyboardEvent) => { // Cmd+, for settings if (e.key === "," && e.metaKey) { e.preventDefault(); setSettingsOpen((prev) => !prev); } // Cmd+B for sidebar toggle if (e.key === "b" && e.metaKey) { e.preventDefault(); setSidebarCollapsed((prev) => !prev); } // Cmd+W returns to home instead of closing the window if (e.key === "w" && e.metaKey) { e.preventDefault(); const { activeSessionId } = useChatSessionStore.getState(); if (activeSessionId) { clearActiveSession(activeSessionId); } } // Cmd+N opens new conversation screen if (e.key === "n" && e.metaKey) { e.preventDefault(); sessionStore.setActiveSession(null); setActiveView("home"); } }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, [clearActiveSession, sessionStore]); const editingProjectProp = useMemo( () => editingProject ? { id: editingProject.id, name: editingProject.name, description: editingProject.description, prompt: editingProject.prompt, icon: editingProject.icon, color: editingProject.color, preferredProvider: editingProject.preferredProvider, preferredModel: editingProject.preferredModel, workingDirs: editingProject.workingDirs, useWorktrees: editingProject.useWorktrees, } : undefined, [editingProject], ); return (
openSettings()} />
{ sessionStore.setActiveSession(null); setActiveView("home"); }} onCreateProject={() => openCreateProjectDialog()} onEditProject={handleEditProject} onArchiveProject={handleArchiveProject} onArchiveChat={handleArchiveChat} onRenameChat={handleRenameChat} onMoveToProject={handleMoveToProject} onReorderProject={projectStore.reorderProjects} onSelectSession={handleSelectSession} onSelectSearchResult={handleSelectSearchResult} activeView={activeView} activeSessionId={activeSessionId} projects={projectStore.projects} className="h-full rounded-xl" />
{/* biome-ignore lint/a11y/noStaticElementInteractions: drag handle for sidebar resize */}
{children ?? ( )}
{settingsOpen && ( setSettingsOpen(false)} /> )} { setCreateProjectOpen(false); setEditingProject(null); setCreateProjectInitialWorkingDir(null); pendingProjectCreatedRef.current = null; }} onCreated={(project) => { projectStore.fetchProjects(); pendingProjectCreatedRef.current?.(project.id); pendingProjectCreatedRef.current = null; setCreateProjectInitialWorkingDir(null); }} initialWorkingDir={createProjectInitialWorkingDir} editingProject={editingProjectProp} />
); }