Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ app/api/
models-config/discover/route.ts POST fetch a configured provider's upstream model list
models-config/test/route.ts POST test a configured model/provider
plugins/route.ts GET/POST package plugin management
project-directories/route.ts GET/POST/DELETE persisted sidebar directories
skills/route.ts GET/PATCH loaded skills and disable-model-invocation
skills/install/route.ts POST install skills through npx skills add
skills/search/route.ts GET/POST skills.sh search
Expand All @@ -76,6 +77,7 @@ lib/
markdown.ts shared markdown helpers
npx.ts npx runner used by skill install
pi-types.ts local structural types for pi SDK objects
project-directories.ts persisted directory list in ~/.pi/agent/pi-web-projects.json
rpc-manager.ts AgentSessionWrapper + registry + startRpcSession
session-reader.ts SessionManager wrappers + path cache + buildSessionContext adapter
tool-presets.ts PRESET_NONE/DEFAULT/FULL + getPresetFromTools()
Expand Down Expand Up @@ -182,6 +184,13 @@ Newer pi emits `compaction_start` / `compaction_end`; older versions emitted `au
- `hooks/useAudio.ts` stores the toggle in `localStorage` as `pi-sound-enabled` and reuses one `AudioContext`.
- Browser autoplay policy means sound must be unlocked from a user gesture; `ChatInput` calls the unlock hook from interactive controls, and `ChatWindow` plays the tone from `onAgentEnd`.

### PWA 版本与 Service Worker 更新策略
- 生产环境必须使用每次构建唯一的版本标识注册 `/sw.js?v=<build-version>`,静态缓存名称也必须包含同一个版本;不能只使用长期不变的 `package.json` 版本,否则代码变化后浏览器可能继续命中旧 chunk。
- 新 Service Worker 安装完成后保持 `waiting`,由界面提示“发现新版本”;用户确认后发送 `SKIP_WAITING`,并在 `controllerchange` 后刷新页面。不要在 `install` 阶段无条件调用 `skipWaiting()`。
- 激活新 Service Worker 时只清理 `pi-web-` 前缀下的旧版本缓存,不得清理其它站点数据或认证信息。`/sw.js`、页面导航和 API 请求必须绕过静态资源缓存。
- 开发环境不注册 Service Worker,并在 Next 客户端代码加载前注销同源旧注册、删除 `pi-web-` 缓存;清理完成后若页面仍被旧 worker 控制,只自动重载一次,避免刷新循环。
- 重启 8081 的 Node/Next.js 进程不会注销浏览器中的 Service Worker。已有标签页或独立 PWA 窗口可能继续由旧 worker 控制,必须让页面重新加载并执行清理逻辑;必要时关闭该同源的全部页面后重新打开。

### Exported session HTML
- `/api/sessions/[id]/export` delegates to pi's export helper, then patches recursive tree helpers in the generated HTML to iterative versions so very deep linear sessions do not overflow the browser call stack.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ Avoid running `next build` / `npm run build` during local development. It writes
app/
api/
agent/ # creates/drives AgentSession and exposes SSE events
auth/ # OAuth and API key management
auth/ # password sessions, OAuth, and API key management
cwd/browse/ # browsable server directory listing
cwd/validate/ # custom working directory validation
default-cwd/ # pi default working directory lookup
Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ npm run lint
app/
api/
agent/ # 创建/驱动 AgentSession,提供 SSE 事件流
auth/ # OAuth 和 API key 管理
auth/ # 密码会话、OAuth 和 API key 管理
cwd/browse/ # 服务端目录浏览
cwd/validate/ # 自定义工作目录校验
default-cwd/ # 获取 pi 默认工作目录
Expand Down
41 changes: 41 additions & 0 deletions app/api/project-directories/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { NextResponse } from "next/server";
import { allowFileRoot } from "@/lib/file-access";
import {
addProjectDirectory,
normalizeProjectDirectory,
readProjectDirectories,
removeProjectDirectory,
} from "@/lib/project-directories";

export const dynamic = "force-dynamic";

export async function GET() {
const projects = readProjectDirectories();
projects.forEach(allowFileRoot);
return NextResponse.json({ projects });
}

export async function POST(request: Request) {
try {
const body = await request.json() as { cwd?: unknown };
const cwd = normalizeProjectDirectory(body.cwd);
allowFileRoot(cwd);
return NextResponse.json({ projects: addProjectDirectory(cwd), cwd });
} catch (error) {
console.error("保存 Pi Web 项目目录失败", error);
return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 400 });
}
}

export async function DELETE(request: Request) {
try {
const body = await request.json() as { cwd?: unknown };
if (typeof body.cwd !== "string" || !body.cwd.trim()) {
return NextResponse.json({ error: "cwd required" }, { status: 400 });
}
return NextResponse.json({ projects: removeProjectDirectory(body.cwd.trim()) });
} catch (error) {
console.error("移除 Pi Web 项目目录失败", error);
return NextResponse.json({ error: error instanceof Error ? error.message : String(error) }, { status: 500 });
}
}
23 changes: 23 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Metadata, Viewport } from "next";
import { Noto_Sans_Mono } from "next/font/google";
import Script from "next/script";
import { PwaRegistration } from "@/components/PwaRegistration";
import "katex/dist/katex.min.css";
import "./globals.css";
Expand All @@ -10,6 +11,25 @@ const notoSansMono = Noto_Sans_Mono({
display: "swap",
});

const DEV_PWA_CLEANUP_SCRIPT = `(function(){
if (!("serviceWorker" in navigator)) return;
Promise.all([
navigator.serviceWorker.getRegistrations().then(function(registrations){
return Promise.all(registrations.map(function(registration){ return registration.unregister(); }));
}),
("caches" in window) ? caches.keys().then(function(keys){
return Promise.all(keys.filter(function(key){ return key.indexOf("pi-web-") === 0; }).map(function(key){ return caches.delete(key); }));
}) : Promise.resolve()
]).then(function(){
if (navigator.serviceWorker.controller && !sessionStorage.getItem("pi-web-dev-sw-cleaned")) {
sessionStorage.setItem("pi-web-dev-sw-cleaned", "1");
location.reload();
} else {
sessionStorage.removeItem("pi-web-dev-sw-cleaned");
}
}).catch(function(error){ console.error("PWA 开发缓存清理失败", error); });
})();`;

export const metadata: Metadata = {
title: "Pi Web",
description: "Pi Web interface for the pi coding agent",
Expand Down Expand Up @@ -62,6 +82,9 @@ export default function RootLayout({
__html: `(function(){try{var t=localStorage.getItem("pi-theme");if(t==="dark")document.documentElement.classList.add("dark")}catch(e){}})();`,
}}
/>
{process.env.NODE_ENV !== "production" && (
<Script id="dev-pwa-cleanup" strategy="beforeInteractive" dangerouslySetInnerHTML={{ __html: DEV_PWA_CLEANUP_SCRIPT }} />
)}
</head>
<body translate="no" className="notranslate" style={{ height: "100dvh", display: "flex", flexDirection: "column" }}>
{children}
Expand Down
14 changes: 14 additions & 0 deletions components/AppShell.session-navigation.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import test from "node:test";

const source = fs.readFileSync(new URL("./AppShell.tsx", import.meta.url), "utf8");

test("重复选择当前会话不会重挂载聊天窗口", () => {
assert.match(source, /activeSessionIdRef\.current === session\.id\) return/);
});

test("会话切换只更新地址栏而不发起 App Router 导航", () => {
assert.match(source, /window\.history\.replaceState\(window\.history\.state/);
assert.match(source, /replaceSessionUrl\(session\.id\)/);
});
56 changes: 47 additions & 9 deletions components/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ type AutoNameStatus =
const TOP_BAR_ICON_BUTTON_SIZE = 36;
const LANGUAGE_MENU_WIDTH = 176;

/** 仅同步地址栏,不触发 App Router 的服务端导航和聊天区域重复挂载。 */
function replaceSessionUrl(sessionId: string | null): void {
const url = new URL(window.location.href);
if (sessionId) url.searchParams.set("session", sessionId);
else url.searchParams.delete("session");
window.history.replaceState(window.history.state, "", `${url.pathname}${url.search}${url.hash}`);
}

export function AppShell() {
const router = useRouter();
const searchParams = useSearchParams();
Expand All @@ -56,6 +64,8 @@ export function AppShell() {
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);
// 仅当前浏览器内记录每个 cwd 的一个未发送新会话,不写入服务端。
const [pendingNewSessionCwds, setPendingNewSessionCwds] = useState<Set<string>>(() => new Set());
const [initialCwdStatus, setInitialCwdStatus] = useState<"idle" | "validating" | "ready" | "error">(
() => initialNavigation.requestedCwd ? "validating" : "idle",
);
Expand Down Expand Up @@ -347,6 +357,7 @@ export function AppShell() {
}, [router, selectedSession]);

const handleSelectSession = useCallback((session: SessionInfo, isRestore = false) => {
if (!isRestore && activeSessionIdRef.current === session.id) return;
setNewSessionCwd(null);
setSelectedSession(session);
setSessionKey((k) => k + 1);
Expand All @@ -360,23 +371,36 @@ export function AppShell() {
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
// and route navigation would trigger an unnecessary RSC request.
if (!isRestore) {
router.replace(`?session=${encodeURIComponent(session.id)}`, { scroll: false });
replaceSessionUrl(session.id);
}
}, [router, isMobile]);
}, [isMobile]);

const handleNewSession = useCallback((_sessionId: string, cwd: string) => {
setSelectedSession(null);
// 同一目录只复用已有的未发送会话;draft-store 负责恢复输入内容。
if (pendingNewSessionCwds.has(cwd)) {
setNewSessionCwd(cwd);
setSessionKey((k) => k + 1);
replaceSessionUrl(null);
return;
}
setPendingNewSessionCwds((current) => {
if (current.has(cwd)) return current;
const next = new Set(current);
next.add(cwd);
return next;
});
setNewSessionCwd(cwd);
setSessionKey((k) => k + 1);
setBranchTree([]);
setBranchActiveLeafId(null);
setSystemPrompt(null);
setActiveTopPanel(null);
if (isMobile) setSidebarOpen(false);
router.replace("/", { scroll: false });
}, [router, isMobile]);
replaceSessionUrl(null);
}, [isMobile, pendingNewSessionCwds]);

// Global keyboard shortcuts (handles Esc, Ctrl+Alt+N etc.)
useGlobalKeyboardShortcuts({
Expand All @@ -402,11 +426,17 @@ export function AppShell() {
// Called by ChatWindow when a new session gets its real id from pi
const handleSessionCreated = useCallback((session: SessionInfo) => {
setNewSessionCwd(null);
setPendingNewSessionCwds((current) => {
if (!current.has(session.cwd)) return current;
const next = new Set(current);
next.delete(session.cwd);
return next;
});
setSelectedSession(session);
setRefreshKey((k) => k + 1);
hydrateSelectedSession(session.id);
router.replace(`?session=${encodeURIComponent(session.id)}`, { scroll: false });
}, [router, hydrateSelectedSession]);
replaceSessionUrl(session.id);
}, [hydrateSelectedSession]);

const handleAgentEnd = useCallback(() => {
setRefreshKey((k) => k + 1);
Expand Down Expand Up @@ -1467,10 +1497,18 @@ export function AppShell() {
{/* Chat content */}
<div style={{ flex: 1, overflow: "hidden", position: "relative" }}>
{showChat ? (
<ChatWindow
<ChatWindow
key={sessionKey}
session={selectedSession}
newSessionCwd={effectiveNewSessionCwd}
newSessionCwd={effectiveNewSessionCwd}
onNewSessionCwdChange={(cwd) => {
setPendingNewSessionCwds((current) => {
const next = new Set(current);
next.add(cwd);
return next;
});
setNewSessionCwd(cwd);
}}
onAgentEnd={handleAgentEnd}
onSessionCreated={handleSessionCreated}
onSessionForked={handleSessionForked}
Expand Down
22 changes: 22 additions & 0 deletions components/ChatInput.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,25 @@ test("renders compact errors above the input as a wrapping alert", () => {
assert.match(html, /white-space:pre-wrap/);
assert.ok(html.indexOf('role="alert"') < html.indexOf("<textarea"));
});

test("renders the worktree selector only for a new session", () => {
const html = renderToStaticMarkup(
React.createElement(
I18nProvider,
null,
React.createElement(ChatInput, {
onSend() {},
onAbort() {},
isStreaming: false,
cwd: "/repo",
newSessionCwd: "/repo-wt",
newSessionWorktrees: [
{ path: "/repo", branch: "main", isMain: true },
{ path: "/repo-wt", branch: "feature/test", isMain: false },
],
}),
),
);
assert.match(html, /选择 worktree/);
assert.match(html, /feature\/test/);
});
Loading