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 @@ -199,7 +199,7 @@ src/
| 目录 | 内容 |
|---|---|
| `chat/` | **MessageList**(底部跟随 + 脱离回底;行模型 `useMemo`,轮末行定位规则在 shared chat-rows;**挂载窗口**见 `mount-window.ts`)/ **SelectionToolbar**(对话区选中文字浮出菜单:添加到对话/新会话继续)/ **MessageItem**(纯分发壳)+ `message-actions.tsx`(复制/Fork/撤回按钮)/ **UserMessage** / **SystemMessage**(compaction 分割线 + mutex 通知)/ **AssistantMessage** / **Markdown**(markstream-react 流式丝滑渲染,fade 关闭避免合成层闪烁;代码块只读编辑器关闭自动 decoration,样式覆写在 globals.css `.markdown-body`,见 PITFALLS)+ **MermaidBlock**(接管 markstream 的 mermaid code_block:loose 模式 + 悬浮工具栏 + 渲染失败报错卡;见 PITFALLS)/ **ToolCallCard** / **SubagentRunCard**(独立行,有 sessionFile 可点开子会话)/ **TodoPanel**(呼吸灯 + 展开 morph 同一容器)/ **MetaGroup**(memo 折叠组 + 圆点行/分类统计行)+ `use-sweep-highlight`(统一扫光)+ `use-shown-working`(working→worked 滞后缓冲)/ **PreviewTicker** + `activity-ticker`(工作中预览行调度)/ **StreamingMarquee**(溢出 tail-follow,位移用 shared marquee-motion)/ **ImagePreview**(全屏多图,portal 到 body)/ **ErrorNote**(统一报错卡)+ **RetryNote**(自动重试瞬态行)/ **CenterOrb** + `center-orb-draw`(中央状态动画,绘制闭式函数)/ **TurnDiffChip**(轮末计时行 + 文件变更 chip;计时器运行中 1s 心跳跳动/定格;文件行右键 + hover「⋯」→ 路径菜单 `ui/FilePathMenu`)/ `meta-summary-label`(i18n 胶水) |
| `diff/` | **useDiffSidebarOverlay**(宽度账本纯计算 + rAF 合帧 resize 监听:可用宽不够就转浮层)/ **DiffSidebar**(右侧变更侧栏:按轮分组 unified diff + 内嵌 BranchRow git 分支行;开关在 SessionTabBar;`cwd` 下传给卡片做相对路径基准)+ DiffFileCard(卡片头右键 + hover「⋯」→ 路径菜单) |
| `diff/` | **DiffSidebar**(右侧变更浮层抽屉:固定宽度 + transform 合成动画,不参与聊天列布局;按轮分组 unified diff + 内嵌 BranchRow git 分支行;开关在 SessionTabBar;`cwd` 下传给卡片做相对路径基准)+ DiffFileCard(卡片头右键 + hover「⋯」→ 路径菜单) |
| `composer/` | **Composer**(装配层 ~330 行,键盘事件分发)+ 三 hook:`use-composer-send`(ensureSession 懒创建/followUp 排队/停止先 clearQueue/发送失败草稿回填)、`use-slash-menu`(命令拉取+胶囊回填+导航)、`use-at-completion`(@ token 探测/续钻/胶囊弹回)+ 展示件 QueueBar/ImageTray/AttachmentChip/**QuoteChip**(选中引用胶囊)/SendErrorBar + **ModelPicker** / **ThinkingPicker**(按模型 thinkingLevels 过滤+clamp)/ **PermissionPicker**(会话权限模式 chip:默认/完全访问)/ **SlashMenu** / **AtMenu** / **ContextRing** + 纯函数 `slash-filter`/`at-files`/`send-error`/`quote`/`model-filter`(各有测试) |
| `session/` | **SessionTabBar** + SessionTab/TabPill(**v8:顶栏胶囊 = 置顶会话(不看 tab 开没开),严格只含置顶**,顺序就是 `pinnedSessions`;未置顶会话只在左栏;空态一行 12px `ink-faint` 提示;状态收拢到头像图标;dnd-kit 拖拽排序(改的也是 `pinnedSessions`),DragOverlay ghost + 轴锁定;拖拽期间退出 drag-region;**最左 = 左栏开合按钮 `PanelLeftIcon`**;胶囊右键 → `ui/ContextMenu`「重命名 / 置顶」;叉叉 = 取消置顶 + 从顶栏清除)/ **RenamePopover**(重命名浮层:锚在胶囊/指针锚点,160ms 进场 / 120ms 退场 + Enter/点外提交/Esc 取消)/ **SessionAvatar** + `session-status`(头像底色语义 + 状态/标题纯逻辑,顶栏与左栏共用;`SessionAvatar` 已无引用,留作备用)/ `session-menu`(菜单可用性与动作:`sidebarMenuKind` 定形态(普通 / 无菜单)、只读会话不给菜单、置顶只动 `pinnedSessions`、重命名落盘(**同步 tabs 与历史列表两份拷贝**)+失败 toast、复制诊断、删除会话;顶栏胶囊与左栏会话行共用)/ **ProjectBranchPicker**(从 `projects/` 移入的项目/分支选择器 chip;**只在无 active(新会话页)显示**,真实会话的项目创建时已绑定不可改)/ **DockSlot**(底部交换槽仲裁:权限 > 扩展对话框 > Composer)/ **ApprovalDock**(权限审批:async respond 成功才移除面板,失败保留重试)/ **InteractionDock**(扩展对话框四卡 select/input/editor/confirm:键盘 ↑↓/Enter/数字/Esc + 倒计时显示,裁决在 backend)/ **TrustDialog**(项目信任两选项)/ **UpdateButton**(顶栏更新按钮) |
| `settings/` | **SettingsDialog**(PANELS 注册表;分类 = 静态 + 插件 settings.panel 贡献动态拼接)/ **GeneralPanel**(语言/上下文管理二态/channel-watch 开关)/ **AppearancePanel**(顶部 Tab 分栏:「基础」=主题三段/背景图/**显示顶栏开关**/中央动画,「UI 插件」= 原独立分类并入的 UiPluginsSection:总开关/插件卡/槽位指派,设计稿 .local/design/ux/appearance-ui-plugins)/ **SkillsPanel** / **McpPanel**(SDK 0.84 无 MCP,占位)/ **ExtensionsPanel** + `extensions/`(目录浏览/安装/卸载,subagent 包安装两段式确认)/ **UiPluginsSection**(挂在 AppearancePanel 的 UI 插件 Tab 下) / **LanObserverPanel** / **AboutPanel** / `providers/`(ProvidersPanel + ProviderRow 操作全图标化 / LoginDialog(交互登录对话框:OAuth + api_key 交互流)/ CustomProviderForm + ModelRowsEditor(逐模型行编辑器)/ BuiltinProviderEditForm(内置端点覆写)/ SubagentPanel(子代理执行器开关 + 逐代理模型/思考深度偏好)+ `model-rows` 纯函数) |
Expand Down Expand Up @@ -232,7 +232,7 @@ src/
| 顶栏 tab / 拖拽排序 / 置顶 | `session/SessionTabBar.tsx`(dnd-kit,DragOverlay ghost 拾起时实测宽度沿用原胶囊 + 轴锁定 + drag-region 退出)+ `stores/sessions.ts` `selectBarSessions`(**顶栏严格 = 置顶表**:会话被置顶就算 tab 没开也在顶栏,点击自动开;新会话不进顶栏)+ `stores/ui-preferences.ts` `reorderPinned`(拖拽改 `pinnedSessions` 顺序) |
| 左侧栏(分组 / 开合 / 置顶 / 搜索 / 移除项目 / 会话右键菜单) | 组件 `components/sidebar/*`(容器 → `Sidebar`;纯派生 → `lib/sidebar-groups.ts`,有单测)+ 持久化字段 `barSessionsVisible` / `sidebarCollapsed` / `expandedGroups` + `expandedGroupsTouched` / `pinnedProjects`(`ui-preferences.ts` → main `ui-state.ts` normalize + 旧文件缺字段时的 touched 迁移)+ 宽度过渡与状态点呼吸 `styles/globals.css` 的 `.sidebar*` 段;置顶会话的行首图钉在 `sidebar/SessionRow.tsx`(行首 16×16 图标槽,标题 x 恒 38);逐帧截图 `scripts/shoot-sidebar.mjs` |
| 顶栏内容 / 显隐 | 顶栏**常驻**(窗口拖动、左栏开合、变更侧栏入口都在上面,v9 起不再整条隐藏)→ `App.tsx` 无条件渲染 `SessionTabBar`;设置项「顶栏显示会话」(`settings/AppearancePanel.tsx` → `barSessionsVisible`)只控制**要不要出置顶会话胶囊**:关掉后顶栏不出胶囊、也不出空态提示,会话全在左栏(v9 已删旧版的三处兜底:左栏头 44px 拖拽带 + 「+」、聊天列顶 12px 隐形拖拽带、左栏底部「本轮改动」入口) |
| 右栏(push ↔ 窄窗浮层) | `components/diff/DiffSidebar.tsx` + `useDiffSidebarOverlay.ts`(宽度账本:可用宽 < 380 + min(420, 38vw) → 转浮层)+ `globals.css` 的 `.diff-sidebar.is-overlay` 段;开关在顶栏(顶栏关闭时在左栏底部) |
| 右侧变更浮层 | `components/diff/DiffSidebar.tsx` + `globals.css` 的 `.diff-sidebar`(固定 `min(420px, 38vw)`,transform 进退,不压缩聊天列);开关在顶栏(顶栏关闭时在左栏底部),关闭入口 = 开关 / 面板关闭按钮 / Esc |
| 权限审批面板 | `backend/src/permissions/gate.ts`(队列)+ `session/ApprovalDock.tsx`(快捷键 Enter/A/D/Esc;await 成功才移除) |
| 扩展交互(issue #45) | 契约 `shared/src/extension-dialog.ts` → `backend/src/session/extension-dialog-host.ts`(队列+裁决)→ `main/ipc/` 转发 → `stores/transcript.ts` pendingDialogs → `session/InteractionDock.tsx`;notify→`stores/toasts.ts` pushExtension;setEditorText→草稿+预填提示(Composer) |
| 会话权限模式(默认/完全访问) | 后端:`pi-backend.ts`(`permissionModes` map 内存态 + `get/setSessionPermissionMode`)+ `permissions/extension.ts`(fullAccess 审计分支)+ `permissions/audit.ts`;IPC:`PermissionGetMode/SetMode`;前端:`composer/PermissionPicker.tsx`(chip;新会话页读写 `newSessionDraft.permissionMode`)+ `stores/sessions.ts`(真实会话走 `permissionModes` map,promotion 成功后把 draft 档位应用到新 id)+ `use-composer-send.ts`。**模式不写进 permissions.json、不被新会话继承(spec D1);D7:按会话持久化到 ui-state.json 的 sessionPermissionModes(只存非 default,切回默认即删键),打开会话时 await 恢复——重启/内存回收后重开同一会话保住原档位,新会话与 fork 仍从 default 起步** |
Expand Down
9 changes: 9 additions & 0 deletions docs/PITFALLS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
| onDragStart 里拿不到拖拽尺寸(`active.rect.current.initial` 恒 null) | 四 · dnd-kit rect ref 填充晚于 onDragStart |
| 报错文案悬在空态页不消失、切新会话还在 | 四 · store 级 error 字段永不清理(已修:改 toast + 乐观回滚) |
| 切到长会话卡顿约 1 秒、消息多的会话越久越卡 | 四 · 长会话切会话卡顿(挂载窗口 + ToolCallCard 布局抖动)(2026-09-12 修复) |
| 有内容的会话开右侧变更栏掉帧,新会话却丝滑 | 四 · 右侧栏 width push 动画导致聊天区逐帧重排(2026-09-21) |
| 已完成会话上滚滚不动、要大力滚,贴底还吸附(0.5.8 线上 bug) | 四 · 长会话切会话卡顿 → 三次修复(markstream content-visibility 600px 估值占位)(2026-09-16 修复) |
| 长会话里上滚,位置被反复重置/拽回底部(0.5.7 线上 bug) | 四 · 长会话切会话卡顿 → 二次修复(markstream 占位条缩水 + 手写滚动补偿)(2026-09-13 修复) |
| 改了 `src/main/` 但 app 行为没变(dev 不重建主进程) | 三 · electron-vite dev 主进程 watcher 不可依赖(2026-09-17) |
Expand Down Expand Up @@ -427,6 +428,14 @@ pi SDK 必须声明进 `packages/desktop/package.json` dependencies(electron-b

挂载窗口的两个已知面:① **切走再切回窗口复位回尾部 40 行**(每次切换成本恒定,不会越用越慢);② 窗口只增不减 ⇒ 滚过的行一直挂着,DOM 上限 = 该会话全量(换来的好处是不做反向回收、无滚动跳变)。③ 页内查找不受影响:Electron 默认菜单没有 Find、代码也没调 `findInPage`,Cmd+F 本来就没有;将来若做消息搜索,应查 transcript store 而非 DOM,与挂载窗口无关。

### 右侧栏 width push 动画导致聊天区逐帧重排(2026-09-21)

**症状**:新会话页打开/收起变更栏很顺,有消息内容的会话明显掉帧;内容越长越明显。

**根因**:右栏原来在同一 flex 行里做 `width: 0 → min(420px, 38vw)` 的 420ms push 过渡。每个动画帧都会改变聊天列宽度,迫使 Markdown 重新换行和整列布局;`MessageList` 的 ResizeObserver 又会在跟随底部时逐帧调用 `scrollTo`。实测一次开栏约 52 次布局、42–50 次贴底滚动;隐藏 diff 内容改善很小,强制脱离布局后 LayoutDuration 从约 37–43ms 降到 3–5ms,说明瓶颈不在 diff 行本身。

**修复**:右栏统一为绝对定位浮层抽屉,宽度固定,进退只动画 `transform/opacity`;聊天列完全不改宽。点击聊天区不自动关闭,保留顶栏开关、面板关闭按钮和 Esc,便于边看消息边核对变更。不要用 `max-width`、grid 列宽或另一种尺寸属性代替 `width`——它们仍然逐帧触发布局;要丝滑必须让运动留在合成层。

### macOS 关窗 = 隐藏窗口:三个反直觉点(2026-09-17,issue #55)

行为:macOS 下点红点/⌘W → `preventDefault() + win.hide()`(app 继续跑,Dock 点回原窗口原状态);⌘Q / 菜单退出仍要真退。
Expand Down
2 changes: 1 addition & 1 deletion packages/desktop/src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export default function App() {
<div className="relative flex min-h-0 flex-1">
<Sidebar />
<div className="relative flex min-w-0 flex-1 flex-col">
{/* 中间列保底 380 = 宽度账本(画板 C),右栏挤不下时已由 DiffSidebar 自己转浮层 */}
{/* 右侧 DiffSidebar 是独立浮层,不参与中间聊天列宽度与布局动画。 */}
<main className="relative min-h-0 flex-1">
{showEmpty ? <EmptyState /> : <MessageList />}
<Slot name={UI_SLOTS.TodoPanel} props={{}} fallback={TodoPanel} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { useTranscriptStore } from "../../stores/transcript";
import { useUiStore } from "../../stores/ui";
import { CloseIcon } from "../icons";
import { DiffFileCard } from "./DiffFileCard";
import { useDiffSidebarOverlay } from "./useDiffSidebarOverlay";

/** 空 turn 列表稳定引用(selector/useMemo 缺省,禁内联新数组) */
const EMPTY_TURNS: TurnChanges[] = [];
Expand Down Expand Up @@ -90,8 +89,8 @@ function BranchRow() {
}

/**
* 右侧 diff 栏(宽度 0 ↔ 420px 过渡,聊天列自然压缩)。
* 窄窗(可用宽放不下「380 保底 + 右栏」)自动转浮层叠在聊天列上,点外/Esc 收起(见 useDiffSidebarOverlay)。
* 右侧 diff 浮层抽屉:固定宽度、transform 进退,不参与聊天列布局。
* 关闭只走顶栏按钮 / 面板关闭按钮 / Esc;点击聊天区不收起,便于对照消息与变更。
* 按轮次倒序分组(最近一轮在上);分段开关「全部 / 最近一轮」;
* chip 跳转 = stores/ui.ts 的 diffFocus → 这里直接操作 DOM(开卡片 + 滚动定位 + 闪烁)。
* 开关状态内存态不持久化(重启统一关闭,用户已定)。
Expand Down Expand Up @@ -120,26 +119,15 @@ export function DiffSidebar() {
const totalRemoved = visibleTurns.reduce((s, tc) => s + tc.totalRemoved, 0);
const bodyRef = useRef<HTMLDivElement>(null);

const overlay = useDiffSidebarOverlay();
const asideRef = useRef<HTMLElement>(null);

// 浮层模式的「点外收起」+ Esc:push 模式不接(右栏是常驻分栏,点聊天区不该关它)
// 浮层保持常驻,点击聊天区不收起;Esc 是关闭按钮之外的键盘出口。
useEffect(() => {
if (!overlay || !open) return;
const onPointerDown = (e: PointerEvent) => {
if (e.target instanceof Node && asideRef.current?.contains(e.target)) return;
setOpen(false);
};
if (!open) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
window.addEventListener("pointerdown", onPointerDown);
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("keydown", onKeyDown);
};
}, [overlay, open, setOpen]);
return () => window.removeEventListener("keydown", onKeyDown);
}, [open, setOpen]);

// chip → 侧栏跳转:目标不在当前分段时先切「全部」(effect 随 visibleTurns 变化重跑),再定位
useEffect(() => {
Expand Down Expand Up @@ -168,11 +156,7 @@ export function DiffSidebar() {
}, [diffFocus, visibleTurns, clearDiffFocus]);

return (
<aside
ref={asideRef}
className={`diff-sidebar${open ? " open" : ""}${overlay ? " is-overlay" : ""}`}
aria-hidden={!open}
>
<aside className={`diff-sidebar${open ? " open" : ""}`} aria-hidden={!open} inert={!open}>
<div className="diff-sidebar-in">
<div className="diff-side-head">
<span className="diff-side-title">{t("diff.title")}</span>
Expand Down

This file was deleted.

Loading
Loading