"use client"; import { useState, useCallback, useRef, useEffect, useLayoutEffect } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { useGlobalKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts"; import { SessionSidebar } from "./SessionSidebar"; import { ChatWindow } from "./ChatWindow"; import { FileViewer } from "./FileViewer"; import { TabBar, type Tab } from "./TabBar"; import { openFileTab, saveFileViewerState } from "./file-tab-state"; import { ModelsConfig } from "./ModelsConfig"; import { SkillsConfig } from "./SkillsConfig"; import { MemoryDashboard } from "./MemoryDashboard"; import { HarnessDashboard } from "./HarnessDashboard"; import { PluginsConfig } from "./PluginsConfig"; import { ProjectTrustDialog } from "./ProjectTrustDialog"; import { BranchNavigator } from "./BranchNavigator"; import { useTheme } from "@/hooks/useTheme"; import { useI18n } from "@/hooks/useI18n"; import { useIsMobile } from "@/hooks/useIsMobile"; import { useViewportHeight } from "@/hooks/useViewportHeight"; import { useResizablePanel } from "@/hooks/useResizablePanel"; import { useAudio } from "@/hooks/useAudio"; import { copyText } from "@/lib/clipboard"; import { getFileName } from "@/lib/file-paths"; import { buildAtMentionText, buildFileAtMentionsText, buildFileLineMentionText } from "@/lib/file-fuzzy"; import { claimExtensionAttentionNotification, shouldShowBrowserNotification, showBrowserNotification, } from "@/lib/browser-notifications"; import { getInitialNavigation } from "@/lib/initial-navigation"; import { clearLastOpen, getLastOpenSession, setLastOpenSession } from "@/lib/workspace-memory"; import { getDefaultRightPanelWidth, getRightPanelMaxWidth, getSidebarMaxWidth, RIGHT_PANEL_FALLBACK_WIDTH, RIGHT_PANEL_MAX_WIDTH, RIGHT_PANEL_MIN_WIDTH, SIDEBAR_DEFAULT_WIDTH, SIDEBAR_MAX_WIDTH, SIDEBAR_MIN_WIDTH, } from "@/lib/panel-layout"; import type { BlockingExtensionUiRequest, SessionInfo, SessionTreeNode } from "@/lib/types"; import type { ProjectTrustStatus } from "@/lib/api-types"; import type { ChatInputHandle } from "./ChatInput"; import type { SessionStatsInfo } from "@/lib/pi-types"; import type { FileViewerState } from "@/lib/file-viewer-state"; type SessionCopyField = "file" | "id"; type AutoNameStatus = | { kind: "idle" } | { kind: "naming" } | { kind: "success" } | { kind: "error"; message: string }; const TOP_BAR_ICON_BUTTON_SIZE = 36; const LANGUAGE_MENU_WIDTH = 176; export function AppShell() { const router = useRouter(); const searchParams = useSearchParams(); const [initialNavigation] = useState(() => getInitialNavigation(searchParams)); const { preference, toggleTheme } = useTheme(); const themeLabelKey = preference === "light" ? "theme.light" : preference === "dark" ? "theme.dark" : "theme.auto"; const { locale, setLocale, t: translate, supportedLocales } = useI18n(); const isMobile = useIsMobile(); useViewportHeight(); // Audio ownership lives here (not in ChatWindow) so the completion tone can // also fire for tasks finishing in a non-active workspace whose ChatWindow // is not mounted. ChatWindow receives the audio callbacks as props. const { soundEnabled, onSoundToggle, playDoneSound, unlockAudio, soundEnabledRef } = useAudio(); const notifiedAttentionRequestIdsRef = useRef(new Set()); const handleBackgroundTaskDone = useCallback(() => { if (soundEnabledRef.current) playDoneSound(); }, [playDoneSound, soundEnabledRef]); const [selectedSession, setSelectedSession] = useState(null); const [runningSessionIds, setRunningSessionIds] = useState>(() => new Set()); const handleRunningSessionIdsChange = useCallback((ids: Set) => { setRunningSessionIds((previous) => { if (previous.size === ids.size && [...ids].every((id) => previous.has(id))) return previous; return ids; }); }, []); // The temporary id distinguishes consecutive fresh composers in one cwd. const [newSessionCwd, setNewSessionCwd] = useState(null); const [newSessionDraftId, setNewSessionDraftId] = useState("initial"); const activeNewSessionDraftKeyRef = useRef(null); const [initialCwdStatus, setInitialCwdStatus] = useState<"idle" | "validating" | "ready" | "error">( () => initialNavigation.requestedCwd ? "validating" : "idle", ); const [initialCwdError, setInitialCwdError] = useState(null); const [refreshKey, setRefreshKey] = useState(0); const [sessionKey, setSessionKey] = useState(0); const [explorerRefreshKey, setExplorerRefreshKey] = useState(0); const [modelsConfigOpen, setModelsConfigOpen] = useState(false); const [modelsRefreshKey, setModelsRefreshKey] = useState(0); const [skillsConfigOpen, setSkillsConfigOpen] = useState(false); const [pluginsConfigOpen, setPluginsConfigOpen] = useState(false); const [memoryDashboardOpen, setMemoryDashboardOpen] = useState(false); const [harnessDashboardOpen, setHarnessDashboardOpen] = useState(false); const [projectTrust, setProjectTrust] = useState(null); const [projectTrustDialogOpen, setProjectTrustDialogOpen] = useState(false); const [projectTrustBusy, setProjectTrustBusy] = useState(false); const [projectTrustError, setProjectTrustError] = useState(null); const [sidebarOpen, setSidebarOpen] = useState(true); const [rightPanelOpen, setRightPanelOpen] = useState(false); const [mobileToolbarMoreOpen, setMobileToolbarMoreOpen] = useState(false); const [mobileSidebarReady, setMobileSidebarReady] = useState(false); const sidebarWidthRef = useRef(SIDEBAR_DEFAULT_WIDTH); const rightPanelWidthRef = useRef(RIGHT_PANEL_FALLBACK_WIDTH); const getResponsiveRightPanelWidth = useCallback( () => typeof window === "undefined" ? RIGHT_PANEL_FALLBACK_WIDTH : getDefaultRightPanelWidth(window.innerWidth), [], ); const getResponsiveSidebarMaxWidth = useCallback( () => typeof window === "undefined" ? SIDEBAR_MAX_WIDTH : getSidebarMaxWidth({ viewportWidth: window.innerWidth, rightPanelOpen, rightPanelWidth: rightPanelWidthRef.current, }), [rightPanelOpen], ); const getResponsiveRightPanelMaxWidth = useCallback( () => typeof window === "undefined" ? RIGHT_PANEL_MAX_WIDTH : getRightPanelMaxWidth({ viewportWidth: window.innerWidth, sidebarOpen, sidebarWidth: sidebarWidthRef.current, }), [sidebarOpen], ); const sidebarResizer = useResizablePanel({ ariaLabel: translate("layout.resizeSidebar"), cssVariable: "--sidebar-width", defaultWidth: SIDEBAR_DEFAULT_WIDTH, getMaxWidth: getResponsiveSidebarMaxWidth, growthDirection: "right", maxWidth: SIDEBAR_MAX_WIDTH, minWidth: SIDEBAR_MIN_WIDTH, storageKey: "pi-sidebar-width", widthRef: sidebarWidthRef, }); const rightPanelResizer = useResizablePanel({ ariaLabel: translate("layout.resizeFilePanel"), cssVariable: "--right-panel-width", defaultWidth: RIGHT_PANEL_FALLBACK_WIDTH, getDefaultWidth: getResponsiveRightPanelWidth, getMaxWidth: getResponsiveRightPanelMaxWidth, growthDirection: "left", maxWidth: RIGHT_PANEL_MAX_WIDTH, minWidth: RIGHT_PANEL_MIN_WIDTH, storageKey: "pi-right-panel-width", widthRef: rightPanelWidthRef, }); const reclampSidebarWidth = sidebarResizer.reclampWidth; const reclampRightPanelWidth = rightPanelResizer.reclampWidth; // On mobile the sidebar is an overlay drawer; hide it by default so the chat // is visible on load. Runs once the breakpoint resolves after hydration. useEffect(() => { if (isMobile) setSidebarOpen(false); }, [isMobile]); useEffect(() => { setMobileSidebarReady(true); }, []); useEffect(() => { if (!rightPanelOpen) return; reclampSidebarWidth(); reclampRightPanelWidth(); }, [reclampRightPanelWidth, reclampSidebarWidth, rightPanelOpen]); const chatInputRef = useRef(null); const topBarRef = useRef(null); const mobileToolbarRef = useRef(null); const languageBtnRef = useRef(null); // Branch navigator state — populated by ChatWindow via onBranchDataChange const [branchTree, setBranchTree] = useState([]); const [branchActiveLeafId, setBranchActiveLeafId] = useState(null); const branchLeafChangeFnRef = useRef<((leafId: string | null) => void) | null>(null); const handleBranchDataChange = useCallback((tree: SessionTreeNode[], activeLeafId: string | null, onLeafChange: (leafId: string | null) => void) => { setBranchTree(tree); setBranchActiveLeafId(activeLeafId); branchLeafChangeFnRef.current = onLeafChange; }, []); const handleBranchLeafChange = useCallback((leafId: string | null) => { branchLeafChangeFnRef.current?.(leafId); }, []); const [systemPrompt, setSystemPrompt] = useState(null); const [systemPromptLoading, setSystemPromptLoading] = useState(false); const systemPromptLoaderRef = useRef<(() => Promise) | null>(null); const systemPromptLoadIdRef = useRef(0); const systemBtnRef = useRef(null); const handleSystemPromptChange = useCallback((prompt: string | null) => { setSystemPrompt(prompt); setSystemPromptLoading(false); }, []); const handleSystemPromptLoaderChange = useCallback((loader: (() => Promise) | null) => { systemPromptLoadIdRef.current += 1; systemPromptLoaderRef.current = loader; setSystemPromptLoading(false); }, []); // Session stats (tokens + cost) — populated by ChatWindow, displayed in top bar const [sessionStats, setSessionStats] = useState(null); const [autoNameStatus, setAutoNameStatus] = useState({ kind: "idle" }); const autoNameTimerRef = useRef | null>(null); const activeSessionIdRef = useRef(selectedSession?.id ?? null); activeSessionIdRef.current = selectedSession?.id ?? null; const handleSessionStatsChange = useCallback((stats: SessionStatsInfo | null) => { setSessionStats(stats); }, []); const [copiedSessionField, setCopiedSessionField] = useState(null); const sessionCopyTimerRef = useRef | null>(null); const handleCopySessionField = useCallback((field: SessionCopyField, value: string) => { void copyText(value).then(() => { if (sessionCopyTimerRef.current) clearTimeout(sessionCopyTimerRef.current); setCopiedSessionField(field); sessionCopyTimerRef.current = setTimeout(() => setCopiedSessionField(null), 1400); }); }, []); useEffect(() => { return () => { if (sessionCopyTimerRef.current) clearTimeout(sessionCopyTimerRef.current); if (autoNameTimerRef.current) clearTimeout(autoNameTimerRef.current); }; }, []); // Context usage — populated by ChatWindow, displayed in top bar const [contextUsage, setContextUsage] = useState<{ percent: number | null; contextWindow: number; tokens: number | null } | null>(null); const handleContextUsageChange = useCallback((usage: { percent: number | null; contextWindow: number; tokens: number | null } | null) => { setContextUsage(usage); }, []); // Single active panel — only one dropdown open at a time const [activeTopPanel, setActiveTopPanel] = useState<"branches" | "system" | "session" | "language" | null>(null); const [topPanelPos, setTopPanelPos] = useState<{ top: number; left: number; width: number } | null>(null); const toggleTopPanel = useCallback(( panel: "branches" | "system" | "session" | "language", keepMobileToolbarOpen = false, ) => { if (isMobile) setSidebarOpen(false); setActiveTopPanel((cur) => cur === panel ? null : panel); if (isMobile && keepMobileToolbarOpen) setMobileToolbarMoreOpen(true); }, [isMobile]); const handleSystemPromptToggle = useCallback((keepMobileToolbarOpen = false) => { const opening = activeTopPanel !== "system"; toggleTopPanel("system", keepMobileToolbarOpen); if (!opening || systemPromptLoading) return; const load = systemPromptLoaderRef.current; if (!load) return; const loadId = ++systemPromptLoadIdRef.current; setSystemPromptLoading(true); void load().catch((error) => { console.error("Failed to load system prompt:", error); }).finally(() => { if (systemPromptLoadIdRef.current === loadId) { setSystemPromptLoading(false); } }); }, [activeTopPanel, systemPromptLoading, toggleTopPanel]); const openSessionStatsPanel = useCallback(() => { if (isMobile) setSidebarOpen(false); setMobileToolbarMoreOpen(false); setActiveTopPanel("session"); }, [isMobile]); const handleSidebarToggle = useCallback(() => { if (isMobile) { setActiveTopPanel(null); setMobileToolbarMoreOpen(false); } setSidebarOpen((open) => !open); }, [isMobile]); const handleMobileToolbarMoreToggle = useCallback(() => { setSidebarOpen(false); setActiveTopPanel(null); setMobileToolbarMoreOpen((open) => !open); }, []); const handleRightPanelToggle = useCallback(() => { if (isMobile) { setSidebarOpen(false); setActiveTopPanel(null); setMobileToolbarMoreOpen(false); } setRightPanelOpen((open) => !open); }, [isMobile]); useEffect(() => { if (!mobileToolbarMoreOpen) return; const handlePointerDown = (event: PointerEvent) => { const toolbar = mobileToolbarRef.current; if (toolbar && event.composedPath().includes(toolbar)) return; setMobileToolbarMoreOpen(false); }; const handleKeyDown = (event: KeyboardEvent) => { if (event.key !== "Escape") return; event.preventDefault(); event.stopPropagation(); setMobileToolbarMoreOpen(false); }; document.addEventListener("pointerdown", handlePointerDown, true); document.addEventListener("keydown", handleKeyDown, true); return () => { document.removeEventListener("pointerdown", handlePointerDown, true); document.removeEventListener("keydown", handleKeyDown, true); }; }, [mobileToolbarMoreOpen]); useEffect(() => { setMobileToolbarMoreOpen(false); }, [isMobile, selectedSession?.id, newSessionDraftId]); useEffect(() => { if (!activeTopPanel || !topBarRef.current) return; const update = () => { const topBarRect = topBarRef.current!.getBoundingClientRect(); if (activeTopPanel === "language" && !isMobile && languageBtnRef.current) { const buttonRect = languageBtnRef.current.getBoundingClientRect(); const width = Math.min(LANGUAGE_MENU_WIDTH, topBarRect.width); const left = Math.min( buttonRect.left - 1, Math.max(topBarRect.left, topBarRect.right - width), ); setTopPanelPos({ top: topBarRect.bottom, left, width }); return; } setTopPanelPos({ top: topBarRect.bottom, left: topBarRect.left, width: topBarRect.width }); }; update(); const ro = new ResizeObserver(update); ro.observe(topBarRef.current); if (languageBtnRef.current) ro.observe(languageBtnRef.current); return () => ro.disconnect(); }, [activeTopPanel, isMobile]); // Right panel — file tabs only const [fileTabs, setFileTabs] = useState([]); const [activeFileTabId, setActiveFileTabId] = useState(null); const handleFileViewerStateChange = useCallback(( tabId: string, viewerRevision: number, viewerState: FileViewerState, ) => { setFileTabs((prev) => saveFileViewerState(prev, tabId, viewerRevision, viewerState)); }, []); // Same @mention format as the chat input's @ autocomplete, so the agent's // read tool resolves it the same way (it strips the @ prefix). const handleAtMention = useCallback((relativePath: string, isDir: boolean) => { chatInputRef.current?.insertText(buildAtMentionText(relativePath, isDir)); if (isMobile) { setRightPanelOpen(false); setSidebarOpen(false); } }, [isMobile]); const handleAtMentions = useCallback((relativePaths: string[]) => { const mentions = buildFileAtMentionsText(relativePaths); if (mentions) chatInputRef.current?.insertText(mentions); if (isMobile) { setRightPanelOpen(false); setSidebarOpen(false); } }, [isMobile]); const handleFileLineMention = useCallback((relativePath: string, startLine: number, endLine: number) => { chatInputRef.current?.insertText(buildFileLineMentionText(relativePath, startLine, endLine)); if (isMobile) { setRightPanelOpen(false); setSidebarOpen(false); } }, [isMobile]); const initialSessionId = initialNavigation.sessionId; const [activeCwd, setActiveCwd] = useState(null); const activeProjectRootRef = useRef(null); // True once the initial ?session= URL param has been resolved (or confirmed absent) const [initialSessionRestored, setInitialSessionRestored] = useState(() => !initialSessionId); // Suppresses sessionKey bump in handleCwdChange during the initial URL restore const suppressCwdBumpRef = useRef(false); // Guards the async workspace restore so a slow response from an earlier // switch cannot resurrect a session into a project the user already left. const workspaceRestoreTokenRef = useRef(0); const invalidateWorkspaceRestore = useCallback(() => { workspaceRestoreTokenRef.current += 1; }, []); // Persist every active-session transition, including new and forked sessions // that bypass the sidebar selection handler. Transient sessions do not yet // carry projectRoot, so use the active project identity until hydration. useEffect(() => { if (!selectedSession) return; const projectKey = selectedSession.projectRoot ?? activeProjectRootRef.current ?? selectedSession.cwd; setLastOpenSession(projectKey, selectedSession.id); }, [selectedSession]); useEffect(() => { const requestedCwd = initialNavigation.requestedCwd; if (!requestedCwd) return; const controller = new AbortController(); setInitialCwdStatus("validating"); setInitialCwdError(null); void fetch("/api/cwd/validate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ cwd: requestedCwd }), signal: controller.signal, }) .then(async (response) => { const data = await response.json().catch(() => ({})) as { cwd?: string; error?: string }; if (!response.ok || !data.cwd) { throw new Error(data.error ?? `HTTP ${response.status}`); } // The sidebar will notify us when it adopts this cwd. Avoid remounting // the just-created empty chat during that initial synchronization. suppressCwdBumpRef.current = true; const draftId = `initial:${requestedCwd}`; setNewSessionDraftId(draftId); activeNewSessionDraftKeyRef.current = `new:${draftId}:${data.cwd}`; setNewSessionCwd(data.cwd); setInitialCwdStatus("ready"); }) .catch((error: unknown) => { if (controller.signal.aborted) return; setInitialCwdError(error instanceof Error ? error.message : String(error)); setInitialCwdStatus("error"); }); return () => controller.abort(); }, [initialNavigation]); // Restore the workspace's last open session after switching to it. Called // from handleCwdChange once the outgoing context has been reset. The session // is looked up against the live list so a deleted or drifted session falls // back to the default welcome page instead of erroring. const restoreWorkspaceContext = useCallback((projectKey: string) => { const token = ++workspaceRestoreTokenRef.current; const lastOpenSessionId = getLastOpenSession(projectKey); if (!lastOpenSessionId) return; void fetch("/api/sessions") .then((r) => (r.ok ? (r.json() as Promise<{ sessions: SessionInfo[] }>) : null)) .then((d) => { if (token !== workspaceRestoreTokenRef.current) return; // stale switch const s = d?.sessions.find((x) => x.id === lastOpenSessionId); if (!s) { // The list loaded but the remembered session is gone — forget it. // When the list itself failed (d === null) keep the memory so a // later switch retries the restore. if (d) clearLastOpen(projectKey); return; } if ((s.projectRoot ?? s.cwd) !== projectKey) { // Defensive: the remembered session drifted out of this workspace. clearLastOpen(projectKey); return; } // Selecting the session must remount the chat with the session // present: useAgentSession loads content in a mount-only effect, so // the null-session welcome mount from the switch would never load // the restored session's messages. setSelectedSession(s); setSessionKey((k) => k + 1); if (new URLSearchParams(window.location.search).get("session") !== s.id) { router.replace(`?session=${encodeURIComponent(s.id)}`, { scroll: false }); } }) .catch(() => { // Network hiccup: keep the remembered session for a later retry. }); }, [router]); const handleCwdChange = useCallback((cwd: string | null, projectRoot?: string | null) => { invalidateWorkspaceRestore(); const currentFreshCwd = newSessionCwd ?? activeCwd; setActiveCwd(cwd); // Skip if cwd is null (initial mount). if (!cwd) return; const newProject = projectRoot ?? cwd; const currentProject = activeProjectRootRef.current ?? (selectedSession ? (selectedSession.projectRoot ?? selectedSession.cwd) : null); activeProjectRootRef.current = newProject; // Keep the project identity in sync during the initial URL restore without // remounting the just-created or restored chat. if (suppressCwdBumpRef.current) { suppressCwdBumpRef.current = false; return; } // Existing sessions stay open when the worktree selector moves within the // same project. A fresh composer must remount when its effective cwd moves, // otherwise its already-created runtime would keep sending to the old cwd. if ( currentProject === newProject && (selectedSession !== null || currentFreshCwd === cwd) ) { return; } // Close any session that belongs to a different project — it no longer // matches the selected project directory. const draftId = typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; setNewSessionDraftId(draftId); activeNewSessionDraftKeyRef.current = `new:${draftId}:${cwd}`; setSelectedSession(null); setNewSessionCwd((prev) => { if (prev && prev !== cwd) return null; return prev; }); setSessionKey((k) => k + 1); setBranchTree([]); setBranchActiveLeafId(null); setSystemPrompt(null); setSystemPromptLoading(false); setActiveTopPanel(null); if (currentProject !== newProject) { // File tabs are keyed by absolute path, so tabs opened in the previous // project must not linger. Same-project worktree switches keep them. setFileTabs([]); setActiveFileTabId(null); setRightPanelOpen(false); // Restore the workspace we switched to: its last open session, or keep // the default welcome page when none is remembered. restoreWorkspaceContext(newProject); } router.replace("/", { scroll: false }); }, [activeCwd, invalidateWorkspaceRestore, newSessionCwd, router, selectedSession, restoreWorkspaceContext]); const handleSelectSession = useCallback((session: SessionInfo, isRestore = false) => { invalidateWorkspaceRestore(); activeNewSessionDraftKeyRef.current = null; // Re-clicking the already-open session must not remount the chat and // re-run the full load/positioning cycle. Only skip when the effective // cwd context already matches — otherwise a pending cwd move still needs // the full re-select flow. if (!isRestore && selectedSession) { const sameProject = (selectedSession.projectRoot ?? selectedSession.cwd) === (session.projectRoot ?? session.cwd); if (selectedSession.id === session.id && sameProject) { if (isMobile) setSidebarOpen(false); return; } } setNewSessionCwd(null); setSelectedSession(session); setSessionKey((k) => k + 1); setSystemPrompt(null); setSystemPromptLoading(false); setInitialSessionRestored(true); // On mobile, collapse the overlay drawer so the chat is revealed after pick. if (isMobile && !isRestore) setSidebarOpen(false); if (isRestore) { // Suppress the redundant sessionKey bump that would come from the // onCwdChange effect firing after setSelectedCwd in the sidebar suppressCwdBumpRef.current = true; } // Skip router.replace when restoring from URL — the param is already correct // and calling replace in production Next.js triggers a Suspense remount loop if (!isRestore) { router.replace(`?session=${encodeURIComponent(session.id)}`, { scroll: false }); } }, [invalidateWorkspaceRestore, router, isMobile, selectedSession]); const handleNewSession = useCallback((sessionId: string, cwd: string) => { invalidateWorkspaceRestore(); const draftKey = `new:${sessionId}:${cwd}`; activeNewSessionDraftKeyRef.current = draftKey; setNewSessionDraftId(sessionId); setSelectedSession(null); setNewSessionCwd(cwd); setSessionKey((k) => k + 1); setBranchTree([]); setBranchActiveLeafId(null); setSystemPrompt(null); setSystemPromptLoading(false); setActiveTopPanel(null); if (isMobile) setSidebarOpen(false); router.replace("/", { scroll: false }); }, [invalidateWorkspaceRestore, router, isMobile]); // Global keyboard shortcuts (handles Esc, Ctrl+Alt+N etc.) useGlobalKeyboardShortcuts({ onNewSession: (cwd: string) => handleNewSession(`kb-${Date.now()}`, cwd), activeCwd, }); // Client-built transient SessionInfo (new session / fork) lacks the // server-computed projectRoot, which the same-project check in // handleCwdChange relies on. Hydrate it from the session list so switching // worktrees right after creating a session doesn't close the chat. const hydrateSelectedSession = useCallback((sessionId: string) => { void fetch("/api/sessions", { cache: "no-store" }) .then((r) => (r.ok ? (r.json() as Promise<{ sessions: SessionInfo[] }>) : null)) .then((d) => { const full = d?.sessions.find((s) => s.id === sessionId); if (!full) return; setSelectedSession((prev) => ( prev?.id === sessionId ? { ...prev, ...full, transient: full.transient ?? false } : prev )); }) .catch(() => {}); }, []); // Called by ChatWindow when a new session gets its real id from pi const handleSessionCreated = useCallback((session: SessionInfo, sourceDraftKey: string) => { setRefreshKey((k) => k + 1); if (activeNewSessionDraftKeyRef.current !== sourceDraftKey) return; invalidateWorkspaceRestore(); activeNewSessionDraftKeyRef.current = null; setNewSessionCwd(null); setSelectedSession(session); hydrateSelectedSession(session.id); router.replace(`?session=${encodeURIComponent(session.id)}`, { scroll: false }); }, [invalidateWorkspaceRestore, router, hydrateSelectedSession]); const deliverSessionNotification = useCallback(({ targetSession, title, body, tag, }: { targetSession: SessionInfo | null; title: string; body: string; tag?: string; }) => { if (!("Notification" in window)) return; const fire = () => { const sessionUrl = targetSession ? `/?session=${encodeURIComponent(targetSession.id)}` : "/"; void showBrowserNotification({ title, body, sessionUrl, tag, onClick: () => { window.focus(); if (targetSession) handleSelectSession(targetSession); }, }); }; if (Notification.permission === "granted") { fire(); } else if (Notification.permission === "default") { void Notification.requestPermission().then((p) => { if (p === "granted") fire(); }); } }, [handleSelectSession]); const handleAgentEnd = useCallback(() => { setRefreshKey((k) => k + 1); setExplorerRefreshKey((k) => k + 1); if (selectedSession) hydrateSelectedSession(selectedSession.id); if (!shouldShowBrowserNotification()) return; const targetSession = selectedSession; deliverSessionNotification({ targetSession, title: targetSession?.name ?? translate("i18n.sessionComplete"), body: translate("i18n.taskFinished"), }); }, [deliverSessionNotification, hydrateSelectedSession, selectedSession, translate]); const handleAttentionNeeded = useCallback((request: BlockingExtensionUiRequest) => { if (!shouldShowBrowserNotification()) return; if (!claimExtensionAttentionNotification(request, notifiedAttentionRequestIdsRef.current)) return; deliverSessionNotification({ targetSession: selectedSession, title: translate("i18n.attentionNeeded"), body: request.method === "custom" ? translate("i18n.extensionInputNeeded") : request.title, tag: `pi-extension-ui:${request.id}`, }); }, [deliverSessionNotification, selectedSession, translate]); const handleAutoName = useCallback(async () => { const sessionId = selectedSession?.id; if (!sessionId || autoNameStatus.kind === "naming") return; if (autoNameTimerRef.current) clearTimeout(autoNameTimerRef.current); setActiveTopPanel(null); setAutoNameStatus({ kind: "naming" }); try { const response = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/auto-name`, { method: "POST", }); const body = (await response.json().catch(() => ({}))) as { title?: string; error?: string }; if (!response.ok || !body.title) { throw new Error(body.error || `HTTP ${response.status}`); } const title = body.title.trim(); setRefreshKey((key) => key + 1); if (activeSessionIdRef.current !== sessionId) return; setSelectedSession((current) => current?.id === sessionId ? { ...current, name: title } : current); setSessionStats((current) => current?.sessionId === sessionId ? { ...current, sessionName: title } : current); setAutoNameStatus({ kind: "success" }); autoNameTimerRef.current = setTimeout(() => setAutoNameStatus({ kind: "idle" }), 1800); } catch (error) { if (activeSessionIdRef.current !== sessionId) return; const message = error instanceof Error ? error.message : String(error); setAutoNameStatus({ kind: "error", message }); autoNameTimerRef.current = setTimeout(() => setAutoNameStatus({ kind: "idle" }), 5000); } }, [autoNameStatus.kind, selectedSession?.id]); useEffect(() => { if (autoNameTimerRef.current) clearTimeout(autoNameTimerRef.current); setAutoNameStatus({ kind: "idle" }); }, [selectedSession?.id]); const handleExplorerRefresh = useCallback(() => { setExplorerRefreshKey((k) => k + 1); }, []); const handleSessionForked = useCallback((newSessionId: string) => { invalidateWorkspaceRestore(); activeNewSessionDraftKeyRef.current = null; setRefreshKey((k) => k + 1); setSessionKey((k) => k + 1); setNewSessionCwd(null); setSelectedSession((prev) => ({ ...(prev ?? { path: "", cwd: "", created: "", modified: "", messageCount: 0, firstMessage: "" }), id: newSessionId, transient: false, })); hydrateSelectedSession(newSessionId); router.replace(`?session=${encodeURIComponent(newSessionId)}`, { scroll: false }); }, [invalidateWorkspaceRestore, router, hydrateSelectedSession]); const handleInitialRestoreDone = useCallback(() => { setInitialSessionRestored(true); }, []); const handleSessionDeleted = useCallback((sessionId: string) => { invalidateWorkspaceRestore(); setRefreshKey((k) => k + 1); if (selectedSession?.id === sessionId) { const cwd = selectedSession.cwd; const draftId = typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; setNewSessionDraftId(draftId); activeNewSessionDraftKeyRef.current = cwd ? `new:${draftId}:${cwd}` : null; setSelectedSession(null); setNewSessionCwd(cwd ?? null); setSessionKey((k) => k + 1); setBranchTree([]); setBranchActiveLeafId(null); setSystemPrompt(null); setSystemPromptLoading(false); setActiveTopPanel(null); router.replace("/", { scroll: false }); } }, [invalidateWorkspaceRestore, selectedSession, router]); const handleOpenFile = useCallback(( filePath: string, fileName: string, options?: { sourceSessionId?: string | null; modeHint?: "diff" }, ) => { const sourceSessionId = options?.sourceSessionId; const modeHint = options?.modeHint; const tabId = `file:${filePath}`; setFileTabs((prev) => openFileTab(prev, { fileName, filePath, modeHint, sourceSessionId, tabId, })); setActiveFileTabId(tabId); setRightPanelOpen(true); // On mobile the file panel is full-screen; close the drawer so it shows. if (isMobile) setSidebarOpen(false); }, [isMobile]); const handleOpenLinkedFile = useCallback((filePath: string) => { handleOpenFile(filePath, getFileName(filePath), { sourceSessionId: selectedSession?.id ?? null }); }, [handleOpenFile, selectedSession?.id]); const handleCloseFileTab = useCallback((tabId: string) => { setFileTabs((prev) => { const next = prev.filter((t) => t.id !== tabId); if (next.length === 0) setRightPanelOpen(false); return next; }); setActiveFileTabId((cur) => { if (cur !== tabId) return cur; const remaining = fileTabs.filter((t) => t.id !== tabId); return remaining.length > 0 ? remaining[remaining.length - 1].id : null; }); }, [fileTabs]); const handleViewFullHistory = useCallback(() => { if (!selectedSession) return; window.open( `/api/sessions/${encodeURIComponent(selectedSession.id)}/export?inline=1`, "_blank", "noopener,noreferrer", ); }, [selectedSession]); // Show chat area if a session is selected, or if we have a cwd to start a new session in const effectiveNewSessionCwd = newSessionCwd ?? (selectedSession === null && activeCwd ? activeCwd : null); const newSessionDraftKey = selectedSession === null && effectiveNewSessionCwd ? `new:${newSessionDraftId}:${effectiveNewSessionCwd}` : null; useLayoutEffect(() => { activeNewSessionDraftKeyRef.current = newSessionDraftKey; }, [newSessionDraftKey]); const showChat = selectedSession !== null || effectiveNewSessionCwd !== null; const projectTrustCwd = selectedSession?.cwd ?? effectiveNewSessionCwd; // While restoring initial session from URL, don't show the placeholder const showPlaceholder = initialSessionRestored && !showChat; useEffect(() => { setProjectTrust(null); setProjectTrustDialogOpen(false); setProjectTrustError(null); if (!projectTrustCwd) return; const controller = new AbortController(); fetch(`/api/project-trust?cwd=${encodeURIComponent(projectTrustCwd)}`, { signal: controller.signal, }) .then(async (response) => { const data = await response.json() as ProjectTrustStatus & { error?: string }; if (!response.ok || data.error) throw new Error(data.error ?? `HTTP ${response.status}`); setProjectTrust(data); }) .catch((error) => { if (error instanceof DOMException && error.name === "AbortError") return; console.error("Failed to load project trust:", error); }); return () => controller.abort(); }, [projectTrustCwd]); const handleTrustProject = useCallback(async () => { if (!projectTrustCwd || projectTrustBusy) return; setProjectTrustBusy(true); setProjectTrustError(null); try { const response = await fetch("/api/project-trust", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ cwd: projectTrustCwd }), }); const data = await response.json() as ProjectTrustStatus & { error?: string }; if (!response.ok || data.error) throw new Error(data.error ?? `HTTP ${response.status}`); setProjectTrust(data); setProjectTrustDialogOpen(false); setModelsRefreshKey((key) => key + 1); setSessionKey((key) => key + 1); } catch (error) { setProjectTrustError(error instanceof Error ? error.message : String(error)); } finally { setProjectTrustBusy(false); } }, [projectTrustBusy, projectTrustCwd]); const activeFileTab = fileTabs.find((tab) => tab.id === activeFileTabId) ?? null; const activeCwdName = activeCwd ? getFileName(activeCwd) || activeCwd : null; const windowTitle = activeCwdName ? `${activeCwdName} - Pi Web` : "Pi Web"; useEffect(() => { const syncWindowTitle = () => { if (document.title !== windowTitle) document.title = windowTitle; }; syncWindowTitle(); const observer = new MutationObserver(syncWindowTitle); observer.observe(document.head, { childList: true, subtree: true, characterData: true }); return () => observer.disconnect(); }, [windowTitle]); const sidebarContent = ( <>
{([ { label: translate("common.models"), onClick: () => setModelsConfigOpen(true), disabled: false, icon: ( ), }, { label: translate("common.skills"), onClick: () => setSkillsConfigOpen(true), disabled: !activeCwd && !selectedSession?.cwd && !newSessionCwd, icon: ( ), }, { label: translate("common.plugins"), onClick: () => setPluginsConfigOpen(true), disabled: !activeCwd && !selectedSession?.cwd && !newSessionCwd, icon: ( ), }, ] as { label: string; onClick: () => void; disabled: boolean; icon: React.ReactNode }[]).map(({ label, onClick, disabled, icon }) => ( ))}
); const renderThemeButton = (mobile: boolean) => ( ); const renderLanguageButton = (mobile: boolean) => ( ); const renderProjectTrustWarning = (mobileBanner: boolean) => { if (!showChat || !projectTrust?.requiresTrust || projectTrust.trusted) return null; return ( ); }; const renderChatToolbarActions = (mobile: boolean) => { if (!mobile && !showChat) return null; return (
{(() => { // 上下文压缩后当前消息可能不再包含 user 消息,需同时参考会话文件的消息总数。 const hasMessages = Boolean( selectedSession && ((sessionStats?.userMessages ?? 0) > 0 || selectedSession.messageCount > 0), ); const disabled = !selectedSession || selectedSession.transient || !hasMessages || autoNameStatus.kind === "naming"; const isSuccess = autoNameStatus.kind === "success"; const isError = autoNameStatus.kind === "error"; const label = autoNameStatus.kind === "naming" ? translate("title.generating") : isSuccess ? translate("title.updated") : isError ? translate("title.failed") : translate("title.generate"); const title = !selectedSession || selectedSession.transient ? translate("title.unsaved") : !hasMessages ? translate("title.noMessages") : isError ? autoNameStatus.message : translate("title.generateSession"); return ( ); })()} {mobile ? ( ) : ( toggleTopPanel("branches")} hasSession /> )} {mobile && renderThemeButton(true)} {mobile && renderLanguageButton(true)}
); }; const renderSessionStatsButton = (mobile: boolean) => { if (!mobile && (!showChat || (!sessionStats && !contextUsage))) return null; const tokens = sessionStats?.tokens; const cost = sessionStats?.cost ?? 0; const formatCompact = (value: number) => value >= 1_000_000 ? `${(value / 1_000_000).toFixed(1)}M` : value >= 1000 ? `${(value / 1000).toFixed(0)}k` : String(value); const costText = cost > 0 ? (cost >= 0.01 ? `$${cost.toFixed(2)}` : `<$0.01`) : null; let contextColor = "var(--text-muted)"; let desktopContextText: string | null = null; let mobileContextText: string | null = null; if (contextUsage?.contextWindow) { const percent = contextUsage.percent; if (percent !== null && percent > 90) contextColor = "#ef4444"; else if (percent !== null && percent > 70) contextColor = "rgba(234,179,8,0.95)"; desktopContextText = percent !== null ? `${percent.toFixed(0)}% / ${formatCompact(contextUsage.contextWindow)}` : `? / ${formatCompact(contextUsage.contextWindow)}`; mobileContextText = percent !== null ? `${percent.toFixed(0)}%` : null; } const tooltipParts: string[] = []; if (tokens) { tooltipParts.push(`in: ${tokens.input.toLocaleString(locale)}`); tooltipParts.push(`out: ${tokens.output.toLocaleString(locale)}`); tooltipParts.push(`cache read: ${tokens.cacheRead.toLocaleString(locale)}`); tooltipParts.push(`cache write: ${tokens.cacheWrite.toLocaleString(locale)}`); if (cost > 0) tooltipParts.push(`cost: $${cost.toFixed(4)}`); } if (contextUsage?.contextWindow) { const percent = contextUsage.percent; tooltipParts.push(`context: ${percent !== null ? percent.toFixed(1) + "%" : "unknown"} of ${contextUsage.contextWindow.toLocaleString()} tokens`); } const tooltip = tooltipParts.join(" | "); const covered = mobile && mobileToolbarMoreOpen; const hasMobileValues = Boolean( (tokens && (tokens.input > 0 || tokens.output > 0)) || costText || mobileContextText, ); return ( ); }; const renderMainFileToggle = (mobile: boolean) => { const covered = mobile && mobileToolbarMoreOpen; return ( ); }; return ( <>
{/* Mobile overlay backdrop */}
setSidebarOpen(false)} style={{ position: "fixed", inset: 0, zIndex: 199, background: "rgba(0,0,0,0.4)", opacity: sidebarOpen ? 1 : 0, pointerEvents: sidebarOpen ? "auto" : "none", transition: "opacity 0.25s ease", }} /> {/* Left sidebar */}
{sidebarContent}
{sidebarOpen && (
)} {/* Center: chat */}
{/* Top bar with sidebar toggle */}
{isMobile && (
{renderSessionStatsButton(true)} {renderMainFileToggle(true)} {mobileToolbarMoreOpen && ( )}
)} {!isMobile && ( <> {renderThemeButton(false)} {renderLanguageButton(false)} {renderProjectTrustWarning(false)} {renderChatToolbarActions(false)} {renderSessionStatsButton(false)} )} {!isMobile && renderMainFileToggle(false)} {isMobile && ( toggleTopPanel("branches")} hasSession={showChat} hideInlineButton /> )} {/* Top panel dropdown — shared, only one active at a time */} {activeTopPanel && topPanelPos && (
{activeTopPanel === "language" && (
{supportedLocales.map((plugin) => ( ))}
)} {activeTopPanel === "system" && (
{systemPrompt ? (
{systemPrompt}
) : systemPrompt === "" ? (
{translate("system.empty")}
) : (
{systemPromptLoading ? translate("system.loading") : translate("system.load")}
)}
)} {activeTopPanel === "session" && (
{sessionStats ? (() => { const formatDuration = (ms: number) => { if (ms <= 0) return "0s"; const totalSec = Math.floor(ms / 1000); const h = Math.floor(totalSec / 3600); const m = Math.floor((totalSec % 3600) / 60); const s = totalSec % 60; if (h > 0) return `${h}h ${m}m`; if (m > 0) return `${m}m ${s}s`; return `${s}s`; }; const totalActiveMs = sessionStats.totalActiveMs ?? 0; const sessionRows = [ ...(sessionStats.sessionName ? [{ label: translate("session.name"), value: sessionStats.sessionName, copyField: null }] : []), { label: translate("session.file"), value: sessionStats.sessionFile ?? translate("session.inMemory"), copyField: "file" as const }, { label: translate("session.id"), value: sessionStats.sessionId, copyField: "id" as const }, ...(totalActiveMs > 0 ? [{ label: translate("session.totalActive"), value: formatDuration(totalActiveMs), copyField: null }] : []), ]; const messageRows = [ [translate("session.user"), sessionStats.userMessages.toLocaleString(locale)], [translate("session.assistant"), sessionStats.assistantMessages.toLocaleString(locale)], [translate("session.toolCalls"), sessionStats.toolCalls.toLocaleString(locale)], [translate("session.toolResults"), sessionStats.toolResults.toLocaleString(locale)], [translate("session.total"), sessionStats.totalMessages.toLocaleString(locale)], ]; const tokenRows = [ [translate("session.input"), sessionStats.tokens.input.toLocaleString(locale)], [translate("session.output"), sessionStats.tokens.output.toLocaleString(locale)], ...(sessionStats.tokens.cacheRead > 0 ? [[translate("session.cacheRead"), sessionStats.tokens.cacheRead.toLocaleString(locale)]] : []), ...(sessionStats.tokens.cacheWrite > 0 ? [[translate("session.cacheWrite"), sessionStats.tokens.cacheWrite.toLocaleString(locale)]] : []), [translate("session.total"), sessionStats.tokens.total.toLocaleString(locale)], ]; const ctx = contextUsage ?? sessionStats.contextUsage; const formatCompact = (n: number) => n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${(n / 1000).toFixed(0)}k` : String(n); const extraTokenRows = [ ...(sessionStats.cost > 0 ? [[translate("session.cost"), `$${sessionStats.cost.toFixed(4)}`]] : []), ...(ctx?.contextWindow ? [[translate("session.context"), `${ctx.percent !== null ? `${ctx.percent.toFixed(1)}%` : "?"} / ${formatCompact(ctx.contextWindow)}`]] : []), // Cache hit rate = cache reads / (input + cache writes + cache reads) — the denominator covers all input-class tokens. ...(sessionStats.tokens.cacheRead + sessionStats.tokens.cacheWrite > 0 && sessionStats.tokens.cacheRead + sessionStats.tokens.cacheWrite + sessionStats.tokens.input > 0 ? [[translate("session.cacheHitRate"), `${(sessionStats.tokens.cacheRead / (sessionStats.tokens.cacheRead + sessionStats.tokens.cacheWrite + sessionStats.tokens.input) * 100).toFixed(1)}%`]] : []), ]; const section = ( title: string, sectionRows: string[][], valueAlign: "left" | "right" = "left", compact = false, ) => (
{title}
{sectionRows.map(([label, value]) => (
{label}
{value}
))}
); const copyButton = (field: SessionCopyField, value: string) => { const copied = copiedSessionField === field; return ( ); }; const sessionInfoSection = (
{translate("session.infoSection")}
{sessionRows.map((row) => (
{row.label}
{row.value}
{row.copyField ? copyButton(row.copyField, row.value) : null}
))}
); return (
{sessionInfoSection} {section(translate("session.messages"), messageRows)} {section(translate("session.tokens"), [...tokenRows, ...extraTokenRows], "right", true)}
); })() : (
{translate("session.load")}
)}
)}
)}
{isMobile && renderProjectTrustWarning(true)}
{/* Chat content */}
{showChat ? ( ) : initialCwdStatus === "validating" ? (
{translate("workspace.opening")}
{initialNavigation.requestedCwd}
) : initialCwdStatus === "error" ? (
{translate("workspace.unable")}
{initialNavigation.requestedCwd}
{initialCwdError}
) : showPlaceholder ? ( activeCwd ? (
{translate("workspace.selectSession")}
) : (
{translate("workspace.getStarted")}
1.{translate("workspace.selectProject")}
2.{translate("workspace.addModels")}
) ) : null}