diff --git a/.yunnan-cached.html b/.yunnan-cached.html new file mode 100644 index 000000000..d398ab096 --- /dev/null +++ b/.yunnan-cached.html @@ -0,0 +1,610 @@ + + + + + +云南 5 天 4 晚休闲自驾 · 国庆窗口决策面板 + + + +
+ +
+

🏔️ 云南 5 天 4 晚 · 大理-洱海-丽江 休闲自驾

+
广州 2 人 + 北京 2 人 · 全程租车 · 4 人返程同城飞广州 · 出行窗口 9.25(中秋)~10.7(国庆)
+
+ 数据源 小红书 App V2 + rgh 酒店实测 + 房价核验 2026-09-05 + 山路驾驶 ✅ 高度接受 + 预算 无上限 · 多档 +
+
+ 💡 一句话结论:若 4 人 9.25 都能出发 → 选 方案 A(9.25-9.29 中秋),比国庆后段(D)省约 ¥8,000(46%)。 + 用下方 方案对比 / 预算计算器 按你们实际能休的日期 + 想要的住宿档位筛选。 +
+
+ + + + +
+

窗口选择一目了然

+
+ +

窗口 × 城市 最划算酒店对比(每间 4 晚总价,rgh 实测)

+
+ +

数据说明

+
+
+ · 酒店价格:rollinggo(rgh)实时查询,2026-09-05 核验;显示"已售罄"的精品民宿(不期尔榆等)= 渠道无库存,需走携程。
+ · 机票价格:基于携程/去哪儿 2026 国庆趋势区间估算("9.27-10.1 均价创三年最低"等),下单前请在航司官网复核
+ · 餐厅/路线:小红书 App V2 检索 676 条候选 → 16 条餐厅 + 8 家口碑住宿,多源交叉验证,广告已过滤。 +
+
+
+ + +
+

4 套 5 天窗口对比

+
+
价格规律:9.27-10.1 机票均价三年最低 · 9.28-30 出发最便宜 · 10.1 / 10.7 返程双高峰 · 酒店 9.30 后进入国庆价(+80~150%)
+
+
+ +

酒店实测:同档酒店跨窗口涨价

+
+
注:丽江阿丹阁 ¥774/4晚 四个窗口不变价(rgh 数据可能缓存,预订时复核);丽江云曼 5★ ¥1,084-1,163 国庆几乎不涨(隐藏性价比款)。
+
+ + +
+

酒店价格筛选(78 组实测)

+
+ 窗口: +
+ 城市: +
+ 星级: + + + +
+
+
+ + +
+

餐厅与口碑住宿

+
+ 区域: +
+ + + + +
+ 置信度: +
+ + + +
+
+

🍜 餐厅(每家 = 店名 / 在哪 / 吃什么 / 为什么去)

+
+ +

🏡 口碑住宿(小红书验证;rgh 无库存需平台订)

+
+
+ + +
+

5 天 4 晚每日行程(方向 A:大理进 → 丽江出)

+
+
路线:广州/北京 ✈️ 大理(会合取车)→ 洱海西线沙溪(顺路)→ 丽江 → 4 人 ✈️ 返广州。
+ 全程只换 2 次酒店(大理 2 晚 + 丽江 2 晚);每日 1-2 个主景点 + 咖啡/午休;总车程约 9-11h / 5 天。
+
+
+
+ + +
+

预算计算器

+
+
+

① 选窗口

+
+

② 广州出发 2 人机票

+
往返 ¥3,000-4,000 / 人(错峰)
+
往返 ¥4,500-6,000 / 人(国庆段)
+
往返 ¥6,500-9,000 / 人(双高峰)
+

③ 北京出发 2 人机票

+
往返 ¥3,800-5,000 / 人(错峰)
+
往返 ¥5,500-7,500 / 人(国庆段)
+
往返 ¥8,000-11,000 / 人(双高峰)
+
注:北京 2 人返程按"云南→广州"计(返程同城飞广州是硬要求,不回北京)。
+
+
+

④ 酒店档位(2 间 × 4 晚)

+
+

⑤ 共同开销(租车/吃/门票,4 人固定)

+
租车+油费+异地还车(4天)¥1,800
+
餐饮(4人×4天+小吃)¥4,320
+
门票杂费(木府/停车等)¥800
+
+
4 人合计(估算中值)
+
¥0 / 4人
+
人均 ≈ ¥0 · 广州2人机票含 0 · 北京2人含 0
+
+
区间会随你选的窗口/档位联动(酒店取所选窗口 rgh 实测,机票按档取中值)。
+
+
+
+ + +
+

🗺️ 炫酷路线地图 出发 → 住宿 → 景点 · 自绘暗色 SVG · 节点可点/日期可高亮

+
+ +
+
+
怎么看:灰实线 = 每日车程(点右侧日期卡高亮当日路线+动画)· ✈ 弧线 = 飞机(广州2人/北京2人 → 大理机场会合;Day5 丽江4人同机返广州)· 🏨 金圈 = 住宿基地(大理2晚+丽江2晚)· 🍜 橙圈 = 餐饮 · 📸 紫圈 = 景点驿站 · 🏔️ = 玉龙雪山(可选)。洱海发光蓝域在中央。
+
+
+ + +
+

预订清单 · 交通 · 停车 · 天气备选

+

🔖 预订优先级

+
+
+
+

🚗 租车 & 异地还车

+
+
+
+

🅿️ 停车攻略

+
+
+
+

🌤️ 天气 · 高反 · 备选

+
+
+ + +
+ + + + + diff --git a/app/globals.css b/app/globals.css index 4010ec13d..489a2146d 100644 --- a/app/globals.css +++ b/app/globals.css @@ -649,6 +649,40 @@ button.extension-widget-trigger:focus-visible { line-height: 1.45; white-space: pre; } + +/* Footer-placement widgets (e.g. ctx.ui.setFooter registrations like + @liziy/token-stats) render as a full-width multi-line block above the + status-shelf children. They intentionally bypass the 108px trigger chip + treatment since their content is meant to span the whole footer. */ +.extension-footer-widgets { + display: flex; + flex: 0 0 100%; + flex-direction: column; + min-width: 0; + border-bottom: 1px solid color-mix(in srgb, var(--border) 78%, transparent); +} + +.extension-footer-widget { + position: relative; + min-width: 0; + padding: 4px 12px 5px; + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 11px; + line-height: 1.4; + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + white-space: pre; +} + +.extension-footer-widget + .extension-footer-widget { + border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent); +} + +.extension-footer-widget.is-updating { + color: var(--text); +} ::-webkit-scrollbar-thumb:hover { background: var(--text-dim); } diff --git a/app/layout.tsx b/app/layout.tsx index 87786fca6..9bba5e6ed 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,4 +1,5 @@ import type { Metadata, Viewport } from "next"; +import Script from "next/script"; import { Noto_Sans_Mono } from "next/font/google"; import { PwaRegistration } from "@/components/PwaRegistration"; import { THEME_INIT_SCRIPT } from "@/lib/theme"; @@ -12,6 +13,8 @@ const notoSansMono = Noto_Sans_Mono({ display: "swap", }); +const basePath = process.env.PI_WEB_BASE_PATH ?? ""; + export const metadata: Metadata = { title: "Pi Web", description: "Pi Web interface for the pi coding agent", @@ -20,14 +23,14 @@ export const metadata: Metadata = { icons: { icon: [ { - url: "/icons/icon-192.png", + url: `${basePath}/icons/icon-192.png`, sizes: "192x192", type: "image/png", }, ], apple: [ { - url: "/icons/apple-touch-icon.png", + url: `${basePath}/icons/apple-touch-icon.png`, sizes: "180x180", type: "image/png", }, @@ -60,7 +63,12 @@ export default function RootLayout({ children: React.ReactNode; }) { return ( - + {children} diff --git a/app/login/page.tsx b/app/login/page.tsx index 0ca08198a..feba2c5fb 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -1,12 +1,17 @@ "use client"; -import Image from "next/image"; import { useState, type FormEvent } from "react"; import { I18nProvider, useI18n } from "@/hooks/useI18n"; function safeDestination(): string { const destination = new URLSearchParams(window.location.search).get("next"); - return destination?.startsWith("/") && !destination.startsWith("//") ? destination : "/"; + const internal = + destination?.startsWith("/") && !destination.startsWith("//") + ? destination + : "/"; + // The next param is an internal path (no basePath); window.location is a raw + // browser API and does not apply Next's basePath, so prepend it manually. + return `${process.env.NEXT_PUBLIC_BASE_PATH ?? ""}${internal}`; } function LoginForm() { @@ -26,7 +31,11 @@ function LoginForm() { body: JSON.stringify({ password }), }); if (!response.ok) { - setError(response.status === 401 ? t("auth.invalidPassword") : t("auth.loginFailed")); + setError( + response.status === 401 + ? t("auth.invalidPassword") + : t("auth.loginFailed"), + ); return; } window.location.replace(safeDestination()); @@ -41,7 +50,13 @@ function LoginForm() {
- +

Pi Web

{t("auth.prompt")}

@@ -49,7 +64,9 @@ function LoginForm() {
- +
-

{error}

+

+ {error} +

@@ -77,5 +106,9 @@ function LoginForm() { } export default function LoginPage() { - return ; + return ( + + + + ); } diff --git a/app/manifest.ts b/app/manifest.ts index 5f9a2018a..24f0f4fa1 100644 --- a/app/manifest.ts +++ b/app/manifest.ts @@ -1,31 +1,33 @@ import type { MetadataRoute } from "next"; +const basePath = process.env.PI_WEB_BASE_PATH ?? ""; + export default function manifest(): MetadataRoute.Manifest { - return { - id: "/", - name: "Pi Web", - short_name: "Pi Web", - description: "Local web interface for the pi coding agent", - start_url: "/", - scope: "/", - display: "standalone", - background_color: "#ffffff", - theme_color: "#1a1a1a", - categories: ["developer", "productivity"], - lang: "en", - icons: [ - { - src: "/icons/icon-192.png", - sizes: "192x192", - type: "image/png", - purpose: "any", - }, - { - src: "/icons/icon-512.png", - sizes: "512x512", - type: "image/png", - purpose: "any", - }, - ], - }; + return { + id: `${basePath}/`, + name: "Pi Web", + short_name: "Pi Web", + description: "Local web interface for the pi coding agent", + start_url: `${basePath}/`, + scope: `${basePath}/`, + display: "standalone", + background_color: "#1a1a1a", + theme_color: "#1a1a1a", + categories: ["developer", "productivity"], + lang: "en", + icons: [ + { + src: `${basePath}/icons/icon-192.png`, + sizes: "192x192", + type: "image/png", + purpose: "any", + }, + { + src: `${basePath}/icons/icon-512.png`, + sizes: "512x512", + type: "image/png", + purpose: "any", + }, + ], + }; } diff --git a/components/AppShell.tsx b/components/AppShell.tsx index 86a1f4d42..6e3265de5 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -1,7 +1,14 @@ "use client"; -import { useState, useCallback, useRef, useEffect, useLayoutEffect, useMemo } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; +import { + useState, + useCallback, + useRef, + useEffect, + useLayoutEffect, + useMemo, +} from "react"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useGlobalKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts"; import { SessionSidebar } from "./SessionSidebar"; import { ChatWindow } from "./ChatWindow"; @@ -16,7 +23,12 @@ import { SystemPromptPanel } from "./SystemPromptPanel"; import { ToolDefinitionsPanel } from "./ToolDefinitionsPanel"; import { AgentSessionPanel } from "./AgentSessionPanel"; import { TerminalPanel } from "./TerminalPanel"; -import { newTerminalTab, restoreTerminalTabs, TERMINAL_TABS_KEY, type TerminalTab } from "./terminal-tab-state"; +import { + newTerminalTab, + restoreTerminalTabs, + TERMINAL_TABS_KEY, + type TerminalTab, +} from "./terminal-tab-state"; import { useTheme } from "@/hooks/useTheme"; import { THEME_OPTIONS } from "@/lib/theme"; import { ThemeIcon } from "./ThemeIcon"; @@ -28,7 +40,11 @@ import { useAudio } from "@/hooks/useAudio"; import { copyText } from "@/lib/clipboard"; import { sendAgentCommand } from "@/lib/agent-client"; import { getFileName } from "@/lib/file-paths"; -import { buildAtMentionText, buildFileAtMentionsText, buildFileLineMentionText } from "@/lib/file-fuzzy"; +import { + buildAtMentionText, + buildFileAtMentionsText, + buildFileLineMentionText, +} from "@/lib/file-fuzzy"; import { claimExtensionAttentionNotification, shouldShowBrowserNotification, @@ -54,16 +70,28 @@ import { SIDEBAR_MAX_WIDTH, SIDEBAR_MIN_WIDTH, } from "@/lib/panel-layout"; -import type { BlockingExtensionUiRequest, SessionInfo, SessionTreeNode } from "@/lib/types"; +import type { + BlockingExtensionUiRequest, + SessionInfo, + SessionTreeNode, +} from "@/lib/types"; import type { ProjectTrustStatus } from "@/lib/api-types"; import type { ChatInputHandle } from "./ChatInput"; import type { SessionStatsInfo } from "@/lib/pi-types"; import type { FileViewerState } from "@/lib/file-viewer-state"; import type { ToolEntry } from "@/lib/tool-presets"; import { getSessionFamily } from "@/lib/session-family"; -import { getLastSettingsSection, type SettingsSection } from "@/lib/settings-navigation"; +import { + getLastSettingsSection, + type SettingsSection, +} from "@/lib/settings-navigation"; -type SessionCopyField = "file" | "id" | "projectDir" | "gitBranch" | "gitWorktree"; +type SessionCopyField = + | "file" + | "id" + | "projectDir" + | "gitBranch" + | "gitWorktree"; type AutoNameStatus = | { kind: "idle" } | { kind: "naming" } @@ -80,8 +108,11 @@ function parkedNewSessionDraftKey(cwd: string): string { export function AppShell() { const router = useRouter(); + const pathname = usePathname(); const searchParams = useSearchParams(); - const [initialNavigation] = useState(() => getInitialNavigation(searchParams)); + const [initialNavigation] = useState(() => + getInitialNavigation(searchParams), + ); const { preference, setThemePreference } = useTheme(); const themeLabelKey = `theme.${preference}`; const { locale, setLocale, t: translate, supportedLocales } = useI18n(); @@ -100,11 +131,19 @@ export function AppShell() { // Audio ownership lives here (not in ChatWindow) so the completion tone can // also fire for tasks finishing in a non-active workspace whose ChatWindow // is not mounted. ChatWindow receives the audio callbacks as props. - const { soundEnabled, onSoundToggle, playDoneSound, unlockAudio, soundEnabledRef } = useAudio(); + const { + soundEnabled, + onSoundToggle, + playDoneSound, + unlockAudio, + soundEnabledRef, + } = useAudio(); const [quoteSelectionEnabled, setQuoteSelectionEnabled] = useState(false); useEffect(() => { try { - setQuoteSelectionEnabled(localStorage.getItem("pi-quote-selection-enabled") === "true"); + setQuoteSelectionEnabled( + localStorage.getItem("pi-quote-selection-enabled") === "true", + ); } catch { // Browser storage is best-effort. } @@ -121,7 +160,9 @@ export function AppShell() { const handleBackgroundTaskDone = useCallback(() => { if (soundEnabledRef.current) playDoneSound(); }, [playDoneSound, soundEnabledRef]); - const [selectedSession, setSelectedSession] = useState(null); + const [selectedSession, setSelectedSession] = useState( + null, + ); const [sessionCatalog, setSessionCatalog] = useState([]); const handleSessionsChange = useCallback((sessions: SessionInfo[]) => { setSessionCatalog(sessions); @@ -138,10 +179,16 @@ export function AppShell() { [selectedSession?.id, sessionsWithSelection], ); const hasSubagentSessions = Boolean(activeSessionFamily?.subagents.length); - const [runningSessionIds, setRunningSessionIds] = useState>(() => new Set()); + const [runningSessionIds, setRunningSessionIds] = useState>( + () => new Set(), + ); const handleRunningSessionIdsChange = useCallback((ids: Set) => { setRunningSessionIds((previous) => { - if (previous.size === ids.size && [...ids].every((id) => previous.has(id))) return previous; + if ( + previous.size === ids.size && + [...ids].every((id) => previous.has(id)) + ) + return previous; return ids; }); }, []); @@ -149,27 +196,44 @@ export function AppShell() { const [newSessionCwd, setNewSessionCwd] = useState(null); const [newSessionDraftId, setNewSessionDraftId] = useState("initial"); const activeNewSessionDraftKeyRef = useRef(null); - const [initialCwdStatus, setInitialCwdStatus] = useState<"idle" | "validating" | "ready" | "error">( - () => initialNavigation.requestedCwd ? "validating" : "idle", - ); + const [initialCwdStatus, setInitialCwdStatus] = useState< + "idle" | "validating" | "ready" | "error" + >(() => (initialNavigation.requestedCwd ? "validating" : "idle")); const [initialCwdError, setInitialCwdError] = useState(null); const [refreshKey, setRefreshKey] = useState(0); const [sessionKey, setSessionKey] = useState(0); - const sessionScrollPositionsRef = useRef(new Map()); - const handleSessionScrollPositionChange = useCallback((sessionId: string, position: ChatScrollPosition) => { - sessionScrollPositionsRef.current.set(sessionId, position); - }, []); - const [searchTarget, setSearchTarget] = useState<{ sessionId: string; entryId: string; blockIndex?: number } | null>(null); - const handleSearchTargetHandled = useCallback((target: { sessionId: string; entryId: string }) => { - setSearchTarget((current) => current === target ? null : current); - }, []); + const sessionScrollPositionsRef = useRef( + new Map(), + ); + const handleSessionScrollPositionChange = useCallback( + (sessionId: string, position: ChatScrollPosition) => { + sessionScrollPositionsRef.current.set(sessionId, position); + }, + [], + ); + const [searchTarget, setSearchTarget] = useState<{ + sessionId: string; + entryId: string; + blockIndex?: number; + } | null>(null); + const handleSearchTargetHandled = useCallback( + (target: { sessionId: string; entryId: string }) => { + setSearchTarget((current) => (current === target ? null : current)); + }, + [], + ); const [explorerRefreshKey, setExplorerRefreshKey] = useState(0); - const [settingsSection, setSettingsSection] = useState(null); + const [settingsSection, setSettingsSection] = + useState(null); const [modelsRefreshKey, setModelsRefreshKey] = useState(0); - const [projectTrust, setProjectTrust] = useState(null); + const [projectTrust, setProjectTrust] = useState( + null, + ); const [projectTrustDialogOpen, setProjectTrustDialogOpen] = useState(false); const [projectTrustBusy, setProjectTrustBusy] = useState(false); - const [projectTrustError, setProjectTrustError] = useState(null); + const [projectTrustError, setProjectTrustError] = useState( + null, + ); const [sidebarOpen, setSidebarOpen] = useState(true); const [rightPanelOpen, setRightPanelOpen] = useState(false); const [mobileToolbarMoreOpen, setMobileToolbarMoreOpen] = useState(false); @@ -177,29 +241,32 @@ export function AppShell() { const sidebarWidthRef = useRef(SIDEBAR_DEFAULT_WIDTH); const rightPanelWidthRef = useRef(RIGHT_PANEL_FALLBACK_WIDTH); const getResponsiveRightPanelWidth = useCallback( - () => typeof window === "undefined" - ? RIGHT_PANEL_FALLBACK_WIDTH - : getDefaultRightPanelWidth(window.innerWidth), + () => + typeof window === "undefined" + ? RIGHT_PANEL_FALLBACK_WIDTH + : getDefaultRightPanelWidth(window.innerWidth), [], ); const getResponsiveSidebarMaxWidth = useCallback( - () => typeof window === "undefined" - ? SIDEBAR_MAX_WIDTH - : getSidebarMaxWidth({ - viewportWidth: window.innerWidth, - rightPanelOpen, - rightPanelWidth: rightPanelWidthRef.current, - }), + () => + typeof window === "undefined" + ? SIDEBAR_MAX_WIDTH + : getSidebarMaxWidth({ + viewportWidth: window.innerWidth, + rightPanelOpen, + rightPanelWidth: rightPanelWidthRef.current, + }), [rightPanelOpen], ); const getResponsiveRightPanelMaxWidth = useCallback( - () => typeof window === "undefined" - ? RIGHT_PANEL_MAX_WIDTH - : getRightPanelMaxWidth({ - viewportWidth: window.innerWidth, - sidebarOpen, - sidebarWidth: sidebarWidthRef.current, - }), + () => + typeof window === "undefined" + ? RIGHT_PANEL_MAX_WIDTH + : getRightPanelMaxWidth({ + viewportWidth: window.innerWidth, + sidebarOpen, + sidebarWidth: sidebarWidthRef.current, + }), [sidebarOpen], ); const sidebarResizer = useResizablePanel({ @@ -241,7 +308,10 @@ export function AppShell() { reclampRightPanelWidth(); }, [reclampRightPanelWidth, reclampSidebarWidth, rightPanelOpen]); const chatInputRef = useRef(null); - const [pendingQuotePrompt, setPendingQuotePrompt] = useState<{ sessionId: string; text: string } | null>(null); + const [pendingQuotePrompt, setPendingQuotePrompt] = useState<{ + sessionId: string; + text: string; + } | null>(null); const topBarRef = useRef(null); const mobileToolbarRef = useRef(null); const themeBtnRef = useRef(null); @@ -250,15 +320,26 @@ export function AppShell() { // Branch navigator state — populated by ChatWindow via onBranchDataChange const [branchTree, setBranchTree] = useState([]); - const [branchActiveLeafId, setBranchActiveLeafId] = useState(null); - const branchLeafChangeFnRef = useRef<((leafId: string | null) => void) | null>(null); + const [branchActiveLeafId, setBranchActiveLeafId] = useState( + null, + ); + const branchLeafChangeFnRef = useRef< + ((leafId: string | null) => void) | null + >(null); const sessionHasBranches = hasSessionBranches(branchTree); - const handleBranchDataChange = useCallback((tree: SessionTreeNode[], activeLeafId: string | null, onLeafChange: (leafId: string | null) => void) => { - setBranchTree(tree); - setBranchActiveLeafId(activeLeafId); - branchLeafChangeFnRef.current = onLeafChange; - }, []); + 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); @@ -280,89 +361,149 @@ export function AppShell() { setSystemTools(tools); }, []); - const handleSystemInfoLoaderChange = useCallback((loader: (() => Promise) | null) => { - systemInfoLoadIdRef.current += 1; - systemInfoLoaderRef.current = loader; - setSystemInfoLoading(false); - }, []); + const handleSystemInfoLoaderChange = useCallback( + (loader: (() => Promise) | null) => { + systemInfoLoadIdRef.current += 1; + systemInfoLoaderRef.current = loader; + setSystemInfoLoading(false); + }, + [], + ); // Session stats (tokens + cost) — populated by ChatWindow, displayed in top bar - const [sessionStats, setSessionStats] = useState(null); - const [autoNameStatus, setAutoNameStatus] = useState({ kind: "idle" }); + const [sessionStats, setSessionStats] = useState( + null, + ); + const [autoNameStatus, setAutoNameStatus] = useState({ + kind: "idle", + }); const autoNameTimerRef = useRef | null>(null); const activeSessionIdRef = useRef(selectedSession?.id ?? null); activeSessionIdRef.current = selectedSession?.id ?? null; - const handleSessionStatsChange = useCallback((stats: SessionStatsInfo | null) => { - setSessionStats(stats); - }, []); - const [copiedSessionField, setCopiedSessionField] = useState(null); - const sessionCopyTimerRef = useRef | null>(null); - const handleCopySessionField = useCallback((field: SessionCopyField, value: string) => { - void copyText(value).then(() => { - if (sessionCopyTimerRef.current) clearTimeout(sessionCopyTimerRef.current); - setCopiedSessionField(field); - sessionCopyTimerRef.current = setTimeout(() => setCopiedSessionField(null), 1400); - }); - }, []); + const handleSessionStatsChange = useCallback( + (stats: SessionStatsInfo | null) => { + setSessionStats(stats); + }, + [], + ); + const [copiedSessionField, setCopiedSessionField] = + useState(null); + const sessionCopyTimerRef = useRef | null>( + null, + ); + const handleCopySessionField = useCallback( + (field: SessionCopyField, value: string) => { + void copyText(value).then(() => { + if (sessionCopyTimerRef.current) + clearTimeout(sessionCopyTimerRef.current); + setCopiedSessionField(field); + sessionCopyTimerRef.current = setTimeout( + () => setCopiedSessionField(null), + 1400, + ); + }); + }, + [], + ); useEffect(() => { return () => { - if (sessionCopyTimerRef.current) clearTimeout(sessionCopyTimerRef.current); + if (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); - }, []); + 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<"agents" | "branches" | "system" | "tools" | "session" | "language" | "theme" | null>(null); - const [topPanelPos, setTopPanelPos] = useState<{ top: number; left: number; width: number } | null>(null); + const [activeTopPanel, setActiveTopPanel] = useState< + | "agents" + | "branches" + | "system" + | "tools" + | "session" + | "language" + | "theme" + | null + >(null); + const [topPanelPos, setTopPanelPos] = useState<{ + top: number; + left: number; + width: number; + } | null>(null); useEffect(() => { if (!sessionHasBranches) { - setActiveTopPanel((panel) => panel === "branches" ? null : panel); + setActiveTopPanel((panel) => (panel === "branches" ? null : panel)); } }, [sessionHasBranches]); useEffect(() => { if (!hasSubagentSessions) { - setActiveTopPanel((panel) => panel === "agents" ? null : panel); + setActiveTopPanel((panel) => (panel === "agents" ? null : panel)); } }, [hasSubagentSessions]); - const toggleTopPanel = useCallback(( - panel: "agents" | "branches" | "system" | "tools" | "session" | "language" | "theme", - keepMobileToolbarOpen = false, - ) => { - if (isMobile) setSidebarOpen(false); - setActiveTopPanel((cur) => cur === panel ? null : panel); - if (isMobile && isNarrowMobile && keepMobileToolbarOpen) setMobileToolbarMoreOpen(true); - }, [isMobile, isNarrowMobile]); - - const handleSystemInfoToggle = useCallback(( - panel: "system" | "tools", - keepMobileToolbarOpen = false, - ) => { - const opening = activeTopPanel !== panel; - toggleTopPanel(panel, keepMobileToolbarOpen); - if (!opening || systemInfoLoading) return; - - const load = systemInfoLoaderRef.current; - if (!load) return; - const loadId = ++systemInfoLoadIdRef.current; - setSystemInfoLoading(true); - void load().catch((error) => { - console.error("Failed to load system information:", error); - }).finally(() => { - if (systemInfoLoadIdRef.current === loadId) { - setSystemInfoLoading(false); - } - }); - }, [activeTopPanel, systemInfoLoading, toggleTopPanel]); + const toggleTopPanel = useCallback( + ( + panel: + | "agents" + | "branches" + | "system" + | "tools" + | "session" + | "language" + | "theme", + keepMobileToolbarOpen = false, + ) => { + if (isMobile) setSidebarOpen(false); + setActiveTopPanel((cur) => (cur === panel ? null : panel)); + if (isMobile && isNarrowMobile && keepMobileToolbarOpen) + setMobileToolbarMoreOpen(true); + }, + [isMobile, isNarrowMobile], + ); + + const handleSystemInfoToggle = useCallback( + (panel: "system" | "tools", keepMobileToolbarOpen = false) => { + const opening = activeTopPanel !== panel; + toggleTopPanel(panel, keepMobileToolbarOpen); + if (!opening || systemInfoLoading) return; + + const load = systemInfoLoaderRef.current; + if (!load) return; + const loadId = ++systemInfoLoadIdRef.current; + setSystemInfoLoading(true); + void load() + .catch((error) => { + console.error("Failed to load system information:", error); + }) + .finally(() => { + if (systemInfoLoadIdRef.current === loadId) { + setSystemInfoLoading(false); + } + }); + }, + [activeTopPanel, systemInfoLoading, toggleTopPanel], + ); const openSessionStatsPanel = useCallback(() => { if (isMobile) setSidebarOpen(false); @@ -425,16 +566,29 @@ export function AppShell() { useLayoutEffect(() => { if (activeTopPanel !== "theme" && activeTopPanel !== "language") return; const menu = selectionMenuRef.current; - const trigger = activeTopPanel === "theme" ? themeBtnRef.current : languageBtnRef.current; + const trigger = + activeTopPanel === "theme" ? themeBtnRef.current : languageBtnRef.current; if (!menu || !trigger) return; - const items = Array.from(menu.querySelectorAll("[role=menuitemradio]")); - (items.find((item) => item.getAttribute("aria-checked") === "true") ?? items[0])?.focus(); + const items = Array.from( + menu.querySelectorAll("[role=menuitemradio]"), + ); + ( + items.find((item) => item.getAttribute("aria-checked") === "true") ?? + items[0] + )?.focus(); const dismissOutside = (event: Event) => { - if (event.composedPath().includes(menu) || event.composedPath().includes(trigger)) return; + if ( + event.composedPath().includes(menu) || + event.composedPath().includes(trigger) + ) + return; setActiveTopPanel(null); const toolbar = mobileToolbarRef.current; - if (event.type === "pointerdown" && (!toolbar || !event.composedPath().includes(toolbar))) { + if ( + event.type === "pointerdown" && + (!toolbar || !event.composedPath().includes(toolbar)) + ) { setMobileToolbarMoreOpen(false); } }; @@ -449,8 +603,13 @@ export function AppShell() { if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return; event.preventDefault(); const index = items.indexOf(document.activeElement as HTMLButtonElement); - const next = event.key === "Home" ? 0 : event.key === "End" ? items.length - 1 - : (index + (event.key === "ArrowDown" ? 1 : -1) + items.length) % items.length; + const next = + event.key === "Home" + ? 0 + : event.key === "End" + ? items.length - 1 + : (index + (event.key === "ArrowDown" ? 1 : -1) + items.length) % + items.length; items[next]?.focus(); }; document.addEventListener("pointerdown", dismissOutside, true); @@ -467,8 +626,14 @@ export function AppShell() { if (!activeTopPanel || !topBarRef.current) return; const update = () => { const topBarRect = topBarRef.current!.getBoundingClientRect(); - if ((activeTopPanel === "language" || activeTopPanel === "theme") && !isMobile) { - const button = activeTopPanel === "theme" ? themeBtnRef.current : languageBtnRef.current; + if ( + (activeTopPanel === "language" || activeTopPanel === "theme") && + !isMobile + ) { + const button = + activeTopPanel === "theme" + ? themeBtnRef.current + : languageBtnRef.current; if (!button) return; const buttonRect = button.getBoundingClientRect(); const width = Math.min(SELECTION_MENU_WIDTH, topBarRect.width); @@ -487,7 +652,11 @@ export function AppShell() { }); return; } - setTopPanelPos({ top: topBarRect.bottom, left: topBarRect.left, width: topBarRect.width }); + setTopPanelPos({ + top: topBarRect.bottom, + left: topBarRect.left, + width: topBarRect.width, + }); }; update(); const ro = new ResizeObserver(update); @@ -502,68 +671,103 @@ export function AppShell() { const [activeFileTabId, setActiveFileTabId] = useState(null); const [terminalTabs, setTerminalTabs] = useState([]); const [terminalsRestored, setTerminalsRestored] = useState(false); - const panelTabs: Tab[] = [...fileTabs, ...terminalTabs.map((tab) => ({ - id: tab.id, - label: getFileName(tab.cwd) || tab.cwd, - filePath: tab.cwd, - kind: "terminal" as const, - closing: Boolean(tab.closing), - }))]; + const panelTabs: Tab[] = [ + ...fileTabs, + ...terminalTabs.map((tab) => ({ + id: tab.id, + label: getFileName(tab.cwd) || tab.cwd, + filePath: tab.cwd, + kind: "terminal" as const, + closing: Boolean(tab.closing), + })), + ]; useEffect(() => { try { - const saved = restoreTerminalTabs(window.sessionStorage.getItem(TERMINAL_TABS_KEY)); + const saved = restoreTerminalTabs( + window.sessionStorage.getItem(TERMINAL_TABS_KEY), + ); setTerminalTabs(saved.tabs); if (saved.activeId) { setActiveFileTabId(saved.activeId); setRightPanelOpen(saved.open); } - } catch { /* storage is optional */ } + } catch { + /* storage is optional */ + } setTerminalsRestored(true); }, []); useEffect(() => { if (!terminalsRestored) return; try { - window.sessionStorage.setItem(TERMINAL_TABS_KEY, JSON.stringify({ - tabs: terminalTabs.map(({ id, cwd }) => ({ id, cwd })), - activeId: activeFileTabId, - open: rightPanelOpen, - })); - } catch { /* storage is optional */ } + window.sessionStorage.setItem( + TERMINAL_TABS_KEY, + JSON.stringify({ + tabs: terminalTabs.map(({ id, cwd }) => ({ id, cwd })), + activeId: activeFileTabId, + open: rightPanelOpen, + }), + ); + } catch { + /* storage is optional */ + } }, [terminalTabs, activeFileTabId, rightPanelOpen, terminalsRestored]); - const handleFileViewerStateChange = useCallback(( - tabId: string, - viewerRevision: number, - viewerState: FileViewerState, - ) => { - setFileTabs((prev) => saveFileViewerState(prev, tabId, viewerRevision, viewerState)); - }, []); + const handleFileViewerStateChange = useCallback( + (tabId: string, viewerRevision: number, viewerState: FileViewerState) => { + setFileTabs((prev) => + saveFileViewerState(prev, tabId, viewerRevision, viewerState), + ); + }, + [], + ); // Same @mention format as the chat input's @ autocomplete, so the agent's // read tool resolves it the same way (it strips the @ prefix). - const handleAtMention = useCallback((relativePath: string, isDir: boolean) => { - chatInputRef.current?.insertText(buildAtMentionText(relativePath, isDir)); - if (isMobile) { setRightPanelOpen(false); setSidebarOpen(false); } - }, [isMobile]); + const handleAtMention = useCallback( + (relativePath: string, isDir: boolean) => { + chatInputRef.current?.insertText(buildAtMentionText(relativePath, isDir)); + if (isMobile) { + setRightPanelOpen(false); + setSidebarOpen(false); + } + }, + [isMobile], + ); - const handleAtMentions = useCallback((relativePaths: string[]) => { - const mentions = buildFileAtMentionsText(relativePaths); - if (mentions) chatInputRef.current?.insertText(mentions); - if (isMobile) { setRightPanelOpen(false); setSidebarOpen(false); } - }, [isMobile]); + const handleAtMentions = useCallback( + (relativePaths: string[]) => { + const mentions = buildFileAtMentionsText(relativePaths); + if (mentions) chatInputRef.current?.insertText(mentions); + if (isMobile) { + setRightPanelOpen(false); + setSidebarOpen(false); + } + }, + [isMobile], + ); - const handleFileLineMention = useCallback((relativePath: string, startLine: number, endLine: number) => { - chatInputRef.current?.insertText(buildFileLineMentionText(relativePath, startLine, endLine)); - if (isMobile) { setRightPanelOpen(false); setSidebarOpen(false); } - }, [isMobile]); + const handleFileLineMention = useCallback( + (relativePath: string, startLine: number, endLine: number) => { + chatInputRef.current?.insertText( + buildFileLineMentionText(relativePath, startLine, endLine), + ); + if (isMobile) { + setRightPanelOpen(false); + setSidebarOpen(false); + } + }, + [isMobile], + ); const initialSessionId = initialNavigation.sessionId; const [activeCwd, setActiveCwd] = useState(null); const activeProjectKeyRef = useRef(null); // True once the initial ?session= URL param has been resolved (or confirmed absent) - const [initialSessionRestored, setInitialSessionRestored] = useState(() => !initialSessionId); + const [initialSessionRestored, setInitialSessionRestored] = useState( + () => !initialSessionId, + ); // Suppresses sessionKey bump in handleCwdChange during the initial URL restore const suppressCwdBumpRef = useRef(false); // Guards the async workspace restore so a slow response from an earlier @@ -579,9 +783,10 @@ export function AppShell() { // carry projectKey, so use the active project identity until hydration. useEffect(() => { if (!selectedSession) return; - const projectKey = selectedSession.projectKey - ?? activeProjectKeyRef.current - ?? workspaceKeyOf(selectedSession); + const projectKey = + selectedSession.projectKey ?? + activeProjectKeyRef.current ?? + workspaceKeyOf(selectedSession); setLastOpenSession(projectKey, selectedSession.id); }, [selectedSession]); @@ -600,7 +805,10 @@ export function AppShell() { signal: controller.signal, }) .then(async (response) => { - const data = await response.json().catch(() => ({})) as { cwd?: string; error?: string }; + const data = (await response.json().catch(() => ({}))) as { + cwd?: string; + error?: string; + }; if (!response.ok || !data.cwd) { throw new Error(data.error ?? `HTTP ${response.status}`); } @@ -616,7 +824,9 @@ export function AppShell() { }) .catch((error: unknown) => { if (controller.signal.aborted) return; - setInitialCwdError(error instanceof Error ? error.message : String(error)); + setInitialCwdError( + error instanceof Error ? error.message : String(error), + ); setInitialCwdStatus("error"); }); @@ -627,196 +837,243 @@ export function AppShell() { // from handleCwdChange once the outgoing context has been reset. The session // is looked up against the live list so a deleted or drifted session falls // back to the default welcome page instead of erroring. - const restoreWorkspaceContext = useCallback((projectKey: string, cwd: string) => { - const token = ++workspaceRestoreTokenRef.current; - const lastOpenSessionId = getLastOpenSession(projectKey); - if (!lastOpenSessionId) return; - void fetch("/api/sessions") - .then((r) => (r.ok ? (r.json() as Promise<{ sessions: SessionInfo[] }>) : null)) - .then((d) => { - if (token !== workspaceRestoreTokenRef.current) return; // stale switch - const s = d?.sessions.find((x) => x.id === lastOpenSessionId); - if (!s) { - // The list loaded but the remembered session is gone — forget it. - // When the list itself failed (d === null) keep the memory so a - // later switch retries the restore. - if (d) clearLastOpen(projectKey); - return; - } - if (workspaceKeyOf(s) !== projectKey) { - // Defensive: the remembered session drifted out of this workspace. - clearLastOpen(projectKey); - return; + const restoreWorkspaceContext = useCallback( + (projectKey: string, cwd: string) => { + const token = ++workspaceRestoreTokenRef.current; + const lastOpenSessionId = getLastOpenSession(projectKey); + if (!lastOpenSessionId) return; + void fetch("/api/sessions") + .then((r) => + r.ok ? (r.json() as Promise<{ sessions: SessionInfo[] }>) : null, + ) + .then((d) => { + if (token !== workspaceRestoreTokenRef.current) return; // stale switch + const s = d?.sessions.find((x) => x.id === lastOpenSessionId); + if (!s) { + // The list loaded but the remembered session is gone — forget it. + // When the list itself failed (d === null) keep the memory so a + // later switch retries the restore. + if (d) clearLastOpen(projectKey); + return; + } + if (workspaceKeyOf(s) !== projectKey) { + // Defensive: the remembered session drifted out of this workspace. + clearLastOpen(projectKey); + return; + } + // Keep the temporary composer's draft in its cwd, even when the + // remembered session belongs to another worktree of this project. + const activeDraftKey = activeNewSessionDraftKeyRef.current; + if (activeDraftKey) { + rekeyDraft(activeDraftKey, parkedNewSessionDraftKey(cwd)); + } + activeNewSessionDraftKeyRef.current = null; + // Selecting the session must remount the chat with the session + // present: useAgentSession loads content in a mount-only effect, so + // the null-session welcome mount from the switch would never load + // the restored session's messages. + setSelectedSession(s); + setSessionKey((k) => k + 1); + if ( + new URLSearchParams(window.location.search).get("session") !== s.id + ) { + router.replace(`?session=${encodeURIComponent(s.id)}`, { + scroll: false, + }); + } + }) + .catch(() => { + // Network hiccup: keep the remembered session for a later retry. + }); + }, + [router], + ); + + const handleCwdChange = useCallback( + ( + cwd: string | null, + projectRoot?: string | null, + projectKey?: string | null, + ) => { + invalidateWorkspaceRestore(); + const currentFreshCwd = newSessionCwd ?? activeCwd; + setActiveCwd(cwd); + // Skip if cwd is null (initial mount). + if (!cwd) return; + const newProject = projectKey ?? projectRoot ?? cwd; + const currentProject = + activeProjectKeyRef.current ?? + (selectedSession ? workspaceKeyOf(selectedSession) : null); + activeProjectKeyRef.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; + } + // The server may hydrate a normalized key after a custom cwd is already + // active. Updating identity for the exact same cwd is not a user switch. + if (currentFreshCwd === cwd && currentProject !== newProject) return; + // Existing sessions stay open when the worktree selector moves within the + // same project. A fresh composer must remount when its effective cwd moves, + // otherwise its already-created runtime would keep sending to the old cwd. + if ( + currentProject === newProject && + (selectedSession !== null || currentFreshCwd === cwd) + ) { + return; + } + // Close any session that belongs to a different project — it no longer + // matches the selected project directory. + const previousDraftKey = activeNewSessionDraftKeyRef.current; + if (previousDraftKey && currentFreshCwd) { + rekeyDraft(previousDraftKey, parkedNewSessionDraftKey(currentFreshCwd)); + } + const draftId = + typeof crypto.randomUUID === "function" + ? crypto.randomUUID() + : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; + const draftKey = `new:${draftId}:${cwd}`; + rekeyDraft(parkedNewSessionDraftKey(cwd), draftKey); + setNewSessionDraftId(draftId); + activeNewSessionDraftKeyRef.current = draftKey; + setSelectedSession(null); + setNewSessionCwd((prev) => { + if (prev && prev !== cwd) return null; + return prev; + }); + setSessionKey((k) => k + 1); + setBranchTree([]); + setBranchActiveLeafId(null); + setSystemPrompt(null); + setSystemTools(null); + setSystemInfoLoading(false); + setActiveTopPanel(null); + if (currentProject !== newProject) { + // File tabs are keyed by absolute path, so tabs opened in the previous + // project must not linger. Same-project worktree switches keep them. + setFileTabs([]); + if (!activeFileTabId || activeFileTabId.startsWith("file:")) { + setActiveFileTabId(null); + setRightPanelOpen(false); } - // Keep the temporary composer's draft in its cwd, even when the - // remembered session belongs to another worktree of this project. - const activeDraftKey = activeNewSessionDraftKeyRef.current; - if (activeDraftKey) { - rekeyDraft(activeDraftKey, parkedNewSessionDraftKey(cwd)); + // Restore the workspace we switched to: its last open session, or keep + // the default welcome page when none is remembered. + restoreWorkspaceContext(newProject, cwd); + } + router.replace(pathname, { scroll: false }); + }, + [ + activeCwd, + activeFileTabId, + invalidateWorkspaceRestore, + newSessionCwd, + pathname, + router, + selectedSession, + restoreWorkspaceContext, + ], + ); + + const handleSelectSession = useCallback( + ( + session: SessionInfo, + isRestore = false, + entryId?: string, + blockIndex?: number, + ) => { + setSearchTarget( + entryId ? { sessionId: session.id, entryId, blockIndex } : null, + ); + invalidateWorkspaceRestore(); + const activeDraftKey = activeNewSessionDraftKeyRef.current; + const activeDraftCwd = + newSessionCwd ?? (selectedSession === null ? activeCwd : null); + if (activeDraftKey && activeDraftCwd) { + rekeyDraft(activeDraftKey, parkedNewSessionDraftKey(activeDraftCwd)); + } + activeNewSessionDraftKeyRef.current = null; + // Adopt an explicitly selected session before the sidebar reports its cwd. + const projectKey = workspaceKeyOf(session); + if (activeProjectKeyRef.current !== projectKey) { + setFileTabs([]); + if (!activeFileTabId || activeFileTabId.startsWith("file:")) { + setActiveFileTabId(null); + setRightPanelOpen(false); } - activeNewSessionDraftKeyRef.current = null; - // Selecting the session must remount the chat with the session - // present: useAgentSession loads content in a mount-only effect, so - // the null-session welcome mount from the switch would never load - // the restored session's messages. - setSelectedSession(s); - setSessionKey((k) => k + 1); - if (new URLSearchParams(window.location.search).get("session") !== s.id) { - router.replace(`?session=${encodeURIComponent(s.id)}`, { scroll: false }); + setActiveTopPanel(null); + } + activeProjectKeyRef.current = projectKey; + // Re-clicking the already-open session must not remount the chat and + // re-run the full load/positioning cycle. Only skip when the effective + // cwd context already matches — otherwise a pending cwd move still needs + // the full re-select flow. + if (!isRestore && selectedSession) { + const sameProject = + workspaceKeyOf(selectedSession) === workspaceKeyOf(session); + if (selectedSession.id === session.id && sameProject) { + if (isMobile) setSidebarOpen(false); + return; } - }) - .catch(() => { - // Network hiccup: keep the remembered session for a later retry. - }); - }, [router]); - - const handleCwdChange = useCallback(( - cwd: string | null, - projectRoot?: string | null, - projectKey?: string | null, - ) => { - invalidateWorkspaceRestore(); - const currentFreshCwd = newSessionCwd ?? activeCwd; - setActiveCwd(cwd); - // Skip if cwd is null (initial mount). - if (!cwd) return; - const newProject = projectKey ?? projectRoot ?? cwd; - const currentProject = activeProjectKeyRef.current - ?? (selectedSession ? workspaceKeyOf(selectedSession) : null); - activeProjectKeyRef.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; - } - // The server may hydrate a normalized key after a custom cwd is already - // active. Updating identity for the exact same cwd is not a user switch. - if (currentFreshCwd === cwd && currentProject !== newProject) return; - // Existing sessions stay open when the worktree selector moves within the - // same project. A fresh composer must remount when its effective cwd moves, - // otherwise its already-created runtime would keep sending to the old cwd. - if ( - currentProject === newProject - && (selectedSession !== null || currentFreshCwd === cwd) - ) { - return; - } - // Close any session that belongs to a different project — it no longer - // matches the selected project directory. - const previousDraftKey = activeNewSessionDraftKeyRef.current; - if (previousDraftKey && currentFreshCwd) { - rekeyDraft(previousDraftKey, parkedNewSessionDraftKey(currentFreshCwd)); - } - const draftId = typeof crypto.randomUUID === "function" - ? crypto.randomUUID() - : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; - const draftKey = `new:${draftId}:${cwd}`; - rekeyDraft(parkedNewSessionDraftKey(cwd), draftKey); - setNewSessionDraftId(draftId); - activeNewSessionDraftKeyRef.current = draftKey; - setSelectedSession(null); - setNewSessionCwd((prev) => { - if (prev && prev !== cwd) return null; - return prev; - }); - setSessionKey((k) => k + 1); - setBranchTree([]); - setBranchActiveLeafId(null); - setSystemPrompt(null); - setSystemTools(null); - setSystemInfoLoading(false); - setActiveTopPanel(null); - if (currentProject !== newProject) { - // File tabs are keyed by absolute path, so tabs opened in the previous - // project must not linger. Same-project worktree switches keep them. - setFileTabs([]); - if (!activeFileTabId || activeFileTabId.startsWith("file:")) { - setActiveFileTabId(null); - setRightPanelOpen(false); } - // Restore the workspace we switched to: its last open session, or keep - // the default welcome page when none is remembered. - restoreWorkspaceContext(newProject, cwd); - } - router.replace(typeof window !== "undefined" ? window.location.pathname : "/", { scroll: false }); - }, [activeCwd, activeFileTabId, invalidateWorkspaceRestore, newSessionCwd, router, selectedSession, restoreWorkspaceContext]); - - const handleSelectSession = useCallback((session: SessionInfo, isRestore = false, entryId?: string, blockIndex?: number) => { - setSearchTarget(entryId ? { sessionId: session.id, entryId, blockIndex } : null); - invalidateWorkspaceRestore(); - const activeDraftKey = activeNewSessionDraftKeyRef.current; - const activeDraftCwd = newSessionCwd ?? (selectedSession === null ? activeCwd : null); - if (activeDraftKey && activeDraftCwd) { - rekeyDraft(activeDraftKey, parkedNewSessionDraftKey(activeDraftCwd)); - } - activeNewSessionDraftKeyRef.current = null; - // Adopt an explicitly selected session before the sidebar reports its cwd. - const projectKey = workspaceKeyOf(session); - if (activeProjectKeyRef.current !== projectKey) { - setFileTabs([]); - if (!activeFileTabId || activeFileTabId.startsWith("file:")) { - setActiveFileTabId(null); - setRightPanelOpen(false); + setNewSessionCwd(null); + setSelectedSession(session); + setSessionKey((k) => k + 1); + setBranchTree([]); + setBranchActiveLeafId(null); + branchLeafChangeFnRef.current = null; + setSystemPrompt(null); + setSystemTools(null); + setSystemInfoLoading(false); + setInitialSessionRestored(true); + // On mobile, collapse the overlay drawer so the chat is revealed after pick. + if (isMobile && !isRestore) setSidebarOpen(false); + if (isRestore) { + // Suppress the redundant sessionKey bump that would come from the + // onCwdChange effect firing after setSelectedCwd in the sidebar + suppressCwdBumpRef.current = true; } - setActiveTopPanel(null); - } - activeProjectKeyRef.current = projectKey; - // Re-clicking the already-open session must not remount the chat and - // re-run the full load/positioning cycle. Only skip when the effective - // cwd context already matches — otherwise a pending cwd move still needs - // the full re-select flow. - if (!isRestore && selectedSession) { - const sameProject = - workspaceKeyOf(selectedSession) === workspaceKeyOf(session); - if (selectedSession.id === session.id && sameProject) { - if (isMobile) setSidebarOpen(false); - return; + // 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, + }); } - } - setNewSessionCwd(null); - setSelectedSession(session); - setSessionKey((k) => k + 1); - setBranchTree([]); - setBranchActiveLeafId(null); - branchLeafChangeFnRef.current = null; - setSystemPrompt(null); - setSystemTools(null); - setSystemInfoLoading(false); - setInitialSessionRestored(true); - // On mobile, collapse the overlay drawer so the chat is revealed after pick. - if (isMobile && !isRestore) setSidebarOpen(false); - if (isRestore) { - // Suppress the redundant sessionKey bump that would come from the - // onCwdChange effect firing after setSelectedCwd in the sidebar - suppressCwdBumpRef.current = true; - } - // Skip router.replace when restoring from URL — the param is already correct - // and calling replace in production Next.js triggers a Suspense remount loop - if (!isRestore) { - router.replace(`?session=${encodeURIComponent(session.id)}`, { scroll: false }); - } - }, [activeCwd, activeFileTabId, invalidateWorkspaceRestore, router, isMobile, newSessionCwd, selectedSession]); - - const handleNewSession = useCallback((sessionId: string, cwd: string) => { - invalidateWorkspaceRestore(); - const draftKey = `new:${sessionId}:${cwd}`; - rekeyDraft(parkedNewSessionDraftKey(cwd), draftKey); - activeNewSessionDraftKeyRef.current = draftKey; - setNewSessionDraftId(sessionId); - setSelectedSession(null); - setNewSessionCwd(cwd); - setSessionKey((k) => k + 1); - setBranchTree([]); - setBranchActiveLeafId(null); - setSystemPrompt(null); - setSystemTools(null); - setSystemInfoLoading(false); - setActiveTopPanel(null); - if (isMobile) setSidebarOpen(false); - router.replace(typeof window !== "undefined" ? window.location.pathname : "/", { scroll: false }); - }, [invalidateWorkspaceRestore, router, isMobile]); + }, + [ + activeCwd, + activeFileTabId, + invalidateWorkspaceRestore, + router, + isMobile, + newSessionCwd, + selectedSession, + ], + ); + + const handleNewSession = useCallback( + (sessionId: string, cwd: string) => { + invalidateWorkspaceRestore(); + const draftKey = `new:${sessionId}:${cwd}`; + rekeyDraft(parkedNewSessionDraftKey(cwd), draftKey); + activeNewSessionDraftKeyRef.current = draftKey; + setNewSessionDraftId(sessionId); + setSelectedSession(null); + setNewSessionCwd(cwd); + setSessionKey((k) => k + 1); + setBranchTree([]); + setBranchActiveLeafId(null); + setSystemPrompt(null); + setSystemTools(null); + setSystemInfoLoading(false); + setActiveTopPanel(null); + if (isMobile) setSidebarOpen(false); + router.replace(pathname, { scroll: false }); + }, + [invalidateWorkspaceRestore, pathname, router, isMobile], + ); // Global keyboard shortcuts (handles Esc, Ctrl+Alt+N etc.) useGlobalKeyboardShortcuts({ @@ -830,81 +1087,106 @@ export function AppShell() { // worktrees right after creating a session doesn't close the chat. const hydrateSelectedSession = useCallback((sessionId: string) => { void fetch("/api/sessions", { cache: "no-store" }) - .then((r) => (r.ok ? (r.json() as Promise<{ sessions: SessionInfo[] }>) : null)) + .then((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) => ( + setSelectedSession((prev) => prev?.id === sessionId ? { ...prev, ...full, transient: full.transient ?? false } - : prev - )); + : prev, + ); }) .catch(() => {}); }, []); - const handleOpenSession = useCallback(async (sessionId: string) => { - try { - const response = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}`, { cache: "no-store" }); - const data = await response.json() as { info?: SessionInfo; error?: string }; - if (!response.ok || !data.info) throw new Error(data.error ?? `HTTP ${response.status}`); - handleSelectSession(data.info); - } catch (error) { - console.error("[pi-web] failed to open session:", error instanceof Error ? error.message : error); - } - }, [handleSelectSession]); + const handleOpenSession = useCallback( + async (sessionId: string) => { + try { + const response = await fetch( + `/api/sessions/${encodeURIComponent(sessionId)}`, + { cache: "no-store" }, + ); + const data = (await response.json()) as { + info?: SessionInfo; + error?: string; + }; + if (!response.ok || !data.info) + throw new Error(data.error ?? `HTTP ${response.status}`); + handleSelectSession(data.info); + } catch (error) { + console.error( + "[pi-web] failed to open session:", + error instanceof Error ? error.message : error, + ); + } + }, + [handleSelectSession], + ); // Called by ChatWindow when a new session gets its real id from pi - const handleSessionCreated = useCallback((session: SessionInfo, sourceDraftKey: string) => { - setRefreshKey((k) => k + 1); - if (activeNewSessionDraftKeyRef.current !== sourceDraftKey) return; - invalidateWorkspaceRestore(); - activeNewSessionDraftKeyRef.current = null; - setNewSessionCwd(null); - setSelectedSession(session); - hydrateSelectedSession(session.id); - router.replace(`?session=${encodeURIComponent(session.id)}`, { scroll: false }); - }, [invalidateWorkspaceRestore, router, hydrateSelectedSession]); - - const deliverSessionNotification = useCallback(({ - targetSession, - title, - body, - tag, - }: { - targetSession: SessionInfo | null; - title: string; - body: string; - tag?: string; - }) => { - if (!("Notification" in window)) return; - - const fire = () => { - const sessionUrl = targetSession ? `/?session=${encodeURIComponent(targetSession.id)}` : "/"; - void showBrowserNotification({ - title, - body, - sessionUrl, - tag, - onClick: () => { - window.focus(); - if (targetSession) handleSelectSession(targetSession); - }, + const handleSessionCreated = useCallback( + (session: SessionInfo, sourceDraftKey: string) => { + setRefreshKey((k) => k + 1); + if (activeNewSessionDraftKeyRef.current !== sourceDraftKey) return; + invalidateWorkspaceRestore(); + activeNewSessionDraftKeyRef.current = null; + setNewSessionCwd(null); + setSelectedSession(session); + hydrateSelectedSession(session.id); + router.replace(`?session=${encodeURIComponent(session.id)}`, { + scroll: false, }); - }; + }, + [invalidateWorkspaceRestore, router, hydrateSelectedSession], + ); - if (Notification.permission === "granted") { - fire(); - void setupPushSubscription(locale); - } else if (Notification.permission === "default") { - void Notification.requestPermission().then((p) => { - if (p === "granted") { - fire(); - void setupPushSubscription(locale); - } - }); - } - }, [handleSelectSession, locale]); + const deliverSessionNotification = useCallback( + ({ + targetSession, + title, + body, + tag, + }: { + targetSession: SessionInfo | null; + title: string; + body: string; + tag?: string; + }) => { + if (!("Notification" in window)) return; + + const fire = () => { + const sessionUrl = targetSession + ? `/?session=${encodeURIComponent(targetSession.id)}` + : "/"; + void showBrowserNotification({ + title, + body, + sessionUrl, + tag, + onClick: () => { + window.focus(); + if (targetSession) handleSelectSession(targetSession); + }, + }); + }; + + if (Notification.permission === "granted") { + fire(); + void setupPushSubscription(locale); + } else if (Notification.permission === "default") { + void Notification.requestPermission().then((p) => { + if (p === "granted") { + fire(); + void setupPushSubscription(locale); + } + }); + } + }, + [handleSelectSession, locale], + ); const handleAgentEnd = useCallback(() => { setRefreshKey((k) => k + 1); @@ -918,24 +1200,41 @@ export function AppShell() { targetSession, title: targetSession?.name ?? translate("i18n.sessionComplete"), body: translate("i18n.taskFinished"), - tag: targetSession ? `pi-session-complete:${targetSession.id}` : "pi-session-complete", + tag: targetSession + ? `pi-session-complete:${targetSession.id}` + : "pi-session-complete", }); - }, [deliverSessionNotification, hydrateSelectedSession, selectedSession, translate]); + }, [ + deliverSessionNotification, + hydrateSelectedSession, + selectedSession, + translate, + ]); - const handleAttentionNeeded = useCallback((request: BlockingExtensionUiRequest) => { - if (selectedSession?.relation?.kind === "subagent") return; - if (!shouldShowBrowserNotification()) return; - if (!claimExtensionAttentionNotification(request, notifiedAttentionRequestIdsRef.current)) return; + const handleAttentionNeeded = useCallback( + (request: BlockingExtensionUiRequest) => { + if (selectedSession?.relation?.kind === "subagent") return; + if (!shouldShowBrowserNotification()) return; + if ( + !claimExtensionAttentionNotification( + request, + notifiedAttentionRequestIdsRef.current, + ) + ) + return; - deliverSessionNotification({ - targetSession: selectedSession, - title: translate("i18n.attentionNeeded"), - body: request.method === "custom" - ? translate("i18n.extensionInputNeeded") - : request.title, - tag: `pi-extension-ui:${request.id}`, - }); - }, [deliverSessionNotification, selectedSession, translate]); + deliverSessionNotification({ + targetSession: selectedSession, + title: translate("i18n.attentionNeeded"), + body: + request.method === "custom" + ? translate("i18n.extensionInputNeeded") + : request.title, + tag: `pi-extension-ui:${request.id}`, + }); + }, + [deliverSessionNotification, selectedSession, translate], + ); const handleAutoName = useCallback(async () => { const sessionId = selectedSession?.id; @@ -945,10 +1244,16 @@ export function AppShell() { 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 }; + 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}`); } @@ -956,15 +1261,27 @@ export function AppShell() { 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); + 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); + 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); + autoNameTimerRef.current = setTimeout( + () => setAutoNameStatus({ kind: "idle" }), + 5000, + ); } }, [autoNameStatus.kind, selectedSession?.id]); @@ -977,120 +1294,177 @@ export function AppShell() { setExplorerRefreshKey((k) => k + 1); }, []); - const handleSessionForked = useCallback((newSessionId: string) => { - invalidateWorkspaceRestore(); - activeNewSessionDraftKeyRef.current = null; - setRefreshKey((k) => k + 1); - setSessionKey((k) => k + 1); - setNewSessionCwd(null); - setSelectedSession((prev) => ({ - ...(prev ?? { path: "", cwd: "", created: "", modified: "", messageCount: 0, firstMessage: "" }), - id: newSessionId, - transient: false, - })); - hydrateSelectedSession(newSessionId); - router.replace(`?session=${encodeURIComponent(newSessionId)}`, { scroll: false }); - }, [invalidateWorkspaceRestore, router, hydrateSelectedSession]); - - const handleAskInNewChat = useCallback(async ( - prompt: string, - sourceSessionId: string, - sourceEntryId: string, - ) => { - const result = await sendAgentCommand<{ newSessionId?: string }>(sourceSessionId, { - type: "fork_branch", - entryId: sourceEntryId, - }); - if (!result?.newSessionId) throw new Error(translate("chat.quoteForkFailed")); - setPendingQuotePrompt({ sessionId: result.newSessionId, text: prompt }); - handleSessionForked(result.newSessionId); - }, [handleSessionForked, translate]); + const handleSessionForked = useCallback( + (newSessionId: string) => { + invalidateWorkspaceRestore(); + activeNewSessionDraftKeyRef.current = null; + setRefreshKey((k) => k + 1); + setSessionKey((k) => k + 1); + setNewSessionCwd(null); + setSelectedSession((prev) => ({ + ...(prev ?? { + path: "", + cwd: "", + created: "", + modified: "", + messageCount: 0, + firstMessage: "", + }), + id: newSessionId, + transient: false, + })); + hydrateSelectedSession(newSessionId); + router.replace(`?session=${encodeURIComponent(newSessionId)}`, { + scroll: false, + }); + }, + [invalidateWorkspaceRestore, router, hydrateSelectedSession], + ); + + const handleAskInNewChat = useCallback( + async (prompt: string, sourceSessionId: string, sourceEntryId: string) => { + const result = await sendAgentCommand<{ newSessionId?: string }>( + sourceSessionId, + { + type: "fork_branch", + entryId: sourceEntryId, + }, + ); + if (!result?.newSessionId) + throw new Error(translate("chat.quoteForkFailed")); + setPendingQuotePrompt({ sessionId: result.newSessionId, text: prompt }); + handleSessionForked(result.newSessionId); + }, + [handleSessionForked, translate], + ); const handleInitialRestoreDone = useCallback(() => { setInitialSessionRestored(true); }, []); - const handleSessionDeleted = useCallback((sessionId: string) => { - invalidateWorkspaceRestore(); - setRefreshKey((k) => k + 1); - if (selectedSession?.id === sessionId) { - const cwd = selectedSession.cwd; - const draftId = typeof crypto.randomUUID === "function" - ? crypto.randomUUID() - : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; - setNewSessionDraftId(draftId); - activeNewSessionDraftKeyRef.current = cwd ? `new:${draftId}:${cwd}` : null; - setSelectedSession(null); - setNewSessionCwd(cwd ?? null); - setSessionKey((k) => k + 1); - setBranchTree([]); - setBranchActiveLeafId(null); - setSystemPrompt(null); - setSystemTools(null); - setSystemInfoLoading(false); - setActiveTopPanel(null); - router.replace(typeof window !== "undefined" ? window.location.pathname : "/", { scroll: false }); - } - }, [invalidateWorkspaceRestore, selectedSession, router]); - - const handleOpenFile = useCallback(( - filePath: string, - fileName: string, - options?: { sourceSessionId?: string | null; modeHint?: "diff" }, - ) => { - const sourceSessionId = options?.sourceSessionId; - const modeHint = options?.modeHint; - const tabId = `file:${filePath}`; - setFileTabs((prev) => openFileTab(prev, { - fileName, - filePath, - modeHint, - sourceSessionId, - tabId, - })); - setActiveFileTabId(tabId); - setRightPanelOpen(true); - // On mobile the file panel is full-screen; close the drawer so it shows. - if (isMobile) setSidebarOpen(false); - }, [isMobile]); + const handleSessionDeleted = useCallback( + (sessionId: string) => { + invalidateWorkspaceRestore(); + setRefreshKey((k) => k + 1); + if (selectedSession?.id === sessionId) { + const cwd = selectedSession.cwd; + const draftId = + typeof crypto.randomUUID === "function" + ? crypto.randomUUID() + : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; + setNewSessionDraftId(draftId); + activeNewSessionDraftKeyRef.current = cwd + ? `new:${draftId}:${cwd}` + : null; + setSelectedSession(null); + setNewSessionCwd(cwd ?? null); + setSessionKey((k) => k + 1); + setBranchTree([]); + setBranchActiveLeafId(null); + setSystemPrompt(null); + setSystemTools(null); + setSystemInfoLoading(false); + setActiveTopPanel(null); + router.replace(pathname, { scroll: false }); + } + }, + [invalidateWorkspaceRestore, selectedSession, pathname, router], + ); + + const handleOpenFile = useCallback( + ( + filePath: string, + fileName: string, + options?: { sourceSessionId?: string | null; modeHint?: "diff" }, + ) => { + const sourceSessionId = options?.sourceSessionId; + const modeHint = options?.modeHint; + const tabId = `file:${filePath}`; + setFileTabs((prev) => + openFileTab(prev, { + fileName, + filePath, + modeHint, + sourceSessionId, + tabId, + }), + ); + setActiveFileTabId(tabId); + setRightPanelOpen(true); + // On mobile the file panel is full-screen; close the drawer so it shows. + if (isMobile) setSidebarOpen(false); + }, + [isMobile], + ); - const handleOpenLinkedFile = useCallback((filePath: string) => { - handleOpenFile(filePath, getFileName(filePath), { sourceSessionId: selectedSession?.id ?? null }); - }, [handleOpenFile, selectedSession?.id]); + const handleOpenLinkedFile = useCallback( + (filePath: string) => { + handleOpenFile(filePath, getFileName(filePath), { + sourceSessionId: selectedSession?.id ?? null, + }); + }, + [handleOpenFile, selectedSession?.id], + ); - const handleOpenTerminal = useCallback((cwd: string) => { - const existing = terminalTabs.find((tab) => tab.cwd === cwd); - const tab = existing ?? newTerminalTab(cwd); - if (!existing) setTerminalTabs((tabs) => [...tabs, tab]); - setActiveFileTabId(tab.id); - setRightPanelOpen(true); - if (isMobile) setSidebarOpen(false); - }, [terminalTabs, isMobile]); + const handleOpenTerminal = useCallback( + (cwd: string) => { + const existing = terminalTabs.find((tab) => tab.cwd === cwd); + const tab = existing ?? newTerminalTab(cwd); + if (!existing) setTerminalTabs((tabs) => [...tabs, tab]); + setActiveFileTabId(tab.id); + setRightPanelOpen(true); + if (isMobile) setSidebarOpen(false); + }, + [terminalTabs, isMobile], + ); const handleTerminalClosed = (tab: TerminalTab) => { - const replacement = tab.closing === "restart" ? newTerminalTab(tab.cwd) : null; + const replacement = + tab.closing === "restart" ? newTerminalTab(tab.cwd) : null; const remaining = terminalTabs.filter((item) => item.id !== tab.id); - setTerminalTabs((tabs) => tabs.flatMap((item) => item.id !== tab.id ? [item] : replacement ? [replacement] : [])); - setActiveFileTabId((current) => current !== tab.id ? current : replacement?.id ?? remaining.at(-1)?.id ?? fileTabs.at(-1)?.id ?? null); - if (!replacement && !remaining.length && !fileTabs.length) setRightPanelOpen(false); + setTerminalTabs((tabs) => + tabs.flatMap((item) => + item.id !== tab.id ? [item] : replacement ? [replacement] : [], + ), + ); + setActiveFileTabId((current) => + current !== tab.id + ? current + : (replacement?.id ?? + remaining.at(-1)?.id ?? + fileTabs.at(-1)?.id ?? + null), + ); + if (!replacement && !remaining.length && !fileTabs.length) + setRightPanelOpen(false); }; - const handleCloseFileTab = useCallback((tabId: string) => { - if (terminalTabs.some((tab) => tab.id === tabId)) { - setTerminalTabs((tabs) => tabs.map((tab) => tab.id === tabId && !tab.closing ? { ...tab, closing: "close" } : tab)); - return; - } - setFileTabs((prev) => { - const next = prev.filter((t) => t.id !== tabId); - if (next.length === 0 && terminalTabs.length === 0) setRightPanelOpen(false); - return next; - }); - setActiveFileTabId((cur) => { - if (cur !== tabId) return cur; - const remaining = fileTabs.filter((t) => t.id !== tabId); - return remaining.at(-1)?.id ?? terminalTabs.at(-1)?.id ?? null; - }); - }, [fileTabs, terminalTabs]); + const handleCloseFileTab = useCallback( + (tabId: string) => { + if (terminalTabs.some((tab) => tab.id === tabId)) { + setTerminalTabs((tabs) => + tabs.map((tab) => + tab.id === tabId && !tab.closing + ? { ...tab, closing: "close" } + : tab, + ), + ); + return; + } + setFileTabs((prev) => { + const next = prev.filter((t) => t.id !== tabId); + if (next.length === 0 && terminalTabs.length === 0) + setRightPanelOpen(false); + return next; + }); + setActiveFileTabId((cur) => { + if (cur !== tabId) return cur; + const remaining = fileTabs.filter((t) => t.id !== tabId); + return remaining.at(-1)?.id ?? terminalTabs.at(-1)?.id ?? null; + }); + }, + [fileTabs, terminalTabs], + ); const handleViewFullHistory = useCallback(() => { if (!selectedSession) return; @@ -1102,10 +1476,12 @@ export function AppShell() { }, [selectedSession]); // Show chat area if a session is selected, or if we have a cwd to start a new session in - const effectiveNewSessionCwd = newSessionCwd ?? (selectedSession === null && activeCwd ? activeCwd : null); - const newSessionDraftKey = selectedSession === null && effectiveNewSessionCwd - ? `new:${newSessionDraftId}:${effectiveNewSessionCwd}` - : null; + const effectiveNewSessionCwd = + newSessionCwd ?? (selectedSession === null && activeCwd ? activeCwd : null); + const newSessionDraftKey = + selectedSession === null && effectiveNewSessionCwd + ? `new:${newSessionDraftId}:${effectiveNewSessionCwd}` + : null; useLayoutEffect(() => { activeNewSessionDraftKeyRef.current = newSessionDraftKey; }, [newSessionDraftKey]); @@ -1125,12 +1501,16 @@ export function AppShell() { 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}`); + 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; + if (error instanceof DOMException && error.name === "AbortError") + return; console.error("Failed to load project trust:", error); }); return () => controller.abort(); @@ -1146,20 +1526,26 @@ export function AppShell() { 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}`); + 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)); + setProjectTrustError( + error instanceof Error ? error.message : String(error), + ); } finally { setProjectTrustBusy(false); } }, [projectTrustBusy, projectTrustCwd]); - const activeFileTab = fileTabs.find((tab) => tab.id === activeFileTabId) ?? null; + const activeFileTab = + fileTabs.find((tab) => tab.id === activeFileTabId) ?? null; const activeCwdName = activeCwd ? getFileName(activeCwd) || activeCwd : null; const windowTitle = activeCwdName ? `${activeCwdName} - Pi Web` : "Pi Web"; @@ -1170,7 +1556,11 @@ export function AppShell() { syncWindowTitle(); const observer = new MutationObserver(syncWindowTitle); - observer.observe(document.head, { childList: true, subtree: true, characterData: true }); + observer.observe(document.head, { + childList: true, + subtree: true, + characterData: true, + }); return () => observer.disconnect(); }, [windowTitle]); @@ -1197,11 +1587,21 @@ export function AppShell() { onRunningSessionIdsChange={handleRunningSessionIdsChange} onSessionsChange={handleSessionsChange} /> -
- {([ - ["models", translate("common.models")], - ["skills", translate("common.skills")], - ] as const).map(([section, label]) => { +
+ {( + [ + ["models", translate("common.models")], + ["skills", translate("common.skills")], + ] as const + ).map(([section, label]) => { const disabled = section !== "models" && !projectTrustCwd; return ( ); })} -
+ onMouseEnter={(event) => { + event.currentTarget.style.background = "var(--bg-hover)"; + event.currentTarget.style.color = "var(--text)"; + }} + onMouseLeave={(event) => { + event.currentTarget.style.background = "none"; + event.currentTarget.style.color = "var(--text-muted)"; + }} + > + + {translate("common.settings")} + +
); @@ -1258,15 +1698,27 @@ export function AppShell() { aria-expanded={activeTopPanel === "theme"} aria-pressed={activeTopPanel === "theme"} style={{ - display: "flex", alignItems: "center", justifyContent: "center", - width: TOP_BAR_ICON_BUTTON_SIZE, height: TOP_BAR_ICON_BUTTON_SIZE, padding: 0, + display: "flex", + alignItems: "center", + justifyContent: "center", + width: TOP_BAR_ICON_BUTTON_SIZE, + height: TOP_BAR_ICON_BUTTON_SIZE, + padding: 0, background: activeTopPanel === "theme" ? "var(--bg-selected)" : "none", - border: "none", borderRight: "1px solid var(--border)", + border: "none", + borderRight: "1px solid var(--border)", color: activeTopPanel === "theme" ? "var(--text)" : "var(--text-muted)", - cursor: "pointer", flexShrink: 0, transition: "color 0.12s", + cursor: "pointer", + flexShrink: 0, + transition: "color 0.12s", + }} + onMouseEnter={(event) => { + event.currentTarget.style.color = "var(--text)"; + }} + onMouseLeave={(event) => { + event.currentTarget.style.color = + activeTopPanel === "theme" ? "var(--text)" : "var(--text-muted)"; }} - onMouseEnter={(event) => { event.currentTarget.style.color = "var(--text)"; }} - onMouseLeave={(event) => { event.currentTarget.style.color = activeTopPanel === "theme" ? "var(--text)" : "var(--text-muted)"; }} data-mobile-toolbar-action={mobile ? "theme" : undefined} > @@ -1284,16 +1736,28 @@ export function AppShell() { 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", + 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={(event) => { + event.currentTarget.style.color = "var(--text)"; }} - onMouseEnter={(event) => { event.currentTarget.style.color = "var(--text)"; }} onMouseLeave={(event) => { - event.currentTarget.style.color = activeTopPanel === "language" ? "var(--text)" : "var(--text-muted)"; + event.currentTarget.style.color = + activeTopPanel === "language" ? "var(--text)" : "var(--text-muted)"; }} data-mobile-toolbar-action={mobile ? "language" : undefined} > @@ -1319,7 +1783,8 @@ export function AppShell() { ); const renderProjectTrustWarning = (mobileBanner: boolean) => { - if (!showChat || !projectTrust?.requiresTrust || projectTrust.trusted) return null; + if (!showChat || !projectTrust?.requiresTrust || projectTrust.trusted) + return null; return ( )} - {sessionHasBranches && (mobile ? ( - - ) : ( - toggleTopPanel("branches")} - hasSession - /> - ))} + {sessionHasBranches && + (mobile ? ( + + ) : ( + toggleTopPanel("branches")} + hasSession + /> + ))} ); @@ -1870,7 +2692,7 @@ export function AppShell() { return ( <> - -
- {/* Mobile overlay backdrop */} -
setSidebarOpen(false)} - style={{ - position: "fixed", - inset: 0, - zIndex: 199, - background: "rgba(0,0,0,0.4)", - opacity: sidebarOpen ? 1 : 0, - pointerEvents: sidebarOpen ? "auto" : "none", - transition: "opacity 0.25s ease", - }} - /> - - {/* Left sidebar */}
- {sidebarContent} -
- {sidebarOpen && ( + {/* Mobile overlay backdrop */}
setSidebarOpen(false)} + style={{ + position: "fixed", + inset: 0, + zIndex: 199, + background: "rgba(0,0,0,0.4)", + opacity: sidebarOpen ? 1 : 0, + pointerEvents: sidebarOpen ? "auto" : "none", + transition: "opacity 0.25s ease", + }} /> - )} - {/* Center: chat */} -
- {/* Top bar with sidebar toggle */} -
-
- - {isMobile && (
- {isNarrowMobile && ( - - )} - {!isNarrowMobile && renderChatToolbarActions(true)} - {renderSessionStatsButton(true)} - {renderMainFileToggle(true)} - {isNarrowMobile && mobileToolbarMoreOpen && ( + + {isMobile && ( - )} -
- )} - {!isMobile && ( - <> - {renderThemeButton(false)} - {renderLanguageButton(false)} - {renderProjectTrustWarning(false)} - {renderChatToolbarActions(false)} - {renderSessionStatsButton(false)} - - )} - {!isMobile && renderMainFileToggle(false)} - {isMobile && sessionHasBranches && ( - toggleTopPanel("branches")} - hasSession={showChat} - hideInlineButton - /> - )} - {/* Top panel dropdown — shared, only one active at a time */} - {activeTopPanel && topPanelPos && ( -
- {activeTopPanel === "language" && ( -
- {supportedLocales.map((plugin) => ( + {isNarrowMobile && ( - ))} + )} + {!isNarrowMobile && renderChatToolbarActions(true)} + {renderSessionStatsButton(true)} + {renderMainFileToggle(true)} + {isNarrowMobile && mobileToolbarMoreOpen && ( + + )}
)} - {activeTopPanel === "theme" && ( + {!isMobile && ( + <> + {renderThemeButton(false)} + {renderLanguageButton(false)} + {renderProjectTrustWarning(false)} + {renderChatToolbarActions(false)} + {renderSessionStatsButton(false)} + + )} + {!isMobile && renderMainFileToggle(false)} + {isMobile && sessionHasBranches && ( + toggleTopPanel("branches")} + hasSession={showChat} + hideInlineButton + /> + )} + {/* Top panel dropdown — shared, only one active at a time */} + {activeTopPanel && topPanelPos && (
- {THEME_OPTIONS.map((option) => ( - - ))} -
- )} - {activeTopPanel === "agents" && activeSessionFamily && selectedSession && ( - - )} - {activeTopPanel === "system" && ( - - )} - {activeTopPanel === "tools" && ( - - )} - {activeTopPanel === "session" && ( -
- {sessionStats ? (() => { - const formatDuration = (ms: number) => { - if (ms <= 0) return "0s"; - const totalSec = Math.floor(ms / 1000); - const h = Math.floor(totalSec / 3600); - const m = Math.floor((totalSec % 3600) / 60); - const s = totalSec % 60; - if (h > 0) return `${h}h ${m}m`; - if (m > 0) return `${m}m ${s}s`; - return `${s}s`; - }; - const totalActiveMs = sessionStats.totalActiveMs ?? 0; - const ws = selectedSession; - const sessionRows = [ - ...(sessionStats.sessionName ? [{ label: translate("session.name"), value: sessionStats.sessionName, copyField: null }] : []), - { label: translate("session.file"), value: sessionStats.sessionFile ?? translate("session.inMemory"), copyField: "file" as const }, - { label: translate("session.id"), value: sessionStats.sessionId, copyField: "id" as const }, - ...(totalActiveMs > 0 ? [{ label: translate("session.totalActive"), value: formatDuration(totalActiveMs), copyField: null }] : []), - ]; - const projectRows = [ - ...(ws ? [{ label: translate("session.projectDir"), value: ws.projectRoot ?? ws.cwd, copyField: "projectDir" as const }] : []), - ...(ws?.branch ? [{ label: translate("session.gitBranch"), value: ws.branch, copyField: "gitBranch" as const }] : []), - ...(ws?.isWorktree ? [{ label: translate("session.gitWorktree"), value: ws.cwd, copyField: "gitWorktree" 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 tokenRows = [ - [translate("session.input"), sessionStats.tokens.input.toLocaleString(locale)], - [translate("session.output"), sessionStats.tokens.output.toLocaleString(locale)], - ...(sessionStats.tokens.cacheRead > 0 ? [[translate("session.cacheRead"), sessionStats.tokens.cacheRead.toLocaleString(locale)]] : []), - ...(sessionStats.tokens.cacheWrite > 0 ? [[translate("session.cacheWrite"), sessionStats.tokens.cacheWrite.toLocaleString(locale)]] : []), - [translate("session.total"), sessionStats.tokens.total.toLocaleString(locale)], - ]; - const ctx = contextUsage ?? sessionStats.contextUsage; - const formatCompact = (n: number) => n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${(n / 1000).toFixed(0)}k` : String(n); - const extraTokenRows = [ - ...(sessionStats.cost > 0 ? [[translate("session.cost"), `$${sessionStats.cost.toFixed(4)}`]] : []), - ...(ctx?.contextWindow ? [[translate("session.context"), `${ctx.percent !== null ? `${ctx.percent.toFixed(1)}%` : "?"} / ${formatCompact(ctx.contextWindow)}`]] : []), - // Cache hit rate = cache reads / (input + cache writes + cache reads) — the denominator covers all input-class tokens. - ...(sessionStats.tokens.cacheRead + sessionStats.tokens.cacheWrite > 0 && sessionStats.tokens.cacheRead + sessionStats.tokens.cacheWrite + sessionStats.tokens.input > 0 - ? [[translate("session.cacheHitRate"), `${(sessionStats.tokens.cacheRead / (sessionStats.tokens.cacheRead + sessionStats.tokens.cacheWrite + sessionStats.tokens.input) * 100).toFixed(1)}%`]] - : []), - ]; - const section = ( - title: string, - sectionRows: string[][], - valueAlign: "left" | "right" = "left", - compact = false, - ) => ( -
-
{title}
-
- {sectionRows.map(([label, value]) => ( -
-
{label}
-
{value}
-
- ))} -
-
- ); - const copyTitleKey: Record = { - file: "session.copyFile", - id: "session.copyId", - projectDir: "session.copyProjectDir", - gitBranch: "session.copyGitBranch", - gitWorktree: "session.copyGitWorktree", - }; - const copyButton = (field: SessionCopyField, value: string) => { - const copied = copiedSessionField === field; - return ( + {supportedLocales.map((plugin) => ( - ); - }; - const sessionInfoSection = ( -
-
{translate("session.infoSection")}
-
- {sessionRows.map((row) => ( -
-
{row.label}
-
{row.value}
-
{row.copyField ? copyButton(row.copyField, row.value) : null}
+ ))} +
+ )} + {activeTopPanel === "theme" && ( +
+ {THEME_OPTIONS.map((option) => ( + + ))} +
+ )} + {activeTopPanel === "agents" && + activeSessionFamily && + selectedSession && ( + + )} + {activeTopPanel === "system" && ( + + )} + {activeTopPanel === "tools" && ( + + )} + {activeTopPanel === "session" && ( +
+ {sessionStats ? ( + (() => { + const formatDuration = (ms: number) => { + if (ms <= 0) return "0s"; + const totalSec = Math.floor(ms / 1000); + const h = Math.floor(totalSec / 3600); + const m = Math.floor((totalSec % 3600) / 60); + const s = totalSec % 60; + if (h > 0) return `${h}h ${m}m`; + if (m > 0) return `${m}m ${s}s`; + return `${s}s`; + }; + const totalActiveMs = sessionStats.totalActiveMs ?? 0; + const ws = selectedSession; + const sessionRows = [ + ...(sessionStats.sessionName + ? [ + { + label: translate("session.name"), + value: sessionStats.sessionName, + copyField: null, + }, + ] + : []), + { + label: translate("session.file"), + value: + sessionStats.sessionFile ?? + translate("session.inMemory"), + copyField: "file" as const, + }, + { + label: translate("session.id"), + value: sessionStats.sessionId, + copyField: "id" as const, + }, + ...(totalActiveMs > 0 + ? [ + { + label: translate("session.totalActive"), + value: formatDuration(totalActiveMs), + copyField: null, + }, + ] + : []), + ]; + const projectRows = [ + ...(ws + ? [ + { + label: translate("session.projectDir"), + value: ws.projectRoot ?? ws.cwd, + copyField: "projectDir" as const, + }, + ] + : []), + ...(ws?.branch + ? [ + { + label: translate("session.gitBranch"), + value: ws.branch, + copyField: "gitBranch" as const, + }, + ] + : []), + ...(ws?.isWorktree + ? [ + { + label: translate("session.gitWorktree"), + value: ws.cwd, + copyField: "gitWorktree" 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 tokenRows = [ + [ + translate("session.input"), + sessionStats.tokens.input.toLocaleString(locale), + ], + [ + translate("session.output"), + sessionStats.tokens.output.toLocaleString(locale), + ], + ...(sessionStats.tokens.cacheRead > 0 + ? [ + [ + translate("session.cacheRead"), + sessionStats.tokens.cacheRead.toLocaleString( + locale, + ), + ], + ] + : []), + ...(sessionStats.tokens.cacheWrite > 0 + ? [ + [ + translate("session.cacheWrite"), + sessionStats.tokens.cacheWrite.toLocaleString( + locale, + ), + ], + ] + : []), + [ + translate("session.total"), + sessionStats.tokens.total.toLocaleString(locale), + ], + ]; + const ctx = contextUsage ?? sessionStats.contextUsage; + const formatCompact = (n: number) => + n >= 1_000_000 + ? `${(n / 1_000_000).toFixed(1)}M` + : n >= 1000 + ? `${(n / 1000).toFixed(0)}k` + : String(n); + const extraTokenRows = [ + ...(sessionStats.cost > 0 + ? [ + [ + translate("session.cost"), + `$${sessionStats.cost.toFixed(4)}`, + ], + ] + : []), + ...(ctx?.contextWindow + ? [ + [ + translate("session.context"), + `${ctx.percent !== null ? `${ctx.percent.toFixed(1)}%` : "?"} / ${formatCompact(ctx.contextWindow)}`, + ], + ] + : []), + // Cache hit rate = cache reads / (input + cache writes + cache reads) — the denominator covers all input-class tokens. + ...(sessionStats.tokens.cacheRead + + sessionStats.tokens.cacheWrite > + 0 && + sessionStats.tokens.cacheRead + + sessionStats.tokens.cacheWrite + + sessionStats.tokens.input > + 0 + ? [ + [ + translate("session.cacheHitRate"), + `${((sessionStats.tokens.cacheRead / (sessionStats.tokens.cacheRead + sessionStats.tokens.cacheWrite + sessionStats.tokens.input)) * 100).toFixed(1)}%`, + ], + ] + : []), + ]; + const section = ( + title: string, + sectionRows: string[][], + valueAlign: "left" | "right" = "left", + compact = false, + ) => ( +
+
+ {title} +
+
+ {sectionRows.map(([label, value]) => ( +
+
+ {label} +
+
+ {value} +
+
+ ))} +
- ))} -
-
- ); - const projectInfoSection = projectRows.length > 0 ? ( -
-
{translate("session.projectSection")}
-
- {projectRows.map((row) => ( -
-
{row.label}
-
{row.value}
-
{row.copyField ? copyButton(row.copyField, row.value) : null}
+ ); + const copyTitleKey: Record = + { + file: "session.copyFile", + id: "session.copyId", + projectDir: "session.copyProjectDir", + gitBranch: "session.copyGitBranch", + gitWorktree: "session.copyGitWorktree", + }; + const copyButton = ( + field: SessionCopyField, + value: string, + ) => { + const copied = copiedSessionField === field; + return ( + + ); + }; + const sessionInfoSection = ( +
+
+ {translate("session.infoSection")} +
+
+ {sessionRows.map((row) => ( +
+
+ {row.label} +
+
+ {row.value} +
+
+ {row.copyField + ? copyButton(row.copyField, row.value) + : null} +
+
+ ))} +
- ))} -
-
- ) : null; - - return ( -
-
- {sessionInfoSection} - {projectInfoSection} + ); + const projectInfoSection = + projectRows.length > 0 ? ( +
+
+ {translate("session.projectSection")} +
+
+ {projectRows.map((row) => ( +
+
+ {row.label} +
+
+ {row.value} +
+
+ {row.copyField + ? copyButton(row.copyField, row.value) + : null} +
+
+ ))} +
+
+ ) : null; + + return ( +
+
+ {sessionInfoSection} + {projectInfoSection} +
+ {section( + translate("session.messages"), + messageRows, + )} + {section( + translate("session.tokens"), + [...tokenRows, ...extraTokenRows], + "right", + true, + )} +
+ ); + })() + ) : ( +
+ {translate("session.load")}
- {section(translate("session.messages"), messageRows)} - {section(translate("session.tokens"), [...tokenRows, ...extraTokenRows], "right", true)} -
- ); - })() : ( -
- {translate("session.load")} + )}
)}
)}
- )} - -
- {isMobile && renderProjectTrustWarning(true)} -
+ {isMobile && renderProjectTrustWarning(true)} +
- {/* Chat content */} -
- {showChat ? ( - setPendingQuotePrompt(null)} - soundEnabled={soundEnabled} - onSoundToggle={onSoundToggle} - playDoneSound={playDoneSound} - unlockAudio={unlockAudio} - /> - ) : initialCwdStatus === "validating" ? ( -
-
{translate("workspace.opening")}
-
- {initialNavigation.requestedCwd} -
-
- ) : initialCwdStatus === "error" ? ( -
-
{translate("workspace.unable")}
-
- {initialNavigation.requestedCwd} + {/* Chat content */} +
+ {showChat ? ( + setPendingQuotePrompt(null)} + soundEnabled={soundEnabled} + onSoundToggle={onSoundToggle} + playDoneSound={playDoneSound} + unlockAudio={unlockAudio} + /> + ) : initialCwdStatus === "validating" ? ( +
+
+ {translate("workspace.opening")} +
+
+ {initialNavigation.requestedCwd} +
-
{initialCwdError}
-
- ) : showPlaceholder ? ( - activeCwd ? ( -
- {translate("workspace.selectSession")} + ) : initialCwdStatus === "error" ? ( +
+
+ {translate("workspace.unable")} +
+
+ {initialNavigation.requestedCwd} +
+
+ {initialCwdError} +
- ) : ( -
- - - -
-
{translate("workspace.getStarted")}
-
- 1.{translate("workspace.selectProject")}
- 2.{translate("workspace.addModels")} + ) : showPlaceholder ? ( + activeCwd ? ( +
+ {translate("workspace.selectSession")} +
+ ) : ( +
+ + + + +
+
+ {translate("workspace.getStarted")} +
+
+ + 1. + + {translate("workspace.selectProject")} +
+ + 2. + + {translate("workspace.addModels")} +
-
- ) - ) : null} + ) + ) : null} +
-
-