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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -561,7 +561,9 @@ pi install npm:pi-intercom

### 独立 Web 工作台

Web runtime 不嵌入交互式终端 Session。它由独立进程创建自己的 Pi `AgentSessionRuntime`、独立 `~/.pi/agent/web-sessions` 持久化目录和生命周期;浏览器发送消息、新建 Session 或切换工作区,不会写入或切换任何已经运行的终端 Pi Session,Web Session 也不会出现在终端的默认 Session 列表中。在侧栏选择 Session 会把它激活为 Web 进程的当前 Pi Session;Prompt 只会投递到请求时仍匹配的活动 Web Session。独立的只读历史浏览不属于首版范围。
Web runtime 不嵌入交互式终端 Session。它由独立进程创建自己的 Pi `AgentSessionRuntime`、独立 `~/.pi/agent/web-sessions` 持久化目录和生命周期;浏览器发送消息、新建 Session 或切换工作区,不会写入或切换任何已经运行的终端 Pi Session,Web Session 也不会出现在终端的默认 Session 列表中。在侧栏选择 Web Session 会把它激活为 Web 进程的当前 Pi Session;Prompt 只会投递到请求时仍匹配的活动 Web Session。

侧栏的“已归档”可按页搜索和恢复 Web Session;“终端历史”在已选工作区下从 Pi 默认 Session 源按页读取摘要和有界文本预览,标记来源与只读状态,不复制到 Web 存储,也不激活终端 Session。列表与预览若被扫描、条数或字节上限截断,会明确显示部分结果;它们不是完整历史证据或历史编辑/分支能力。

同一 Pi agent 目录一次只允许一个 Web Host 持有该 Session/元数据目录。第二个 `openpi web` 会明确拒绝启动;正常关停会先排空共享目录变更再释放租约,进程崩溃后仅在确认原 owner 的 PID 与进程启动身份不再匹配时恢复。一个 Host 可在侧栏管理多个工作区,因此不需要为每个仓库启动一个进程。

Expand Down
34 changes: 27 additions & 7 deletions tests/web/app-render.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { createWebStore, webStore } from "../../web/ui/src/store/web-store.ts";
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
});

it("keeps a workspace draft separate from the old Session UI and retains text after failed sending", async () => {
Expand Down Expand Up @@ -616,6 +617,28 @@ it("reports failed copy honestly, supports retry, and cleans up feedback on unmo
});

it("shows bounded archive history even when its workspace summary was omitted", async () => {
vi.spyOn(WebClient.prototype, "archivedSessions").mockResolvedValue({
sessions: [
{
id: "archived",
path: "/omitted/a.jsonl",
cwd: "/omitted",
name: "Archived work",
archived: true,
modified: "2026-09-01T10:00:00Z",
created: "2026-09-01T10:00:00Z",
messageCount: 1,
firstMessage: "saved",
},
],
truncation: {
truncated: true,
matchesOmitted: 0,
recordsUnscanned: 20,
maxPageSize: 50,
maxScanned: 5000,
},
});
const store = createWebStore();
const restore = vi
.spyOn(store.getState().actions, "unarchiveSession")
Expand Down Expand Up @@ -665,16 +688,13 @@ it("shows bounded archive history even when its workspace summary was omitted",
}),
);
fireEvent.click(screen.getByRole("button", { name: "Archived" }));
expect(screen.getByText("Archived work")).toBeTruthy();
expect(screen.getByText("/omitted")).toBeTruthy();
expect(await screen.findByText("Archived work")).toBeTruthy();
expect(screen.getByText(/\/omitted/u)).toBeTruthy();
expect(
screen.getByText(
"20 more sessions and 1 workspace summaries are not loaded. Search covers the loaded list only.",
),
screen.getByText("History scan is partial; some records may not be shown."),
).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Conversation options" }));
fireEvent.click(
await screen.findByRole("menuitem", { name: "Restore conversation" }),
screen.getByRole("button", { name: "Restore conversation Archived work" }),
);
expect(
await screen.findByText(
Expand Down
231 changes: 202 additions & 29 deletions tests/web/openpi-web.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,8 +388,10 @@ test.describe("touch viewport", () => {
.first();
await expect(sessionMenu).toBeVisible();
await sessionMenu.click();
await expect(page.getByRole("menu", { name: "会话选项" })).toBeVisible();
await page.keyboard.press("Escape");
const menu = page.getByRole("menu", { name: "会话选项" });
await expect(menu).toBeVisible();
await menu.press("Escape");
await expect(sidebar).toBeVisible();

await page.getByRole("button", { name: "Workspace options" }).click();
await page.getByRole("menuitem", { name: "重命名工作区" }).click();
Expand Down Expand Up @@ -578,30 +580,38 @@ test("mobile sidebar recovers focus when archived and restored rows disappear",
const sidebar = page.locator(".session-sidebar");
const close = sidebar.getByRole("button", { name: "收起侧边栏" });
await trigger.click();
for (const action of ["归档会话", "恢复会话"]) {
const rows = sidebar.locator(".session-row");
const count = await rows.count();
await rows
.filter({ hasNot: page.locator('[aria-current="page"]') })
.first()
.getByRole("button", { name: "会话选项" })
.focus();
await page.keyboard.press("Enter");
await page.getByRole("menuitem", { name: action }).click();
await expect(rows).toHaveCount(count - 1);
await expect(close).toBeFocused();
await page.keyboard.press("Tab");
await expect(
sidebar.getByRole("button", { name: "新建会话", exact: true }),
).toBeFocused();
await page.keyboard.press("Escape");
await expect(sidebar).not.toBeVisible();
await expect(trigger).toBeFocused();
await trigger.click();
await sidebar
.getByRole("button", { name: "已归档", exact: true })
.click();
}
const rows = sidebar.locator(".session-row");
const count = await rows.count();
await rows
.filter({ hasNot: page.locator('[aria-current="page"]') })
.first()
.getByRole("button", { name: "会话选项" })
.focus();
await page.keyboard.press("Enter");
await page.getByRole("menuitem", { name: "归档会话" }).click();
await expect(rows).toHaveCount(count - 1);
await expect(close).toBeFocused();
await page.keyboard.press("Tab");
await expect(
sidebar.getByRole("button", { name: "新建会话", exact: true }),
).toBeFocused();
await page.keyboard.press("Escape");
await expect(sidebar).not.toBeVisible();
await expect(trigger).toBeFocused();
await trigger.click();
await sidebar.getByRole("button", { name: "已归档", exact: true }).click();
const historyRows = sidebar.locator(".session-history-row");
await expect(historyRows).toHaveCount(1);
await historyRows
.first()
.getByRole("button", { name: /恢复会话/u })
.click();
await expect(historyRows).toHaveCount(0);
await expect(close).toBeFocused();
await page.keyboard.press("Tab");
await expect(
sidebar.getByRole("button", { name: "新建会话", exact: true }),
).toBeFocused();
} finally {
await rm(path, { force: true });
}
Expand Down Expand Up @@ -1028,15 +1038,43 @@ test("restores archived history without switching the active Session", async ({
await route.fulfill({ json: { path, archived: false } });
}
});
await page.route("**/api/sessions/archived?**", (route) =>
route.fulfill({
json: {
sessions: archived
? [
{
id: "archived-browser",
path,
cwd: "/archived",
name: "Saved browser work",
archived: true,
created: "2026-09-07T00:00:00Z",
modified: "2026-09-07T00:00:00Z",
messageCount: 1,
firstMessage: "saved",
},
]
: [],
truncation: {
truncated: false,
matchesOmitted: 0,
recordsUnscanned: 0,
maxPageSize: 50,
maxScanned: 5000,
},
},
}),
);
await openWorkbench(page);
await page.getByRole("button", { name: "已归档", exact: true }).click();
await expect(
page.getByText("Saved browser work", { exact: true }),
).toBeVisible();
for (let attempt = 0; attempt < 2; attempt++) {
await page.getByText("Saved browser work", { exact: true }).hover();
await page.getByRole("button", { name: "会话选项" }).click();
await page.getByRole("menuitem", { name: "恢复会话" }).click();
await page
.getByRole("button", { name: "恢复会话 Saved browser work" })
.click();
if (!attempt)
await expect(
page.getByText("暂时无法确认恢复结果,请刷新后重试。"),
Expand All @@ -1057,6 +1095,141 @@ test("restores archived history without switching the active Session", async ({
).toBeVisible();
});

test("terminal Pi history stays read-only and bounded across desktop and mobile", async ({
page,
}, testInfo) => {
let workspace = "";
const activations: string[] = [];
page.on("request", (request) => {
const path = new URL(request.url()).pathname;
if (
request.method() === "POST" &&
["/api/sessions/select", "/api/sessions", "/api/prompt"].includes(path)
)
activations.push(path);
});
await page.route("**/api/snapshot**", async (route) => {
const response = await route.fetch();
const snapshot = await response.json();
workspace = "/repo";
snapshot.workspaces = [{ path: workspace, name: "Repo", current: true }];
await route.fulfill({ response, json: snapshot });
});
await page.route("**/api/terminal-sessions?**", (route) => {
const path = new URL(route.request().url()).searchParams.get("path");
const summary = {
id: "terminal-fixture",
path: "/fixture/terminal.jsonl",
cwd: workspace,
name: "Terminal notes",
created: "2026-09-01T00:00:00Z",
modified: "2026-09-01T00:00:00Z",
messageCount: 2,
metadataPartial: false,
firstMessage: "Original request",
source: "pi-default",
origin: "terminal",
readOnly: true,
};
return route.fulfill({
json: path
? {
...summary,
preview: {
messages: [
{
role: "user",
content: [{ type: "text", text: "Historical text" }],
},
],
totalMessages: 2,
bytesRead: 90,
retainedBytes: 50,
truncatedBytes: 40,
},
}
: { sessions: [summary], cursor: 0, total: 1, partial: true },
});
});
await openWorkbench(page);
expect(workspace).not.toBe("");
await page.getByRole("button", { name: "终端历史", exact: true }).click();
await expect(
page.getByRole("button", { name: /Terminal notes/u }),
).toBeVisible();
await page.getByRole("button", { name: /Terminal notes/u }).click();
const dialog = page.getByRole("dialog", { name: "终端会话历史" });
await expect(dialog).toContainText("Historical text");
await expect(dialog).toContainText("Pi 默认会话 · 终端 · 只读");
await expect(dialog).toContainText("显示 1 / 2 条消息");
for (const width of [1280, 390]) {
await page.setViewportSize({ width, height: 720 });
await expect(dialog).toBeVisible();
expect(
await page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
),
).toBe(true);
await page.screenshot({
path: testInfo.outputPath(`terminal-history-${width}.png`),
fullPage: true,
});
}
expect(activations).toEqual([]);
});

test("older archived sessions remain discoverable beyond the snapshot limit", async ({
page,
}) => {
const queries: string[] = [];
await page.route("**/api/snapshot**", async (route) => {
const response = await route.fetch();
const snapshot = await response.json();
snapshot.sessions = [];
snapshot.truncation.sessionsOmitted = 500;
await route.fulfill({ response, json: snapshot });
});
await page.route("**/api/sessions/archived?**", (route) => {
const params = new URL(route.request().url()).searchParams;
queries.push(params.get("cursor") ?? "first");
const old = params.has("cursor");
return route.fulfill({
json: {
sessions: [
{
id: old ? "older" : "recent",
path: old ? "/repo/older.jsonl" : "/repo/recent.jsonl",
cwd: "/repo",
name: old ? "Earlier request" : "Recent request",
archived: true,
created: "2026-09-01T00:00:00Z",
modified: "2026-09-01T00:00:00Z",
messageCount: 1,
firstMessage: "saved",
},
],
...(old ? {} : { nextCursor: "older-page" }),
truncation: {
truncated: !old,
matchesOmitted: old ? 0 : 1,
recordsUnscanned: 0,
maxPageSize: 50,
maxScanned: 5000,
},
},
});
});
await openWorkbench(page);
await page.getByRole("button", { name: "已归档", exact: true }).click();
await expect(page.getByText("Recent request", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "加载更多" }).click();
await expect(
page.getByText("Earlier request", { exact: true }),
).toBeVisible();
await expect(page.getByText("已到可用历史末尾")).toBeVisible();
expect(queries).toEqual(["first", "older-page"]);
});

test("trajectory inspects bounded prompt and tool evidence on desktop and mobile", async ({
page,
}, testInfo) => {
Expand Down
Loading
Loading