Skip to content
Merged
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
4 changes: 2 additions & 2 deletions docs/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ src/

| 文件/目录 | 职责 |
|---|---|
| `main.tsx` / `App.tsx` | 入口 / 视图切换(chat/projects)+ 事件桥:onEvent → `EventConflator` rAF 合流 → transcript store(桥在 `hooks/use-session-event-bridge.ts`)。订阅纪律:App 只订阅原始值(子树无 memo,订阅 transcript 对象会随每条流式 delta 全级联) |
| `main.tsx` / `App.tsx` | 入口 / 视图切换(chat/projects)+ 事件桥:onEvent → `EventConflator` rAF 合流 → transcript store(桥在 `hooks/use-session-event-bridge.ts`;`session_info_changed` 同步 sessions + projects 两份名称投影,防 GC 后标题回退)。订阅纪律:App 只订阅原始值(子树无 memo,订阅 transcript 对象会随每条流式 delta 全级联) |
| `bootstrap-theme.ts` | 首帧前写 `data-theme`(读 `?theme=` query,防开屏闪色) |
| `monaco-contribs.ts` | monaco worker 接管 + 懒加载服务补注册(出现新 UNKNOWN service 报错时按同法在此补模块) |
| `splash-dom.ts` / `splash.ts` / `styles/splash.css` | 开屏动画三件:DOM/粒子参数(`DOT_COUNT`)→ 时长与单次标记(sessionStorage)→ 全部样式与收场编排(改视觉只动这三个文件) |
Expand Down Expand Up @@ -229,7 +229,7 @@ src/
| show_image 发图 | 工具本体 `backend/src/tools/show-image.ts`;实时 = shared reducer(pendingImages 缓冲,turn_end 固化排 assistant 之后);历史 = `toSessionMessages` 的 `role:"image"`;渲染 = MessageItem image 分支(缩略图按数量分档) |
| subagent 独立行 | 工具 `backend/src/tools/subagent/`;提取 shared `src/subagent.ts`(extractSubagentRuns,结构检测不依赖工具名);互斥通知 `subagent_mutex` → reducer system 消息(dedup by extensionPath);渲染 `chat/SubagentRunCard.tsx`(行内 11px ink-faint 展示实际思考档位;有 `sessionFile` 的行可点 → `openFromHistory` 打开**只读**检视页)。**检视会话只从导航投影过滤**(`lib/session-visibility.ts`):它留在 store `sessions` 里,但不进左栏分组/计数/搜索与顶栏胶囊 |
| 斜杠命令 | 面板 `composer/SlashMenu.tsx` + `slash-filter.ts` + `use-slash-menu.ts`;命令表 backend `slash-commands.ts`(**新会话页(无 active)按 `cwd` 走 `listSlashCommandsForCwd`,真实会话按 `sessionId`**);**模板/skill/扩展命令 SDK 原生展开无需代码**;`/settings` 定位走 settings store `openWith()` |
| 上下文压缩 UI | compaction_start/end → shared reducer 生成 system 消息 + compacting 位(**压缩期间 Composer 禁发**,SDK 拒绝压缩中的 prompt);渲染 `chat/SystemMessage.tsx`(分割线,done 可展开摘要);手动 `/compact [focus]`(focus 拼入摘要 prompt);**压缩后 UI 历史完整保留**(SDK 只裁 LLM 上下文,jsonl 完整,reducer 只追加分界线) |
| 上下文压缩 UI | compaction_start/end → shared reducer 生成 system 消息 + compacting 位(**压缩期间 Composer 禁发**,SDK 拒绝压缩中的 prompt);渲染 `chat/SystemMessage.tsx`(分割线,done 可展开摘要);手动 `/compact [focus]`(focus 拼入摘要 prompt);**压缩后 UI 历史完整保留**:live reducer 只追加分界线;重开回放由 backend `toBranchSessionMessages(sessionManager.getBranch())` 读取 JSONL 当前分支完整 message entries,禁止读取已被压缩的 `AgentSession.messages` 模型上下文 |
| 上下文蒸发 | backend `tools/context-evaporation/`(见 backend 表);开关 = 设置 GeneralPanel 二态(默认蒸发,写 settings.json 单 key,2s 生效免重开);调参 `scripts/replay-evaporation.mts`;观测 = log `context-evaporation` 行 + trace_custom |
| 上下文用量圆环 | `composer/ContextRing.tsx` + `hooks/use-context-usage.ts`(事件驱动刷新,与插件 host API 共用)→ IPC getContextUsage → SDK `session.getContextUsage()`(percent null 或无消息不渲染;<60% 灰 / 60-85% 琥珀 / >85% 红) |
| 每轮计时行 + 文件变更 chip + diff 侧栏 | 计时 shared `transcript/turn-timings.ts`(deriveTurnTimings;分量 = reducer 盖戳的 `UIToolCall.endedAt` + `runEndedAt` 定格 + 历史回放透传 toolResult timestamp);变更 shared `transcript/turn-files.ts`(deriveTurnChanges)+ `chat-rows.ts`(行定位:每轮必有计时行,lan-web 不传 timings 保持旧行为);渲染 `chat/TurnDiffChip.tsx`(timer 恒在首位)+ `diff/DiffSidebar.tsx`(含 BranchRow);开关 = SessionTabBar 的 DiffIcon 按钮 + `stores/ui.ts` diffSidebarOpen |
Expand Down
12 changes: 12 additions & 0 deletions docs/PITFALLS.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@
| 同一会话文件并发 open 后订阅/扩展/trace 翻倍、旧实例泄漏 | 二 · openSession 幂等:registry 短路 + single-flight + add 不静默覆盖(2026-09-20) |
| 复制/恢复过会话文件后,它在列表里的时间/位置全变了 | 二 · 同章节「birthtime 不是会话创建时间」 |
| 新建的会话过一阵突然从左侧栏消失(点「+」/重启后又回来) | 四 · 会话目录写穿:行存不存在不能依赖内存(2026-09-21) |
| 左栏会话标题在项目目录名(如 `percho`)与首条用户消息之间反复切换 | 四 · 同章节「名称也要写回目录投影」(2026-09-22) |
| 会话压缩后当时历史还在,过段时间重新打开却只剩压缩后的内容 | 四 · UI 历史不能读取被压缩的模型上下文(2026-09-22) |
| 给 store 加模块级订阅后,某些入口报 `Cannot read properties of undefined (reading 'subscribe')` | 四 · 同章节「renderer 模块图不许有环」(2026-09-21) |
| 反复被 GC 卸载的已置顶会话,顶栏胶囊也一起消失了 | 四 · 同章节「写穿」:胶囊与左栏同源(tabs → 目录兜底) |

Expand Down Expand Up @@ -547,13 +549,23 @@ pi SDK 必须声明进 `packages/desktop/package.json` dependencies(electron-b

对策(已落地):**目录是存在性的唯一来源,会话一进内存就写穿进去**。`stores/projects.ts` 底部一处 `useSessionsStore.subscribe`(会话一进内存就补进目录,**只补缺不覆盖**,磁盘权威的时间字段不被内存 meta 盖掉)+ `load()` 结尾再补一次(`0` 消息会话还没有会话文件,重拉的快照必然没有它)。内存 `sessions` 自此只负责运行态;GC 卸载 = 只撤运行态,不撤存在性。单测:`stores/projects.test.ts` 的「会话目录写穿」四例(含「卸载后左栏行仍在」)+ `lib/sidebar-groups.test.ts`。

**名称也要写回目录投影(2026-09-22)**:新建会话的首份 meta 尚未命名,写穿目录后才由首条用户消息触发 `session_info_changed`。若事件只更新内存 `sessions`,会话在内存时标题正确;GC 卸载后只剩目录里的 `name: undefined`,`sessionTitle()` 就回退到 cwd 末级名(本次为 `percho`);再次打开后内存 meta 从磁盘读到正确名称,于是标题又变回首条消息,形成反复切换。修复是在事件桥同时更新 `sessions` 与 `projects.allSessions`;显式重命名原本就采用同样的双投影同步。回归用例覆盖「自动命名写回后 GC 卸载仍保留名称」。

连带两个教训:

- **推导链条里任何一环「旧」,整条结论都不可信**:spec 里曾写着「左栏列的是磁盘历史,所以卸载不会让任何列表缺项」——这句话在快照新鲜时成立,而快照并不保证新鲜(`session-memory-policy.md` §1 已标注修正)。审查这类断言时要问「这份数据的**新鲜度**由谁保证」。
- **renderer 模块图不许有环**:写穿用一个模块级订阅,立刻在「`sessions` 先进」的入口(三个 store 单测)报 `Cannot read properties of undefined (reading 'subscribe')`——环 `projects → sessions → ui-preferences → sidebar-groups → projects` 让 `projects` 的模块体在 `sessions` 未求值完时执行。环的成因只是 3 行纯函数 `toggleInList` 挂在 `sidebar-groups` 上(已拆成叶模块 `lib/toggle-in-list.ts`)。防线:`src/renderer/src/import-cycles.test.ts` 扫全图断言 0 环(本仓当时为 0,含 `import type`)。

复现手法(可复用):dev 实例 + CDP 直接驱 store 走真实入口(`.local/dev-logs/repro-session-catalog.mjs`):`createSession()`(真实 promotion)→ `activateNewSessionDraft()` 切走 → `unloadSession(id)`(GC 真实入口)→ 断言 `inMemory=false` 但 `inCatalog=true` 且 `document.querySelector('[data-session-id=…]')` 仍在。**侧栏按组渲染,断言前要先展开该会话所在项目的组**(`setExpandedGroups([...groups, cwd])`),否则“行不在 DOM 里”是假阴性。

### UI 历史不能读取被压缩的模型上下文(2026-09-22)

症状:执行 compaction 后,当前界面的旧消息仍完整;过一段时间切走再回来,压缩前的大段内容却消失。目标会话文件有 1319 行、当前分支 1253 条 message entry,但两次压缩后 `AgentSession.messages` 只剩 251 条上下文消息;日志中的 GC close/open 正好对应“过段时间”这个触发点。

根因:实时 `compaction_end` 已刻意只追加分割线、不重置 UI,所以当下正常;但 GC 卸载再打开时,`getSessionMessages()` 错把 `AgentSession.messages` 当历史数据源。这个属性是**给模型下一轮请求使用的裁剪上下文**,不是完整会话历史。完整历史一直在 JSONL 会话树当前分支里,并未丢失。

对策:UI 回放统一读取 `sessionManager.getBranch()` 的全部 `message` entry,再转换并配对 entryId;模型调用仍使用 SDK 自己的 `session.messages`,两种语义不混用。文件只读透视也复用同一个 branch 转换函数。回归测试必须真实插入 compaction entry,并证明模型上下文缩短的同时 UI 历史仍包含压缩前消息。

## 五、工程纪律

### renderer 单测跑在 node 环境:测不了 i18n 与返回 JSX 的模块函数(2026-09-20)
Expand Down
10 changes: 3 additions & 7 deletions packages/backend/src/pi-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,14 +71,13 @@ import { addAllowedPattern, addWorkspaceRoot } from "./project/workspace-store";
import { slimBulkyEvent, slimMessageUpdate } from "./session/event-slim";
import { ExtensionDialogHost } from "./session/extension-dialog-host";
import {
assignEntryIds,
blockImages,
blockText,
type RawMessage,
readSessionMessagesFromContent,
resolveForkEntryId,
resolveRecallEntryId,
toSessionMessages,
toBranchSessionMessages,
} from "./session/messages";
import { autoNameSession } from "./session/naming";
import { EventRateTracker } from "./session/rates";
Expand Down Expand Up @@ -867,13 +866,10 @@ export class PiBackend {
return format === "html" ? entry.session.exportToHtml() : entry.session.exportToJsonl();
}

/** 读取会话历史消息(打开历史会话时回放给 UI) */
/** 读取会话树当前分支的完整历史(compaction 只裁模型上下文,不裁 UI 历史) */
async getSessionMessages(sessionId: string): Promise<SessionMessage[]> {
const entry = this.requireSession(sessionId);
const messages = toSessionMessages(entry.session.messages);
// 配对消息与会话树 entry id(assistant 供 fork 定位、user 供撤回定位)
assignEntryIds(messages, entry.session.sessionManager.getBranch());
return messages;
return toBranchSessionMessages(entry.session.sessionManager.getBranch());
}

/**
Expand Down
14 changes: 12 additions & 2 deletions packages/backend/src/session/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,16 +180,26 @@ export function readSessionMessagesFromContent(content: string): SessionMessage[
for (const entry of entries) {
if (entry.type !== "session") byId.set(entry.id, entry);
}
const branch: (typeof entries)[number][] = [];
const branch: SessionEntry[] = [];
let cursor = entries.length > 0 ? entries[entries.length - 1] : null;
while (cursor && cursor.type !== "session") {
branch.unshift(cursor);
cursor = cursor.parentId ? (byId.get(cursor.parentId) ?? null) : null;
}
return toBranchSessionMessages(branch);
}

/**
* 当前会话树分支 → UI 历史消息。必须读取 getBranch() 的完整持久历史,而不是
* AgentSession.messages(后者是发给模型的上下文,compaction 后会裁掉旧消息)。
*/
export function toBranchSessionMessages(branch: readonly SessionEntry[]): SessionMessage[] {
const raw = branch
.filter((entry): entry is SessionMessageEntry => entry.type === "message")
.map((entry) => entry.message);
return toSessionMessages(raw);
const messages = toSessionMessages(raw);
assignEntryIds(messages, branch);
return messages;
}

/**
Expand Down
27 changes: 26 additions & 1 deletion packages/backend/test/recall.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Message } from "@earendil-works/pi-ai";
import { SessionManager } from "@earendil-works/pi-coding-agent";
import { describe, expect, it } from "vitest";
import { resolveRecallEntryId } from "../src/session/messages";
import { resolveRecallEntryId, toBranchSessionMessages } from "../src/session/messages";

/** 构造带两条用户消息(+一条回复在中间)的内存会话,返回 manager 与 entry id */
function makeSession() {
Expand Down Expand Up @@ -60,6 +60,31 @@ describe("resolveRecallEntryId(撤回目标解析)", () => {
});
});

describe("压缩后的 UI 历史", () => {
it("读取完整会话树分支,而不是被 compaction 裁剪的模型上下文", () => {
const sm = SessionManager.inMemory();
sm.appendMessage({ role: "user", content: "压缩前第一条", timestamp: 1 } satisfies Message);
sm.appendMessage({ role: "user", content: "压缩前第二条", timestamp: 2 } satisfies Message);
const kept = sm.appendMessage({
role: "user",
content: "压缩前保留条目",
timestamp: 3,
} satisfies Message);
sm.appendCompaction("模型上下文摘要", kept, 10_000);
sm.appendMessage({ role: "user", content: "压缩后消息", timestamp: 4 } satisfies Message);

const uiHistory = toBranchSessionMessages(sm.getBranch());
expect(uiHistory.map((message) => message.text)).toEqual([
"压缩前第一条",
"压缩前第二条",
"压缩前保留条目",
"压缩后消息",
]);
// SDK 上下文确实已缩短,证明测试没有把两种数据源混为一谈。
expect(sm.buildSessionContext().messages.length).toBeLessThan(4);
});
});

describe("撤回持久化机制(SessionManager 层语义)", () => {
it("branch + custom entry 标记后,上下文排除被撤回消息且 leaf 落在标记上", () => {
const sm = SessionManager.inMemory();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useEffect } from "react";
import { getPi } from "../api";
import { useDraftStore } from "../stores/drafts";
import { EventConflator } from "../stores/event-conflator";
import { useProjectsStore } from "../stores/projects";
import { useSessionsStore } from "../stores/sessions";
import { pushExtensionToast } from "../stores/toasts";
import { useTranscriptStore } from "../stores/transcript";
Expand Down Expand Up @@ -32,7 +33,10 @@ export function useSessionEventBridge({
const offEvent = pi.onEvent(({ sessionId, event }: { sessionId: string; event: SessionEvent }) => {
if (event.type === "session_info_changed") {
useSessionsStore.getState().updateSessionName(sessionId, event.name);
return; // 会话名走 sessions store;reducer 对该类型本就无操作
// 新会话先以「未命名」meta 写穿进目录,首条消息随后才触发自动命名。
// 目录也必须同步,否则 GC 卸载内存项后,左栏会退回 cwd 末级名(如 percho)。
useProjectsStore.getState().applySessionName(sessionId, event.name);
return; // 会话名走 sessions + projects 两份投影;reducer 对该类型本就无操作
}
conflator.push(sessionId, event);
});
Expand Down
11 changes: 11 additions & 0 deletions packages/desktop/src/renderer/src/stores/projects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,17 @@ describe("会话目录写穿(存在性只依赖目录)", () => {
const rows = nav.projects.flatMap((project) => project.sessions.map((row) => row.session.sessionId));
expect(rows).toEqual(["new-1"]);
});

it("自动命名写回目录后,GC 卸载不会退回项目目录名", () => {
const unnamed = { ...freshMeta("new-1", "/work/percho"), name: undefined };
useSessionsStore.setState({ sessions: [unnamed] });

// 对应 session_info_changed 的目录投影同步。
useProjectsStore.getState().applySessionName("new-1", "用户第一条消息");
useSessionsStore.setState({ sessions: [] });

expect(useProjectsStore.getState().allSessions[0]?.name).toBe("用户第一条消息");
});
});

describe("projects.load latest-wins(spec D6)", () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/desktop/src/renderer/src/stores/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ interface ProjectsStore {
loaded: boolean;
load: () => Promise<void>;
/** 重命名成功后同步历史列表(见实现处注释) */
applySessionName: (sessionId: string, name: string) => void;
applySessionName: (sessionId: string, name: string | undefined) => void;
select: (cwd: string | null) => void;
setSearch: (search: string) => void;
addProject: () => Promise<void>;
Expand Down
2 changes: 1 addition & 1 deletion packages/desktop/src/renderer/src/stores/transcript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ export const useTranscriptStore = create<TranscriptStore>((set) => ({
unseenCompletion: false,
compacting: false,
followUpQueue: current?.followUpQueue ?? [],
// loadHistory 只换消息流(compaction 后对齐 pi 裁剪),todo 列表保留
// loadHistory 只换消息流(backend 回放会话树完整分支),todo 列表保留
todos: current?.todos ?? [],
// 错误卡派生自消息流本身,pending 状态不穿越回放
pendingLlmError: null,
Expand Down
Loading