Files
pi-agent-integrated/pi-web/components/AppShell.tsx
T

1561 lines
73 KiB
TypeScript

"use client";
import { useState, useCallback, useRef, useEffect } 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 { ModelsConfig } from "./ModelsConfig";
import { McpConfig } from "./McpConfig";
import { SkillsConfig } from "./SkillsConfig";
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 { copyText } from "@/lib/clipboard";
import { getFileName } from "@/lib/file-paths";
import { buildAtMentionText, buildFileAtMentionsText, buildFileLineMentionText } from "@/lib/file-fuzzy";
import { getInitialNavigation } from "@/lib/initial-navigation";
import type { SessionInfo, SessionTreeNode } from "@/lib/types";
import type { ProjectTrustStatus } from "@/lib/api-types";
import type { ChatInputHandle } from "./ChatInput";
import type { SessionStatsInfo } from "@/lib/pi-types";
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 { isDark, toggleTheme } = useTheme();
const { locale, setLocale, t: translate, supportedLocales } = useI18n();
const isMobile = useIsMobile();
const [selectedSession, setSelectedSession] = useState<SessionInfo | null>(null);
// When user clicks +, we only store the cwd — no fake session id
const [newSessionCwd, setNewSessionCwd] = useState<string | null>(null);
const [initialCwdStatus, setInitialCwdStatus] = useState<"idle" | "validating" | "ready" | "error">(
() => initialNavigation.requestedCwd ? "validating" : "idle",
);
const [initialCwdError, setInitialCwdError] = useState<string | null>(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 [mcpConfigOpen, setMcpConfigOpen] = useState(false);
const [skillsConfigOpen, setSkillsConfigOpen] = useState(false);
const [pluginsConfigOpen, setPluginsConfigOpen] = useState(false);
const [projectTrust, setProjectTrust] = useState<ProjectTrustStatus | null>(null);
const [projectTrustDialogOpen, setProjectTrustDialogOpen] = useState(false);
const [projectTrustBusy, setProjectTrustBusy] = useState(false);
const [projectTrustError, setProjectTrustError] = useState<string | null>(null);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [mobileSidebarReady, setMobileSidebarReady] = useState(false);
// 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);
}, []);
const chatInputRef = useRef<ChatInputHandle | null>(null);
const topBarRef = useRef<HTMLDivElement>(null);
const languageBtnRef = useRef<HTMLButtonElement>(null);
// Branch navigator state — populated by ChatWindow via onBranchDataChange
const [branchTree, setBranchTree] = useState<SessionTreeNode[]>([]);
const [branchActiveLeafId, setBranchActiveLeafId] = useState<string | null>(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<string | null>(null);
const systemBtnRef = useRef<HTMLButtonElement>(null);
const handleSystemPromptChange = useCallback((prompt: string | null) => {
setSystemPrompt(prompt);
}, []);
// Session stats (tokens + cost) — populated by ChatWindow, displayed in top bar
const [sessionStats, setSessionStats] = useState<SessionStatsInfo | null>(null);
const [autoNameStatus, setAutoNameStatus] = useState<AutoNameStatus>({ kind: "idle" });
const autoNameTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const activeSessionIdRef = useRef<string | null>(selectedSession?.id ?? null);
activeSessionIdRef.current = selectedSession?.id ?? null;
const handleSessionStatsChange = useCallback((stats: SessionStatsInfo | null) => {
setSessionStats(stats);
}, []);
const [copiedSessionField, setCopiedSessionField] = useState<SessionCopyField | null>(null);
const sessionCopyTimerRef = useRef<ReturnType<typeof setTimeout> | 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") => {
if (isMobile) setSidebarOpen(false);
setActiveTopPanel((cur) => cur === panel ? null : panel);
}, [isMobile]);
const openSessionStatsPanel = useCallback(() => {
if (isMobile) setSidebarOpen(false);
setActiveTopPanel("session");
}, [isMobile]);
const handleSidebarToggle = useCallback(() => {
if (isMobile) setActiveTopPanel(null);
setSidebarOpen((open) => !open);
}, [isMobile]);
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<Tab[]>([]);
const [activeFileTabId, setActiveFileTabId] = useState<string | null>(null);
const [rightPanelOpen, setRightPanelOpen] = useState(false);
// 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));
}, []);
const handleAtMentions = useCallback((relativePaths: string[]) => {
const mentions = buildFileAtMentionsText(relativePaths);
if (mentions) chatInputRef.current?.insertText(mentions);
}, []);
const handleFileLineMention = useCallback((relativePath: string, startLine: number, endLine: number) => {
chatInputRef.current?.insertText(buildFileLineMentionText(relativePath, startLine, endLine));
}, []);
const initialSessionId = initialNavigation.sessionId;
const [activeCwd, setActiveCwd] = useState<string | null>(null);
const activeProjectRootRef = useRef<string | null>(null);
// True once the initial ?session= URL param has been resolved (or confirmed absent)
const [initialSessionRestored, setInitialSessionRestored] = useState<boolean>(() => !initialSessionId);
// Suppresses sessionKey bump in handleCwdChange during the initial URL restore
const suppressCwdBumpRef = useRef(false);
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;
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]);
const handleCwdChange = useCallback((cwd: string | null, projectRoot?: string | null) => {
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;
}
// Worktrees of one repo share a project root. Moving the effective cwd
// within the same project (e.g. switching worktree, or clicking a session
// that lives in another worktree) must not close the open session.
if (currentProject === newProject) {
return;
}
// Close any session that belongs to a different project — it no longer
// matches the selected project directory.
setSelectedSession(null);
setNewSessionCwd((prev) => {
if (prev && prev !== cwd) return null;
return prev;
});
setSessionKey((k) => k + 1);
setBranchTree([]);
setBranchActiveLeafId(null);
setSystemPrompt(null);
setActiveTopPanel(null);
// File tabs are keyed by absolute path, so tabs opened in the previous
// project would otherwise linger after switching to a different project.
// Reached only past the same-project early return above, so worktrees of
// one repo keep their open tabs. Mirror handleCloseFileTab and close the
// now-empty right panel.
setFileTabs([]);
setActiveFileTabId(null);
setRightPanelOpen(false);
router.replace("/", { scroll: false });
}, [router, selectedSession]);
const handleSelectSession = useCallback((session: SessionInfo, isRestore = false) => {
setNewSessionCwd(null);
setSelectedSession(session);
setSessionKey((k) => k + 1);
setSystemPrompt(null);
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 });
}
}, [router, isMobile]);
const handleNewSession = useCallback((_sessionId: string, cwd: string) => {
setSelectedSession(null);
setNewSessionCwd(cwd);
setSessionKey((k) => k + 1);
setBranchTree([]);
setBranchActiveLeafId(null);
setSystemPrompt(null);
setActiveTopPanel(null);
if (isMobile) setSidebarOpen(false);
router.replace("/", { scroll: false });
}, [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")
.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 && prev.id === sessionId && !prev.projectRoot ? full : prev));
})
.catch(() => {});
}, []);
// Called by ChatWindow when a new session gets its real id from pi
const handleSessionCreated = useCallback((session: SessionInfo) => {
setNewSessionCwd(null);
setSelectedSession(session);
setRefreshKey((k) => k + 1);
hydrateSelectedSession(session.id);
router.replace(`?session=${encodeURIComponent(session.id)}`, { scroll: false });
}, [router, hydrateSelectedSession]);
const handleAgentEnd = useCallback(() => {
setRefreshKey((k) => k + 1);
setExplorerRefreshKey((k) => k + 1);
}, []);
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) => {
setRefreshKey((k) => k + 1);
setSessionKey((k) => k + 1);
setNewSessionCwd(null);
setSelectedSession((prev) => ({
...(prev ?? { path: "", cwd: "", created: "", modified: "", messageCount: 0, firstMessage: "" }),
id: newSessionId,
}));
hydrateSelectedSession(newSessionId);
router.replace(`?session=${encodeURIComponent(newSessionId)}`, { scroll: false });
}, [router, hydrateSelectedSession]);
const handleInitialRestoreDone = useCallback(() => {
setInitialSessionRestored(true);
}, []);
const handleSessionDeleted = useCallback((sessionId: string) => {
setRefreshKey((k) => k + 1);
if (selectedSession?.id === sessionId) {
const cwd = selectedSession.cwd;
setSelectedSession(null);
setNewSessionCwd(cwd ?? null);
setSessionKey((k) => k + 1);
setBranchTree([]);
setBranchActiveLeafId(null);
setSystemPrompt(null);
setActiveTopPanel(null);
router.replace("/", { scroll: false });
}
}, [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) => {
const existing = prev.find((t) => t.id === tabId);
if (!existing) {
return [...prev, {
id: tabId,
label: fileName,
filePath,
sourceSessionId,
initialDisplayMode: modeHint,
}];
}
const sourceUnchanged = !sourceSessionId || existing.sourceSessionId === sourceSessionId;
const modeUnchanged = !modeHint || existing.initialDisplayMode === modeHint;
if (sourceUnchanged && modeUnchanged) return prev;
return prev.map((t) => {
if (t.id !== tabId) return t;
const next: Tab = { ...t };
if (sourceSessionId) next.sourceSessionId = sourceSessionId;
if (modeHint) next.initialDisplayMode = modeHint;
return next;
});
});
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 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((t) => t.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 = (
<>
<SessionSidebar
selectedSessionId={selectedSession?.id ?? null}
onSelectSession={handleSelectSession}
onNewSession={handleNewSession}
initialSessionId={initialSessionId}
skipInitialProjectSelection={initialNavigation.requestedCwd !== null}
onInitialRestoreDone={handleInitialRestoreDone}
refreshKey={refreshKey}
onSessionDeleted={handleSessionDeleted}
selectedCwd={selectedSession?.cwd ?? newSessionCwd ?? null}
onCwdChange={handleCwdChange}
onOpenFile={handleOpenFile}
explorerRefreshKey={explorerRefreshKey}
onExplorerRefresh={handleExplorerRefresh}
onAtMention={handleAtMention}
onAtMentions={handleAtMentions}
/>
<div style={{ padding: "8px", flexShrink: 0, display: "flex", justifyContent: "space-between", gap: 4 }}>
{([
{
label: translate("common.models"),
onClick: () => setModelsConfigOpen(true),
disabled: false,
icon: (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="4" y="4" width="16" height="16" rx="2" /><rect x="9" y="9" width="6" height="6" />
<line x1="9" y1="1" x2="9" y2="4" /><line x1="15" y1="1" x2="15" y2="4" />
<line x1="9" y1="20" x2="9" y2="23" /><line x1="15" y1="20" x2="15" y2="23" />
<line x1="20" y1="9" x2="23" y2="9" /><line x1="20" y1="14" x2="23" y2="14" />
<line x1="1" y1="9" x2="4" y2="9" /><line x1="1" y1="14" x2="4" y2="14" />
</svg>
),
},
{
label: translate("common.mcp"),
onClick: () => setMcpConfigOpen(true),
disabled: false,
icon: (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
),
},
{
label: translate("common.skills"),
onClick: () => setSkillsConfigOpen(true),
disabled: !activeCwd && !selectedSession?.cwd && !newSessionCwd,
icon: (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2L2 7l10 5 10-5-10-5z" />
<path d="M2 17l10 5 10-5" />
<path d="M2 12l10 5 10-5" />
</svg>
),
},
{
label: translate("common.plugins"),
onClick: () => setPluginsConfigOpen(true),
disabled: !activeCwd && !selectedSession?.cwd && !newSessionCwd,
icon: (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 7V2" />
<path d="M15 7V2" />
<path d="M6 13V8a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v5a6 6 0 0 1-12 0Z" />
<path d="M12 19v3" />
</svg>
),
},
] as { label: string; onClick: () => void; disabled: boolean; icon: React.ReactNode }[]).map(({ label, onClick, disabled, icon }) => (
<button
key={label}
onClick={onClick}
disabled={disabled}
title={label}
style={{
flex: 1, display: "flex", alignItems: "center", justifyContent: "center", gap: 6,
height: 32, padding: 0, background: "none", border: "none",
borderRadius: 9, color: "var(--text-muted)", cursor: disabled ? "default" : "pointer",
fontSize: 12, opacity: disabled ? 0.35 : 1,
transition: "background 0.12s, color 0.12s",
}}
onMouseEnter={(e) => { if (!disabled) { e.currentTarget.style.background = "var(--bg-hover)"; e.currentTarget.style.color = "var(--text)"; } }}
onMouseLeave={(e) => { e.currentTarget.style.background = "none"; e.currentTarget.style.color = "var(--text-muted)"; }}
>
{icon}
{label}
</button>
))}
</div>
</>
);
return (
<>
<style>{`
@keyframes session-info-pop {
0% {
opacity: 0;
transform: translateY(-24px);
filter: blur(6px);
box-shadow: 0 2px 8px rgba(0,0,0,0);
}
55% {
opacity: 1;
transform: translateY(0);
filter: blur(0);
background: color-mix(in srgb, var(--accent) 8%, var(--bg-panel));
box-shadow: 0 18px 44px rgba(37,99,235,0.16);
}
100% {
opacity: 1;
transform: translateY(0);
filter: blur(0);
background: var(--bg-panel);
box-shadow: 0 10px 28px rgba(0,0,0,0.10);
}
}
@keyframes session-info-light-wash {
0% {
opacity: 0;
transform: translateX(-110%) skewX(-16deg);
}
24% {
opacity: 0.42;
}
100% {
opacity: 0;
transform: translateX(115%) skewX(-16deg);
}
}
.session-info-popover {
position: relative;
overflow: hidden;
transform-origin: top right;
animation: session-info-pop 360ms ease-out both;
will-change: transform, opacity, filter, background, box-shadow;
}
.session-info-popover::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 44%;
pointer-events: none;
background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--accent) 24%, transparent), transparent);
animation: session-info-light-wash 620ms ease-out both;
}
@media (prefers-reduced-motion: reduce) {
.session-info-popover,
.session-info-popover::after {
animation: none;
}
}
@media (max-width: 640px) {
.sidebar-overlay-backdrop.sidebar-mobile-pending {
opacity: 0 !important;
pointer-events: none !important;
}
.sidebar-container.sidebar-mobile-pending.sidebar-open {
transform: translateX(-100%);
box-shadow: none;
}
}
`}</style>
<div style={{ display: "flex", height: "100dvh", overflow: "hidden", background: "var(--bg)" }}>
{/* Mobile overlay backdrop */}
<div
className={`sidebar-overlay-backdrop${mobileSidebarReady ? "" : " sidebar-mobile-pending"}`}
onClick={() => 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 */}
<div
className={`sidebar-container${sidebarOpen ? " sidebar-open" : " sidebar-closed"}${mobileSidebarReady ? "" : " sidebar-mobile-pending"}`}
style={{
background: "var(--bg-panel)",
borderRight: "1px solid var(--border)",
display: "flex",
flexDirection: "column",
flexShrink: 0,
zIndex: 200,
}}
>
{sidebarContent}
</div>
{/* Center: chat */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden", minWidth: 0 }}>
{/* Top bar with sidebar toggle */}
<div ref={topBarRef} style={{ display: "flex", alignItems: "center", flexShrink: 0, borderBottom: "1px solid var(--border)", height: 36, background: "var(--bg-panel)" }}>
<button
onClick={handleSidebarToggle}
title={sidebarOpen ? translate("sidebar.hide") : translate("sidebar.show")}
aria-label={sidebarOpen ? translate("sidebar.hide") : translate("sidebar.show")}
style={{
display: "flex", alignItems: "center", justifyContent: "center",
width: TOP_BAR_ICON_BUTTON_SIZE, height: TOP_BAR_ICON_BUTTON_SIZE, padding: 0,
background: "none", border: "none", borderRight: "1px solid var(--border)",
color: "var(--text-muted)", cursor: "pointer", flexShrink: 0, transition: "color 0.12s",
}}
onMouseEnter={(e) => { e.currentTarget.style.color = "var(--text)"; }}
onMouseLeave={(e) => { e.currentTarget.style.color = "var(--text-muted)"; }}
>
{sidebarOpen ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" /><line x1="9" y1="3" x2="9" y2="21" />
</svg>
) : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="18" x2="21" y2="18" />
</svg>
)}
</button>
<button
onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
toggleTheme({ x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 });
}}
title={isDark ? translate("theme.light") : translate("theme.dark")}
aria-label={isDark ? translate("theme.light") : translate("theme.dark")}
aria-pressed={isDark}
style={{
display: "flex", alignItems: "center", justifyContent: "center",
width: TOP_BAR_ICON_BUTTON_SIZE, height: TOP_BAR_ICON_BUTTON_SIZE, padding: 0,
background: "none", border: "none", borderRight: "1px solid var(--border)",
color: "var(--text-muted)", cursor: "pointer", flexShrink: 0, transition: "color 0.12s",
}}
onMouseEnter={(e) => { e.currentTarget.style.color = "var(--text)"; }}
onMouseLeave={(e) => { e.currentTarget.style.color = "var(--text-muted)"; }}
>
{isDark ? (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="5" />
<line x1="12" y1="1" x2="12" y2="3" /><line x1="12" y1="21" x2="12" y2="23" />
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" /><line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
<line x1="1" y1="12" x2="3" y2="12" /><line x1="21" y1="12" x2="23" y2="12" />
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" /><line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
</svg>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
)}
</button>
<button
ref={languageBtnRef}
type="button"
onClick={() => toggleTopPanel("language")}
title={translate("common.language")}
aria-label={translate("common.language")}
aria-haspopup="menu"
aria-expanded={activeTopPanel === "language"}
aria-pressed={activeTopPanel === "language"}
style={{
display: "flex", alignItems: "center", justifyContent: "center",
width: TOP_BAR_ICON_BUTTON_SIZE, height: TOP_BAR_ICON_BUTTON_SIZE, padding: 0,
background: activeTopPanel === "language" ? "var(--bg-selected)" : "none",
border: "none", borderRight: "1px solid var(--border)",
color: activeTopPanel === "language" ? "var(--text)" : "var(--text-muted)",
cursor: "pointer", flexShrink: 0, transition: "color 0.12s",
}}
onMouseEnter={(e) => { e.currentTarget.style.color = "var(--text)"; }}
onMouseLeave={(e) => {
e.currentTarget.style.color = activeTopPanel === "language" ? "var(--text)" : "var(--text-muted)";
}}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="m5 8 6 6" />
<path d="m4 14 6-6 2-3" />
<path d="M2 5h12" />
<path d="M7 2h1" />
<path d="m22 22-5-10-5 10" />
<path d="M14 18h6" />
</svg>
</button>
{showChat && projectTrust?.requiresTrust && !projectTrust.trusted && (
<button
type="button"
onClick={() => {
setProjectTrustError(null);
setProjectTrustDialogOpen(true);
}}
title={translate("trust.resourcesNotLoaded")}
aria-label={translate("trust.resourcesNotLoaded")}
style={{
display: "flex",
alignItems: "center",
gap: 6,
height: "100%",
padding: isMobile ? "0 10px" : "0 12px",
background: "none",
border: "none",
borderRight: "1px solid var(--border)",
color: "#d97706",
cursor: "pointer",
flexShrink: 0,
fontSize: 11,
whiteSpace: "nowrap",
}}
>
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z" />
<path d="M12 8v4" />
<path d="M12 16h.01" />
</svg>
{!isMobile && <span>{translate("trust.resourcesNotLoaded")}</span>}
</button>
)}
{showChat && (
<div style={{ display: "flex", alignItems: "stretch", height: "100%" }}>
<button
onClick={handleViewFullHistory}
disabled={!selectedSession}
title={selectedSession ? translate("history.full") : translate("history.unsaved")}
aria-label={translate("history.full")}
style={{
display: "flex",
alignItems: "center",
gap: 6,
height: "100%",
padding: "0 12px",
background: "none",
border: "none",
borderTop: "2px solid transparent",
borderRight: "1px solid var(--border)",
color: selectedSession ? "var(--text-muted)" : "var(--text-dim)",
cursor: selectedSession ? "pointer" : "not-allowed",
opacity: selectedSession ? 1 : 0.45,
flexShrink: 0,
fontSize: 11,
whiteSpace: "nowrap",
transition: "color 0.1s, background 0.1s, opacity 0.1s",
}}
onMouseEnter={(e) => {
if (!selectedSession) return;
e.currentTarget.style.color = "var(--text)";
e.currentTarget.style.background = "var(--bg-hover)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = selectedSession ? "var(--text-muted)" : "var(--text-dim)";
e.currentTarget.style.background = "none";
}}
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{
color: selectedSession ? "var(--text-muted)" : "var(--text-dim)",
flexShrink: 0,
}}
>
<path d="M3 12a9 9 0 1 0 3-6.7L3 8" />
<path d="M3 3v5h5" />
<path d="M12 7v5l3 2" />
</svg>
{!isMobile && <span>{translate("history.label")}</span>}
</button>
{(() => {
const hasMessages = Boolean(
selectedSession
&& (sessionStats?.userMessages ?? selectedSession.messageCount) > 0,
);
const disabled = !selectedSession || !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
? translate("title.unsaved")
: !hasMessages
? translate("title.noMessages")
: isError
? autoNameStatus.message
: translate("title.generateSession");
return (
<button
type="button"
onClick={() => void handleAutoName()}
disabled={disabled}
title={title}
aria-label={label}
style={{
display: "flex", alignItems: "center", gap: 6,
height: "100%", padding: "0 12px",
background: "none", border: "none",
borderTop: "2px solid transparent",
borderRight: "1px solid var(--border)",
color: isError ? "#dc2626" : isSuccess ? "var(--accent)" : disabled ? "var(--text-dim)" : "var(--text-muted)",
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled && autoNameStatus.kind !== "naming" ? 0.45 : 1,
flexShrink: 0, fontSize: 11, whiteSpace: "nowrap",
transition: "color 0.1s, background 0.1s, opacity 0.1s",
}}
onMouseEnter={(e) => {
if (disabled) return;
e.currentTarget.style.color = isError ? "#dc2626" : "var(--text)";
e.currentTarget.style.background = "var(--bg-hover)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = isError ? "#dc2626" : isSuccess ? "var(--accent)" : disabled ? "var(--text-dim)" : "var(--text-muted)";
e.currentTarget.style.background = "none";
}}
>
{autoNameStatus.kind === "naming" ? (
<svg className="animate-spin" width="13" height="13" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2" opacity="0.25" />
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
) : isSuccess ? (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="m15 4 5 5L7 22l-5-5Z" />
<path d="m14 5 5 5" />
<path d="M6 4V2M5 3H3M19 19v3M17.5 20.5h3" />
</svg>
)}
{!isMobile && <span>{label}</span>}
</button>
);
})()}
<BranchNavigator
tree={branchTree}
activeLeafId={branchActiveLeafId}
onLeafChange={handleBranchLeafChange}
inline
compact={isMobile}
containerRef={topBarRef}
open={activeTopPanel === "branches"}
onToggle={() => toggleTopPanel("branches")}
hasSession
/>
<button
ref={systemBtnRef}
onClick={() => toggleTopPanel("system")}
title={translate("system.prompt")}
aria-label={translate("system.prompt")}
aria-pressed={activeTopPanel === "system"}
style={{
display: "flex", alignItems: "center", gap: 6,
height: "100%", padding: "0 12px",
background: activeTopPanel === "system" ? "var(--bg-selected)" : "none",
border: "none",
borderTop: activeTopPanel === "system" ? "2px solid var(--accent)" : "2px solid transparent",
borderRight: "1px solid var(--border)",
cursor: "pointer",
color: activeTopPanel === "system" ? "var(--text)" : "var(--text-muted)",
fontSize: 11, whiteSpace: "nowrap", transition: "color 0.1s, background 0.1s",
}}
onMouseEnter={(e) => { e.currentTarget.style.color = "var(--text)"; }}
onMouseLeave={(e) => { e.currentTarget.style.color = activeTopPanel === "system" ? "var(--text)" : "var(--text-muted)"; }}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ color: systemPrompt ? "var(--accent)" : "var(--text-dim)", flexShrink: 0 }}>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="8" y1="13" x2="16" y2="13" />
<line x1="8" y1="17" x2="13" y2="17" />
</svg>
{!isMobile && <span>{translate("system.label")}</span>}
</button>
</div>
)}
{/* Session stats — right-aligned in top bar */}
{showChat && (sessionStats || contextUsage) && (() => {
const tokens = sessionStats?.tokens;
const c = sessionStats?.cost ?? 0;
const fmt = (n: number) => n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${(n / 1000).toFixed(0)}k` : String(n);
const costStr = c > 0 ? (c >= 0.01 ? `$${c.toFixed(2)}` : `<$0.01`) : null;
let ctxColor = "var(--text-muted)";
let ctxStr: string | null = null;
if (contextUsage?.contextWindow) {
const pct = contextUsage.percent;
if (pct !== null && pct > 90) ctxColor = "#ef4444";
else if (pct !== null && pct > 70) ctxColor = "rgba(234,179,8,0.95)";
ctxStr = pct !== null ? `${pct.toFixed(0)}% / ${fmt(contextUsage.contextWindow)}` : `? / ${fmt(contextUsage.contextWindow)}`;
}
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 (c > 0) tooltipParts.push(`cost: $${c.toFixed(4)}`);
}
if (contextUsage?.contextWindow) {
const pct = contextUsage.percent;
tooltipParts.push(`context: ${pct !== null ? pct.toFixed(1) + "%" : "unknown"} of ${contextUsage.contextWindow.toLocaleString()} tokens`);
}
const tooltip = tooltipParts.join(" | ");
return (
<button
type="button"
onClick={() => toggleTopPanel("session")}
title={tooltip || translate("session.title")}
aria-label={translate("session.title")}
aria-pressed={activeTopPanel === "session"}
style={{
marginLeft: "auto",
display: "flex", alignItems: "center", gap: 10,
paddingLeft: 12,
paddingRight: rightPanelOpen ? 12 : 48,
height: "100%",
background: activeTopPanel === "session" ? "var(--bg-selected)" : "none",
border: "none",
borderTop: activeTopPanel === "session" ? "2px solid var(--accent)" : "2px solid transparent",
fontSize: 11, color: "var(--text-muted)",
whiteSpace: "nowrap", cursor: "pointer",
fontVariantNumeric: "tabular-nums",
transition: "color 0.1s, background 0.1s",
}}
onMouseEnter={(e) => { e.currentTarget.style.color = "var(--text)"; }}
onMouseLeave={(e) => { e.currentTarget.style.color = activeTopPanel === "session" ? "var(--text)" : "var(--text-muted)"; }}
>
{isMobile && (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10" /><line x1="12" y1="16" x2="12" y2="12" /><line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
)}
{!isMobile && tokens && tokens.input > 0 && (
<span style={{ display: "flex", alignItems: "center", gap: 4 }}>
<svg width="12" height="12" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round">
<line x1="5" y1="8.5" x2="5" y2="1.5" /><polyline points="2 4 5 1.5 8 4" />
</svg>
{fmt(tokens.input)}
</span>
)}
{!isMobile && tokens && tokens.output > 0 && (
<span style={{ display: "flex", alignItems: "center", gap: 4 }}>
<svg width="12" height="12" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round">
<line x1="5" y1="1.5" x2="5" y2="8.5" /><polyline points="2 6 5 8.5 8 6" />
</svg>
{fmt(tokens.output)}
</span>
)}
{!isMobile && tokens && tokens.cacheRead > 0 && (
<span style={{ display: "flex", alignItems: "center", gap: 4 }}>
<svg width="12" height="12" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round">
<path d="M8.5 5a3.5 3.5 0 1 1-1-2.45" /><polyline points="6.5 1.5 8.5 2.5 7.5 4.5" />
</svg>
{fmt(tokens.cacheRead)}
</span>
)}
{!isMobile && costStr && (
<span style={{ display: "flex", alignItems: "center", color: "var(--text)", fontWeight: 500 }}>
{costStr}
</span>
)}
{ctxStr && (
<span style={{ display: "flex", alignItems: "center", gap: 4, color: ctxColor }}>
<svg width="12" height="12" viewBox="0 0 10 10" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round">
<path d="M1 9 L1 5 Q1 1 5 1 Q9 1 9 5 L9 9" /><line x1="1" y1="9" x2="9" y2="9" />
</svg>
{ctxStr}
</span>
)}
</button>
);
})()}
{/* Top panel dropdown — shared, only one active at a time */}
{activeTopPanel && topPanelPos && (
<div style={{
position: "fixed",
top: topPanelPos.top,
left: topPanelPos.left,
width: topPanelPos.width,
maxHeight: `calc(100dvh - ${topPanelPos.top}px)`,
overflowY: "auto",
zIndex: 500,
}}>
{activeTopPanel === "language" && (
<div
role="menu"
aria-label={translate("common.language")}
style={{
background: "var(--bg-panel)",
borderLeft: "1px solid var(--border)",
borderRight: "1px solid var(--border)",
borderBottom: "1px solid var(--border)",
overflow: "hidden",
padding: 4,
}}
>
{supportedLocales.map((plugin) => (
<button
key={plugin.id}
type="button"
onClick={() => {
setLocale(plugin.id as typeof locale);
setActiveTopPanel(null);
}}
role="menuitemradio"
aria-checked={locale === plugin.id}
style={{
display: "flex", alignItems: "center",
width: "100%", height: 34, padding: "0 10px",
border: "none", borderRadius: 4,
background: locale === plugin.id ? "var(--bg-selected)" : "transparent",
color: "var(--text)", cursor: "pointer", textAlign: "left", fontSize: 12,
transition: "background 0.1s",
}}
onMouseEnter={(e) => {
if (locale !== plugin.id) e.currentTarget.style.background = "var(--bg-hover)";
}}
onMouseLeave={(e) => {
if (locale !== plugin.id) e.currentTarget.style.background = "transparent";
}}
>
<span>{plugin.label}</span>
</button>
))}
</div>
)}
{activeTopPanel === "system" && (
<div style={{
background: "var(--bg-panel)",
borderBottom: "1px solid var(--border)",
}}>
{systemPrompt ? (
<div style={{
maxHeight: "min(600px, 75vh)",
overflowY: "auto",
padding: "12px 16px",
color: "var(--text-muted)",
fontSize: 12,
lineHeight: 1.6,
whiteSpace: "pre-wrap",
fontFamily: "var(--font-mono)",
}}>
{systemPrompt}
</div>
) : systemPrompt === "" ? (
<div style={{ padding: "10px 16px", fontSize: 12, color: "var(--text-muted)", fontStyle: "italic" }}>
{translate("system.empty")}
</div>
) : (
<div style={{ padding: "10px 16px", fontSize: 12, color: "var(--text-muted)", fontStyle: "italic" }}>
{translate("system.load")}
</div>
)}
</div>
)}
{activeTopPanel === "session" && (
<div className="session-info-popover" style={{
background: "var(--bg-panel)",
borderBottom: "1px solid var(--border)",
boxShadow: "0 10px 28px rgba(0,0,0,0.10)",
padding: "12px 16px",
}}>
{sessionStats ? (() => {
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 },
];
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 promptTokens = sessionStats.tokens.input + sessionStats.tokens.cacheRead + sessionStats.tokens.cacheWrite;
const cacheHitRate = promptTokens > 0 && (sessionStats.tokens.cacheRead > 0 || sessionStats.tokens.cacheWrite > 0)
? `${((sessionStats.tokens.cacheRead / promptTokens) * 100).toFixed(1)}%`
: null;
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)]] : []),
...(cacheHitRate ? [[translate("session.cacheHitRate"), cacheHitRate]] : []),
[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)}`]] : []),
];
const section = (
title: string,
sectionRows: string[][],
valueAlign: "left" | "right" = "left",
compact = false,
) => (
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 11, fontWeight: 700, color: "var(--text)", marginBottom: 6 }}>{title}</div>
<div style={{
display: "grid",
gridTemplateColumns: compact ? "max-content max-content" : "auto minmax(0, 1fr)",
columnGap: compact ? 14 : 12,
rowGap: 4,
justifyContent: compact ? "start" : undefined,
}}>
{sectionRows.map(([label, value]) => (
<div key={`${title}:${label}`} style={{ display: "contents" }}>
<div style={{ color: "var(--text-dim)", whiteSpace: "nowrap" }}>{label}</div>
<div style={{
color: "var(--text-muted)",
minWidth: 0,
overflowWrap: compact ? "normal" : "anywhere",
textAlign: valueAlign,
whiteSpace: valueAlign === "right" ? "nowrap" : "normal",
}}>{value}</div>
</div>
))}
</div>
</div>
);
const copyButton = (field: SessionCopyField, value: string) => {
const copied = copiedSessionField === field;
return (
<button
type="button"
title={copied ? translate("session.copied") : translate(field === "file" ? "session.copyFile" : "session.copyId")}
onClick={() => handleCopySessionField(field, value)}
style={{
alignSelf: "start",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: 22,
height: 22,
marginTop: -2,
color: copied ? "var(--accent)" : "var(--text-dim)",
background: "transparent",
border: "1px solid var(--border)",
borderRadius: 4,
cursor: "pointer",
flex: "0 0 auto",
transition: "color 0.12s, border-color 0.12s, background 0.12s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.color = "var(--accent)";
e.currentTarget.style.borderColor = "var(--accent)";
e.currentTarget.style.background = "var(--bg-hover)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.color = copied ? "var(--accent)" : "var(--text-dim)";
e.currentTarget.style.borderColor = "var(--border)";
e.currentTarget.style.background = "transparent";
}}
>
{copied ? (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)}
</button>
);
};
const sessionInfoSection = (
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 11, fontWeight: 700, color: "var(--text)", marginBottom: 6 }}>{translate("session.infoSection")}</div>
<div style={{ display: "grid", gridTemplateColumns: "auto minmax(0, 1fr) auto", columnGap: 12, rowGap: 8, alignItems: "start" }}>
{sessionRows.map((row) => (
<div key={`session-info:${row.label}`} style={{ display: "contents" }}>
<div style={{ color: "var(--text-dim)", whiteSpace: "nowrap" }}>{row.label}</div>
<div style={{
color: "var(--text-muted)",
minWidth: 0,
overflowWrap: "anywhere",
wordBreak: "break-word",
whiteSpace: "normal",
}}>{row.value}</div>
<div>{row.copyField ? copyButton(row.copyField, row.value) : null}</div>
</div>
))}
</div>
</div>
);
return (
<div style={{
display: "grid",
gridTemplateColumns: isMobile
? "1fr"
: "minmax(360px, 1.7fr) minmax(140px, 0.55fr) minmax(190px, 0.75fr)",
gap: isMobile ? 16 : 24,
fontSize: 12,
lineHeight: 1.5,
fontFamily: "var(--font-mono)",
}}>
{sessionInfoSection}
{section(translate("session.messages"), messageRows)}
{section(translate("session.tokens"), [...tokenRows, ...extraTokenRows], "right", true)}
</div>
);
})() : (
<div style={{ fontSize: 12, color: "var(--text-muted)", fontStyle: "italic" }}>
{translate("session.load")}
</div>
)}
</div>
)}
</div>
)}
</div>
{/* Chat content */}
<div style={{ flex: 1, overflow: "hidden", position: "relative" }}>
{showChat ? (
<ChatWindow
key={sessionKey}
session={selectedSession}
newSessionCwd={effectiveNewSessionCwd}
onAgentEnd={handleAgentEnd}
onSessionCreated={handleSessionCreated}
onSessionForked={handleSessionForked}
modelsRefreshKey={modelsRefreshKey}
chatInputRef={chatInputRef}
onBranchDataChange={handleBranchDataChange}
onSystemPromptChange={handleSystemPromptChange}
onSessionStatsChange={handleSessionStatsChange}
onSessionStatsPanelOpen={openSessionStatsPanel}
onContextUsageChange={handleContextUsageChange}
onOpenFile={handleOpenLinkedFile}
/>
) : initialCwdStatus === "validating" ? (
<div
role="status"
style={{ height: "100%", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 8, padding: 24, color: "var(--text-muted)", textAlign: "center" }}
>
<div style={{ fontSize: 14, color: "var(--text)" }}>{translate("workspace.opening")}</div>
<div style={{ maxWidth: "min(720px, 100%)", overflowWrap: "anywhere", fontFamily: "var(--font-mono)", fontSize: 12 }}>
{initialNavigation.requestedCwd}
</div>
</div>
) : initialCwdStatus === "error" ? (
<div
role="alert"
style={{ height: "100%", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 8, padding: 24, color: "var(--text-muted)", textAlign: "center" }}
>
<div style={{ fontSize: 14, color: "#dc2626" }}>{translate("workspace.unable")}</div>
<div style={{ maxWidth: "min(720px, 100%)", overflowWrap: "anywhere", fontFamily: "var(--font-mono)", fontSize: 12 }}>
{initialNavigation.requestedCwd}
</div>
<div style={{ maxWidth: 720, fontSize: 12 }}>{initialCwdError}</div>
</div>
) : showPlaceholder ? (
activeCwd ? (
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", color: "var(--text-muted)", fontSize: 15 }}>
{translate("workspace.selectSession")}
</div>
) : (
<div style={{ position: "absolute", top: 12, left: 12, display: "flex", alignItems: "flex-start", gap: 8, userSelect: "none", pointerEvents: "none" }}>
<svg width="44" height="44" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.7, flexShrink: 0 }}>
<line x1="20" y1="12" x2="4" y2="12" /><polyline points="10 6 4 12 10 18" />
</svg>
<div>
<div style={{ fontSize: 18, fontWeight: 600, color: "var(--text)", marginBottom: 8 }}>{translate("workspace.getStarted")}</div>
<div style={{ fontSize: 12, color: "var(--text-muted)", lineHeight: 1.8 }}>
<span style={{ color: "var(--text-dim)", marginRight: 6 }}>1.</span>{translate("workspace.selectProject")}<br />
<span style={{ color: "var(--text-dim)", marginRight: 6 }}>2.</span>{translate("workspace.addModels")}
</div>
</div>
</div>
)
) : null}
</div>
</div>
{/* Right panel: file viewer — always mounted, width animated via CSS */}
<div
className={`right-panel-container${rightPanelOpen ? " right-panel-open" : " right-panel-closed"}`}
style={{
display: "flex",
flexDirection: "column",
borderLeft: "1px solid var(--border)",
background: "var(--bg)",
}}
>
{/* Right panel tab bar */}
<div style={{ display: "flex", alignItems: "center", flexShrink: 0, background: "var(--bg-panel)", borderBottom: "1px solid var(--border)", height: 36 }}>
<div style={{ flex: 1, overflow: "hidden" }}>
<TabBar
tabs={fileTabs}
activeTabId={activeFileTabId ?? ""}
onSelectTab={setActiveFileTabId}
onCloseTab={handleCloseFileTab}
/>
</div>
</div>
{/* File content */}
<div style={{ flex: 1, overflow: "hidden" }}>
{activeFileTab?.filePath ? (
<FileViewer
filePath={activeFileTab.filePath}
cwd={activeCwd ?? undefined}
sourceSessionId={activeFileTab.sourceSessionId}
gitRefreshKey={explorerRefreshKey}
initialDisplayMode={activeFileTab.initialDisplayMode}
onMentionLines={rightPanelOpen ? handleFileLineMention : undefined}
onEditorSaved={() => setExplorerRefreshKey((k) => k + 1)}
onOpenFile={(filePath) => handleOpenFile(
filePath,
getFileName(filePath),
{ sourceSessionId: activeFileTab.sourceSessionId },
)}
/>
) : (
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", color: "var(--text-dim)", fontSize: 12 }}>
{translate("files.noneOpen")}
</div>
)}
</div>
</div>
</div>
{/* File panel toggle — always visible at top-right */}
<button
onClick={() => setRightPanelOpen((v) => !v)}
title={rightPanelOpen ? translate("files.hidePanel") : translate("files.showPanel")}
aria-label={rightPanelOpen ? translate("files.hidePanel") : translate("files.showPanel")}
style={{
position: "fixed", top: 0, right: 0, zIndex: 300,
display: "flex", alignItems: "center", justifyContent: "center",
width: 36, height: 36, padding: 0,
background: "var(--bg-panel)", border: "none", borderLeft: "1px solid var(--border)", borderBottom: "1px solid var(--border)",
color: rightPanelOpen ? "var(--text)" : "var(--text-muted)",
cursor: "pointer", transition: "color 0.12s",
}}
onMouseEnter={(e) => { e.currentTarget.style.color = "var(--text)"; }}
onMouseLeave={(e) => { e.currentTarget.style.color = rightPanelOpen ? "var(--text)" : "var(--text-muted)"; }}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" /><line x1="15" y1="3" x2="15" y2="21" />
</svg>
</button>
{modelsConfigOpen && <ModelsConfig onClose={() => { setModelsConfigOpen(false); setModelsRefreshKey((k) => k + 1); }} />}
{mcpConfigOpen && <McpConfig onClose={() => setMcpConfigOpen(false)} />}
{projectTrustDialogOpen && projectTrustCwd && (
<ProjectTrustDialog
cwd={projectTrustCwd}
busy={projectTrustBusy}
error={projectTrustError}
onCancel={() => {
if (!projectTrustBusy) setProjectTrustDialogOpen(false);
}}
onConfirm={() => void handleTrustProject()}
/>
)}
{skillsConfigOpen && projectTrustCwd && (
<SkillsConfig cwd={projectTrustCwd} onClose={() => setSkillsConfigOpen(false)} />
)}
{pluginsConfigOpen && projectTrustCwd && (
<PluginsConfig
cwd={projectTrustCwd}
sessionId={selectedSession?.id ?? null}
onClose={() => setPluginsConfigOpen(false)}
onReloaded={() => setSessionKey((k) => k + 1)}
/>
)}
</>
);
}