mirror of
https://github.com/luckyyzh/pi-agent-integrated.git
synced 2026-10-03 11:09:34 +00:00
2630 lines
77 KiB
TypeScript
2630 lines
77 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 { ExtensionsConfig } from "./ExtensionsConfig";
|
|
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 [extensionsConfigOpen, setExtensionsConfigOpen] = 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>
|
|
),
|
|
},
|
|
{
|
|
label: translate("common.extensions"),
|
|
onClick: () => setExtensionsConfigOpen(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="M8 3v4" />
|
|
<path d="M16 3v4" />
|
|
<path d="M4 7h16" />
|
|
<rect x="4" y="7" width="16" height="14" rx="2" />
|
|
<path d="M9 12h6M9 16h4" />
|
|
</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)}
|
|
/>
|
|
)}
|
|
{extensionsConfigOpen && projectTrustCwd && (
|
|
<ExtensionsConfig
|
|
cwd={projectTrustCwd}
|
|
onCloseAction={() => setExtensionsConfigOpen(false)}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|