---
url: /wave-agent/specs/core/recent-workdirs.md
description: 机器级持久化的最近打开目录（~/.wave/recent-workdirs.json）：桌面端按主机读取、远控 Web 页新建会话时共用同一份
---

# 功能规格说明：最近工作目录

**创建日期**：2026-10-21

## 澄清

### 2026-10-21 决策

* **载体从「各端自己记」改成「这台机器记一份」**：桌面端原来写自己的 `userData/wave-desktop.json`（按 host 分组），浏览器侧 localStorage 只属于那台手机。两者都不是「这台机器」——远控页把会话全删光后最近目录直接变空，就是这个错位的直接后果。
* **文件**：`~/.wave/recent-workdirs.json`，与 `auth.json` / `remote-settings.json` 同级（用户级，跟机器走）。结构 `{ "dirs": string[] }`，MRU 顺序，上限 10（与桌面端 `MAX_RECENT_WORKDIRS` 对齐），原子写。
* **写**：谁让用户选了目录，谁就向**那台机器**记一条——桌面端在工作目录选择器选定目录时、远控页在新建会话成功后。恢复已有会话、agent 在会话里 `cd` 到别处、只在目录浏览器里翻看，都不写。
* **不在会话创建路径里自动写**：worktree 新建是两步（`createWorktree` → `initialize(workdir=工作树路径)`），服务端拿到的已经是被派生的路径，判断「用户到底选了哪个目录」要靠猜；把写入点放在调用方（它手里就是用户选的那个目录）更准。
* **读**：bridge 新增只读方法 `getRecentWorkdirs`（无入参，回这台机器的 MRU）。
* **worktree**：记仓库根，不记工作树路径（与列表「只列主仓库根」的既有口径一致）。
* **桌面端**：按主机向那台机器读（本机 stdio 子进程 / 远端 daemon），本地那份降为「旧版机器没有这条方法」时的回退缓存。
* **远控页**：活会话派生的目录在前、机器 MRU 在后，跨两段去重合并；读不到就退回现状。
* **白名单**：32 → **34**（读 `getRecentWorkdirs` + 写 `rememberRecentWorkdir`）。写入并没有把「能建会话」之外的能力开出去——页面本来就能在任意目录 `initialize`，这里额外能做的只是让某个路径出现在列表里。

### 2026-11-03 决策（列表项按「目录名 + 父路径」两行显示，分隔符跟着那台机器）

* **条目的第一行是目录名，第二行是父路径**（完整路径留在 `title` 上），与桌面端最近打开、侧边栏分组标题同一份口径——用户看的是「哪个项目」，不是一长串路径。
* **拆分必须同时认 `/` 与 `\`**：目录拼写跟着那台机器走，远端是 Windows 时就是系统原生的 `C:\Users\…`（`realpath` 的结果），而 worktree 会话那条 `git worktree list` 给的是正斜杠。只按 `/` 切时整条 Windows 路径会落进第一行——远控页「新建对话」的最近目录就是这么坏的（桌面端早前踩过同一条，所以两边现在共用 `packages/webview/src/utils/workdirPath.ts`）。
* **浏览面板同理**：面包屑逐级、`…` 上级、进入子目录的拼接都按当前路径的风格走（盘根 `C:\` 就是根，不显示 `…`），否则 Windows 主机上点面包屑会落到盘根、上级也去错地方。

### 2026-08-05 决策（工作树路径不出现在列表里、移除要落到那台机器）

* **「worktree 记仓库根」从契约变成两侧都执行的口径**：原来只在调用方手里「用户选的是哪个目录」，用户直接从目录浏览器里点进某个工作树、或历史遗留数据里已有一批工作树路径时，列表里就出现 `.wave/worktrees/<name>` 这种条目。改为在 SDK 的 `~/.wave/recent-workdirs.json` 读写两侧各归一一次：**写**时目录本身是 linked worktree（`.git` 是文件、其指向的 git 目录里 `commondir` 指向主仓库的 `.git`）就记主仓库根；**读**时对每一条做同一次归一，因此本规则落地前写进去的工作树路径读出来就是仓库根，并与仓库根那条**合并去重**。工作树目录已被删除（会话删了，git 元数据也没了）时按 Wave 自己的布局 `<repoRoot>/.wave/worktrees/<name>` 兜底换算——否则一条只剩路径的遗留条目会永远冒充一个项目。只归一「目录本身就是工作树根」这一层——用户选的是工作树的子目录时保持原样，不向上猜（猜错会把用户选的目录换掉）。
* **移除必须落在那台机器上**：桌面端最近目录项上的「从列表移除」原来只删本地回退缓存（`wave-desktop.json`），机器那份文件没动，紧跟着的重读又把它带回来——这就是「删不掉」。新增只写方法 `forgetRecentWorkdir`（参数是目录，按上面的归一口径比较，把该目录的所有写法一并删掉），桌面端移除时先问机器、成功后再删本地缓存。
* **旧版机器 / 不可达**：机器没有 `forgetRecentWorkdir`（旧版 CLI/daemon）时降级为只清本地缓存——条目可能在下一次读取时重现，但不得报错、不得卡住界面；主机不可达时按既有约束**保留**条目（[desktop-remote.md](../desktop/desktop-remote.md)「远端主机不可达时不得删除会话/最近目录」）。
* **`forgetRecentWorkdir` 不进远控页白名单**：白名单回答的是「页面能做什么」，页面从来没有移除入口（本文件用户故事 3 场景 5：页面不能改动已有条目），放行只会扩大暴露面。桌面端走自己的 stdio/隧道通道，与白名单无关。

## 用户场景与测试 *（必填）*

### 用户故事：最近目录记在这台机器上（优先级：P1）

作为用手机和桌面端轮着用同一台电脑的用户，我希望「最近打开过的目录」记在这台电脑上，以便换手机、清浏览器数据、甚至把所有会话都删光之后，下次新建会话仍能一键回到最近用过的目录。

**为什么是这个优先级**：它是两端能共用同一份列表的前提。没有它，远控页的最近目录只能从活会话派生（`listDaemonSessions`），会话一删就归零——那正是这个能力被发现的原因。

**独立测试**：向一台机器写几条最近目录，检查 `~/.wave/recent-workdirs.json` 的内容与顺序；重复写同一目录验证只保留一条并置顶；写到第 11 条验证最旧的被挤出；删光会话后验证文件内容不变；把文件内容改成非法 JSON 后验证读取按空列表返回且会话创建不受影响。

**验收场景**：

1. **假设** 用户在某目录选定并新建了会话，**则** 那台机器的 `~/.wave/recent-workdirs.json` 中该目录被置顶；已存在的同名条目只保留一条（比较前去掉尾部分隔符，`/repo` 与 `/repo/` 视为同一个）
2. **假设** 列表已有 10 条，**则** 新的写入把最旧的一条挤掉，文件最多保留 10 条
3. **假设** 用户建的（或直接选定的）目录是一个 linked worktree，**则** 记入的是**仓库根目录**，工作树路径不进入列表；**当** 文件里已经存在工作树路径的条目（规则落地前的历史数据），**则** 读取时必须显示为它对应的仓库根，并与仓库根那条按路径去重合并为一条；**当** 该工作树目录已被删除（`git` 元数据随之消失），**则** 按 `<repoRoot>/.wave/worktrees/<name>` 布局换算仓库根；**假设** 用户选的是工作树内的子目录，**则** 按用户所选原样记录（只归一「目录本身就是工作树根」这一层，不向上猜）
4. **假设** 用户恢复一条已有会话、在会话里用 bash `cd` 到别处、或只在目录浏览器里翻看而没有选定，**则** 列表不变
5. **假设** 文件不存在、内容不是合法 JSON、或目录不可写，**则** 读取按空列表处理、写入静默失败；会话创建与其余功能不受影响，调用方不因它报错
6. **假设** 同一台机器上多个进程（daemon / 桌面端子进程 / CLI）先后写入，**则** 每次写入都以「读现状 → 合并 → 原子替换」完成；并发下允许丢掉一次最近更新，不允许文件损坏成不可解析

***

### 用户故事：桌面端按主机读那台机器的列表（优先级：P1）

作为桌面端用户，我希望切换主机时看到的最近打开目录就是**那台机器**自己的记录，以便它和手机端（远控页）看到的是同一份，而不是桌面端在一个本地文件里替所有主机攒出来的副本。

**为什么是这个优先级**：不切的话两边会长期分叉——同一台电脑，桌面端本机组一份、远控页一份，用户会在两端看到不同的「最近打开」。桌面端既有的行为契约（只由用户主动选择更新、启动取首项、切主机取该主机首项、工作树路径不入列表）**一条都不变**，变的只是数据从哪来。

**独立测试**：选一台主机打开工作目录选择器，验证列表与该主机 `~/.wave/recent-workdirs.json` 一致；在同一台机器上用远控页新建一个会话，回到桌面端该主机验证目录已出现；把该主机的方法挡掉（模拟旧版机器）验证回退到本地缓存且仍可继续工作；恢复一条历史会话验证最近打开列表顺序不变。

**验收场景**：

1. **假设** 用户选中某台主机，**则** 最近打开列表来自**那台机器**（`getRecentWorkdirs` 走该主机的通道：本机是 stdio 子进程、远端是隧道里的 daemon）；同一路径在另一台主机上不出现
2. **假设** 用户用远控页在这台机器上新建过会话，**则** 桌面端该主机的最近打开能看到那个目录；反之桌面端选过的目录，远控页新建会话时也能看到（两端同一份）
3. **假设** 该主机的 version 旧到没有 `getRecentWorkdirs`，**则** 桌面端回退到本地 `wave-desktop.json` 里那个主机的分组，**不得**显示空列表或报错；此后该主机的本地分组仍按用户的选择继续更新（否则回退列表再也不长）
4. **假设** 用户在桌面端选定目录（本机 OS 对话框 / 远端目录浏览面板 / worktree 会话用到的仓库根），**则** 向**那台机器**记一条（`rememberRecentWorkdir`），同时继续更新本地缓存那份回退数据
5. **假设** 用户恢复历史会话、或 agent 工作目录在会话中被 bash 改写，**则** 不写入（既有契约不变，见 [desktop-sessions.md](../desktop/desktop-sessions.md) 场景 9）
6. **假设** 用户点最近打开条目上的「从列表移除」，**则** 桌面端必须先把这项目录从那台机器的 `~/.wave/recent-workdirs.json` 里删掉（`forgetRecentWorkdir`，按归一口径比较：工作树路径与仓库根同属一项），再删本地回退缓存并刷新列表，刷新后该条目**不得**重新出现；**当** 该主机的版本没有 `forgetRecentWorkdir`，**则** 降级为只删本地缓存（条目可能在下一次读取时重现），不报错、不卡界面；**当** 主机不可达，**则** 保留该条目，仅提示无法连接主机

***

### 用户故事：远控页新建会话时列出机器侧最近目录（优先级：P1）

作为用手机发起新会话的用户，我希望「新建会话」里除了活会话的目录，还能看到这台电脑最近用过的目录，以便删光了所有会话、或者想开一个之前用过但眼下没有会话的目录时，不必每次都从「浏览…」一级级点进去。

**为什么是这个优先级**：这是这份机器级记录的第一个直接受益者，也是「会话全删光后最近目录变空」这个具体缺陷的修法。

**独立测试**：在机器上先建立过若干目录的会话，把手机端的会话全部删除，再打开「新建会话」，验证最近目录仍列出那些目录；把 `getRecentWorkdirs` 挡掉（模拟旧版电脑）验证退回只列活会话目录且不报错。

**验收场景**：

1. **假设** 用户打开「新建会话」，**则** 最近目录是两段合并的结果：**活会话派生的目录在前**（`repoRoot ?? workdir`，按最近活跃排序，口径见 [remote-web.md](../remote/remote-web.md) 本故事场景 2），**机器 MRU 在后**，两段之间按路径去重
2. **假设** 手机端把会话全部删光（活会话为 0），**则** 最近目录仍列出机器 MRU，不再只剩「浏览…」
3. **假设** 电脑侧是旧版本（`getRecentWorkdirs` 不可用），**则** 退回只列活会话派生的目录（本能力引入前的行为），**不得**因此报错或把弹窗置成空
4. **假设** 列表里的某个目录已经在机器上被删除，**则** 读取时**不过滤**（不逐条 stat）；用户点选后创建失败按既有错误路径提示
5. **假设** 有人问「页面能不能直接改写这份列表」，**则** 不能：写方法 `rememberRecentWorkdir` 只在新建会话成功后由页面调用一次，参数是那次创建用的目录；页面没有其它入口改动它，也不能借它删改已有条目
6. **假设** 那台电脑是 Windows（目录是 `C:\Users\…` 这种系统原生拼写），**则** 每个最近目录条目仍显示为「目录名（第一行）+ 父路径（第二行）」，完整路径只在 `title` 上；「浏览…」面板的面包屑逐级可点、`…` 上级与进入子目录都按反斜杠拼接（`C:\` 就是根）——不得把整条路径当成一段显示，也不得把用户带到一个拼错的路径上（拆分与拼接规则见 2026-11-03 决策，两侧共用同一份实现）
