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
90 changes: 90 additions & 0 deletions docs/webui.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,96 @@ Contract details:

The operator-facing view — which models show what control, and why MiniMax-M3 only has on/off — is the thinking section of [`webui.zh-CN.md`](webui.zh-CN.md).

## Session switch follows workspace (webui-parity ticket 39)

Switching to another session re-points the active workspace to the
session's stored workspace. The file tree (slice 01) re-roots under
the new directory; the old project's `webui:files-tree:<workspaceDir>`
key is left intact in `sessionStorage` so the user returns to the
same expanded set if they switch back.

### Contract

`POST /api/sessions/switch` (`routes/sessions.js#handleSwitchSession`)
resolves the workspace in this order:

1. **Target session's stored `workspace`** — that is the workspace the
user was in when they last had this session open, modulo any
pollution a previous code path introduced.
2. **`DEFAULT_WORKSPACE`** (env `MCODE_WORKSPACE` > mcode TUI
`cwd.json` > `homedir()`) when the stored value is empty. Empty
is the shape legacy sessions or `②`-polluted records carry.
3. **Refuse with `400`** if the resolved path fails
`assertWorkspacePath` containment (e.g. the user tightened
`MCODE_WEBUI_WORKSPACE_ROOTS` since the session was last opened).

The chosen path runs through the same `assertWorkspacePath` gate that
the workspace picker (`/api/workspace`),
`browseWorkspace` (`/api/workspace/browse`), the new-session POST, and
`/api/fs/*` all funnel through — refusing to switch into an
out-of-bounds path is the same boundary the picker refuses to land on.

The switch NEVER overwrites the target session's stored `workspace`
with the previous `cs.workspace.dir`. That was the pre-fix behaviour
(ticket 39's ② pollution path): every first-touch of an `mvs_`
session from project A inherited A's path, so the per-project
session grouping ended up duplicating A's directory for every
session the user opened from A. Newly-created overlays now start with
`workspace: ""`; the target-first read picks `DEFAULT_WORKSPACE` for
them.

The switch does NOT update `cs.lastUsedWorkspace`. The session bar's
"recent" sort (slice 07) is written only by `handleSend`; switching is
browsing, not authoring, and the previous "click any session and the
session jumps to the top" behaviour was the report that pinned that
contract.

### Mid-run safety

Switching mid-run does NOT abort the in-flight turn. The run's
ownership and stream buffer are keyed by `(cid, mcodeSessionId)` in
`lib/state-bus.js#runChatByCid`, not by workspace. The engine child
process holds its own cwd from when the turn started; the new
`cs.workspace.dir` is purely the next-viewing surface. Finalize-time
behaviour (`routes/chat.js` finalize drain) is unchanged: still-viewing
writes into `cs.chat`; switched-away writes via
`appendChatToSession(owningSid, lines)`.

### What the user sees

- **Side effect**: the file-tree panel re-roots under the new
workspace's root; expansion / filter / showHidden for the old
workspace stay preserved in `sessionStorage`, the new workspace
starts at its own stored expansion (or empty if never opened).
- **Failure mode**: an out-of-bounds stored workspace returns `400`
with the gate's actionable message
(`工作区越界: <path> 不在任何允许根内。允许根: …`). `cs.workspace.dir`
is NOT re-pointed; the previous workspace stays active.
- **Pre-existing polluted sessions** (recorded before this fix carried
the previous project's path): the new rule reads the stored value
verbatim. The user sees the polluted path's file tree until they
open the workspace picker and re-pick the intended directory; that
one re-pick rewrites the stored value to the canonical realpath.

### What contributors will change

- `routes/sessions.js#handleSwitchSession` adds
`_resolveSwitchWorkspace(target, currentWs)` and writes
`cs.workspace = { dir: switchWs.dir, branch: null, tree: null }`
after the target is resolved and before `resetContext`.
- `routes/sessions.js#handleSwitchSession` no longer passes
`workspace: ws` to `ensureOverlayForMcodeSid` — new overlays start
with `workspace: ""`; the read picks `DEFAULT_WORKSPACE` for
first-touch `mvs_` switches.
- The response payload gains `session.workspace` and
`session.workspaceFallback`; the SSE state push (handled by
`pushStateFor(cid)` at the end) carries the new `cs.workspace.dir`
verbatim, so `FilesPanel`'s `useSessionContext()` subscription
re-renders without any client-side wiring change.
- `routes/sessions.js#_eventsAppend("session.switch", …)` records
`workspace` and `workspaceFallback` so post-mortems can answer
"why did the file tree jump".

## File tree (delivered UI)

Every shipped file tree, panel and column evidence is `grep`-able. The list
Expand Down
35 changes: 35 additions & 0 deletions docs/webui.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,41 @@ exec 回合的代价——以下都是当前真实存在的行为,选择权限

接口契约(字段、两条下发通道、会话启动时的重放规则)见 [`webui.md`](webui.md) 的 Thinking levels 一节。

## 切换会话会带工作区一起切(webui-parity ticket 39)

切换到另一个会话时,当前工作区会跟到目标会话上次所在的工作区。文件树(slice 01)在新目录下重新取根;旧项目在 `sessionStorage` 里的 `webui:files-tree:<workspaceDir>` 展开态保留不动,所以切回去仍是同一份展开。

### 契约

`POST /api/sessions/switch`(`routes/sessions.js#handleSwitchSession`)按以下顺序解析工作区:

1. **目标会话存的 `workspace`**——即该会话上一次被打开时所在的工作区。该字段在旧的实现里被污染过,所以这里读出来就是用户上次看到的样子。
2. **`DEFAULT_WORKSPACE`**(环境变量 `MCODE_WORKSPACE` > mcode TUI 的 `cwd.json` > `homedir()`),当存的值为空字符串时。空值是老会话、或被 ticket 39 标记为 ② 污染过的记录会出现的形状。
3. **解析得到的路径无法通过 `assertWorkspacePath` 围栏**时(例如会话记录的工作区落在用户后续收窄的 `MCODE_WEBUI_WORKSPACE_ROOTS` 之外),**400 拒绝**,不让用户带着非法工作区进入路径。

选定的路径走的是工作区选择器(`/api/workspace`)、`browseWorkspace`(`/api/workspace/browse`)、新建会话 POST、`/api/fs/*` 共用的同一道 `assertWorkspacePath` 围栏——拒绝切到越界路径,与选择器拒绝落在越界位置是同一条边界。

切换**永远不会**用前一个 `cs.workspace.dir` 覆盖目标会话存的 `workspace`。那是旧代码的行为(ticket 39 的 ② 污染路径):在项目 A 首次打开某个 `mvs_` 会话时,新建的壳记录被印上 A 的路径,于是按项目分组的会话表里把 A 的目录复制给了每一个从 A 打开的会话。修复后,新建壳的 `workspace: ""`,由后续的「读目标,缺失则 DEFAULT_WORKSPACE」逻辑接管。

切换**不会**更新 `cs.lastUsedWorkspace`。侧栏"最近"排序(slice 07)只由 `handleSend` 写——切换是浏览,不是创作;旧版"点哪个会话哪个就置顶"的用户反馈已经把那条契约钉死了。

### Mid-run 安全

回合正在跑的时候切换会话**不会**打断这个回合。回合的所有权和流缓冲以 `(cid, mcodeSessionId)` 为键存在 `lib/state-bus.js#runChatByCid`,与工作区无关。引擎子进程持有回合开始时的 cwd,新的 `cs.workspace.dir` 只是"接下来要在哪个工作区里看"。终态时(`routes/chat.js` finalize drain)的行为保持不变——仍在查看就写进 `cs.chat`,已切走就走 `appendChatToSession(owningSid, lines)`。

### 用户能看到什么

- **侧效**:文件树面板在新工作区下重新取根;旧工作区的展开/过滤/显示隐藏项仍存在 `sessionStorage` 里,新工作区从它自己存过的展开态(若从未打开则为空)开始。
- **失败形态**:越界的工作区返回 `400`,带围栏的明确文案(`工作区越界: <path> 不在任何允许根内。允许根: …`)。`cs.workspace.dir` 不会被改,前一个工作区继续生效。
- **历史上被污染过的会话**(修复之前被打上过错误工作区路径的记录):新规则会原样读出存的值。用户看到的是被污染过的目录的文件树,需要打开工作区选择器重选一次预期目录;那一次重选会把存的值改写成 canonical realpath。

### 改动落在哪里(给后续维护者)

- `routes/sessions.js#handleSwitchSession` 新增 `_resolveSwitchWorkspace(target, currentWs)`,在 target 解析完成、`resetContext` 之前写入 `cs.workspace = { dir: switchWs.dir, branch: null, tree: null }`。
- `routes/sessions.js#handleSwitchSession` 不再向 `ensureOverlayForMcodeSid` 传 `workspace: ws`——新建壳的 `workspace: ""`,由读路径在首次 `mvs_` 接触时回退到 `DEFAULT_WORKSPACE`。
- 响应负载多了 `session.workspace` 和 `session.workspaceFallback`;尾部 `pushStateFor(cid)` 推 SSE 时原样带上新的 `cs.workspace.dir`,`FilesPanel` 通过 `useSessionContext()` 订阅自然重新渲染,前端**不**需要为了这次修复改动任何接线。
- `routes/sessions.js#_eventsAppend("session.switch", …)` 写入 `workspace` 和 `workspaceFallback`,事后追查"为什么文件树跳了"时可以从审计链里直接定位。

## 文件树(已发布的 UI)

下方每个已发布的文件树、面板与列都给出组件文件锚点与一个
Expand Down
136 changes: 127 additions & 9 deletions packages/webui/server/routes/sessions.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import {
clients,
runChatViewChat,
} from "../lib/state-bus.js";
import { MCODE_RUNTIME_DB } from "../lib/config.js";
import { MCODE_RUNTIME_DB, DEFAULT_WORKSPACE } from "../lib/config.js";
import { getSessionTree, invalidateSessionTree } from "../lib/session-tree.js";
import { authorize } from "../lib/authorize.js";
import { pushAlert } from "../lib/alerts.js";
Expand All @@ -46,6 +46,55 @@ import { append as _eventsAppend } from "../lib/events.js";
// workspace write lands on the same boundary.
import { assertWorkspacePath } from "../lib/workspace.js";

// _resolveSwitchWorkspace — pick the workspace the switched-into session
// "belongs to" and run it through the same containment gate that the
// workspace picker / handleNewSession / browseWorkspace all funnel through.
//
// Source priority (s39 — webui-parity ticket 39: file tree must follow the
// switched session):
//
// 1. The target session's stored `workspace` field — that IS the
// workspace the user was in when they last had it open, modulo any
// pollution the old code introduced. Real existence + containment
// are checked; an out-of-bounds or stale value surfaces as a 400
// so the user can either widen the allowed roots or pick a fresh
// workspace, instead of silently landing on the previous project.
//
// 2. DEFAULT_WORKSPACE (env MCODE_WORKSPACE > mcode TUI cwd.json > homedir)
// when the stored value is empty. Empty is also the value seen for
// (a) records created by the old code that polled freshly-typed mvs
// sessions with the current cs.workspace (the data-corruption bug
// this ticket fixes), and (b) older sessions that pre-date the
// workspace field. DEFAULT_WORKSPACE is already in the default
// allowed-roots surface (see getAllowedWorkspaceRoots), so the
// containment check accepts it without env setup.
//
// Critical invariants:
// - The switch NEVER keeps cs.workspace on the prior project. The
// user-reported symptom was exactly that: "the file tree still
// shows the previous project's files". Falling back to current ws
// when target.workspace is empty is the bug we are removing.
// - The switch NEVER writes cs.workspace.dir to a path the
// containment gate rejected. A 400 with the gate's actionable
// error is the only acceptable outcome.
// - The switch NEVER overwrites a target session's stored workspace
// with the current cs.workspace. That was the ② pollution path —
// re-introducing it would re-break the regression we just fixed.
// New overlay records (mvs_ first-touch) get workspace:"" here; the
// target-first read picks DEFAULT_WORKSPACE for them.
function _resolveSwitchWorkspace(target, currentWs) {
const raw = target && typeof target.workspace === "string" ? target.workspace.trim() : "";
// Empty / non-string / null → DEFAULT_WORKSPACE. Never the current cs
// workspace — that's the user-reported "stays on the old project"
// failure mode this fix removes.
const candidate = raw || DEFAULT_WORKSPACE;
const gate = assertWorkspacePath(candidate);
if (!gate.ok) {
return { ok: false, error: gate.error, attempted: candidate };
}
return { ok: true, dir: gate.path, real: gate.real, fallback: !raw };
}

/**
* Detect the cumulative-render pollution pattern in a stored chat
* buffer (session-isolation/06). When the engine emits each segment
Expand Down Expand Up @@ -305,8 +354,21 @@ export async function handleSwitchSession(req, res, ctx) {
// uuid wrapper → the same conversation had two identities, the
// direct cause of the "extra untitled entry" sidebar confusion.
// Repeated switches now hit the same record.
//
// s39 (webui-parity ticket 39): the workspace argument is GONE.
// The old `workspace: ws` here stamped the freshly-created overlay
// with the CURRENT cs.workspace, so every first-touch of an mvs_
// session from project A inherited project A's path. Switching
// back to that mvs_ session from project B then either (a) was
// ignored by the read-only switch path, leaving the file tree
// stuck on B, or (b) — under the prior mutation — overwrote the
// overlay's workspace with B's path, polluting every per-project
// grouping. New overlays start with workspace:"" (set inside
// ensureOverlayForMcodeSid when no value is passed); the
// target-first read below then lands on DEFAULT_WORKSPACE for
// first-touch mvs_ switches, with no per-session pollution.
const existed = findOverlayForMcodeSid(all, id);
target = ensureOverlayForMcodeSid(all, id, { title, workspace: ws });
target = ensureOverlayForMcodeSid(all, id, { title });
target.updatedAt = Date.now();
saveSessions(all);
console.log(
Expand Down Expand Up @@ -418,6 +480,39 @@ export async function handleSwitchSession(req, res, ctx) {
}
}
const prevSid = cs.sessionId;
// s39 (webui-parity ticket 39): resolve the target session's workspace
// and re-point cs.workspace.dir to it BEFORE any other cs mutation,
// so the SSE state push (pushStateFor at the end) and the response
// session payload both carry the new workspace in lockstep with the
// session-id switch. The pre-fix behaviour read cs.workspace without
// writing it, which left the file tree bound to the previous project;
// this is the user-reported defect the ticket fixes.
//
// Containment gate is mandatory (s39 boundary): session-stored
// workspace is historical input — it may point to a directory the
// user removed from the allowed roots since the session was last
// opened, or to a path that was legal at the time but no longer is.
// assertWorkspacePath runs the same boundary the workspace picker,
// browseWorkspace, and the new-session POST funnel through; refusing
// here keeps that boundary singular.
const currentWs = (cs && cs.workspace && cs.workspace.dir) || "";
const switchWs = _resolveSwitchWorkspace(target, currentWs);
if (!switchWs.ok) {
console.log(
`[switch] cid=${cid} REFUSED id=${id.substring(0, 12)}… reason=workspace_containment attempted="${switchWs.attempted}"`,
);
res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
return res.end(JSON.stringify({
ok: false,
error: switchWs.error,
attempted: switchWs.attempted,
}));
}
// cs.sessionId / mcodeSessionId / title / chat come first; the
// workspace write is paired with the session-id swap. Last-used-ws
// is intentionally untouched (a switch is browsing, not a workspace
// change — see the comment on handleWorkspaceChange for the same
// reasoning that protects lastUsedWorkspace from the switch path).
cs.sessionId = target.id;
cs.mcodeSessionId = target.mcodeSessionId || null;
cs.sessionTitle = target.title || "Untitled";
Expand All @@ -428,12 +523,21 @@ export async function handleSwitchSession(req, res, ctx) {
sessionOutput: 0,
sessionTotal: 0,
};
// Switching session must NOT mutate cs.workspace.dir or
// cs.lastUsedWorkspace — both are written only by their own flows
// (workspace change / send prompt). Switching is browsing; pinning
// the browsed workspace to the top of the sidebar was the
// user-reported "click any session in C and C auto-sorts first"
// behavior.
cs.workspace = {
dir: switchWs.dir,
branch: null,
tree: null,
};
if (switchWs.fallback) {
console.log(
`[switch] cid=${cid} target ${target.id.substring(0, 8)}… had no workspace — fell back to DEFAULT_WORKSPACE=${switchWs.dir}`,
);
}
// Switching session must NOT mutate cs.lastUsedWorkspace — last-used
// is written only by handleSend (workspace change / send prompt);
// switching is browsing; pinning the browsed workspace to the top of
// the sidebar was the user-reported "click any session in C and C
// auto-sorts first" behavior.
resetContext(cs);
// Sync real token usage from mavis db on switch to a historical session
if (cs.mcodeSessionId) {
Expand All @@ -460,14 +564,21 @@ export async function handleSwitchSession(req, res, ctx) {
matchKind: matchKind || "new_from_mcode",
mcodeSessionId: cs.mcodeSessionId || "",
title: cs.sessionTitle,
// s39 (webui-parity ticket 39): record which workspace the
// switch landed on, plus whether it was a fallback to
// DEFAULT_WORKSPACE. Both pieces are useful when auditing
// "why did the file tree change" or "why is the sidebar
// sorting by a directory I never opened".
workspace: switchWs.dir,
workspaceFallback: !!switchWs.fallback,
},
});
} catch (e) {
return _auditFail(res, e, "session.switch");
}
pushStateFor(cid);
console.log(
`[switch] cid=${cid} OK prev.sessionId=${prevSid ? prevSid.substring(0, 8) : "null"}… → new.sessionId=${cs.sessionId.substring(0, 8)}… title="${cs.sessionTitle}" chatLen=${cs.chat.length}`,
`[switch] cid=${cid} OK prev.sessionId=${prevSid ? prevSid.substring(0, 8) : "null"}… → new.sessionId=${cs.sessionId.substring(0, 8)}… title="${cs.sessionTitle}" chatLen=${cs.chat.length} workspace=${switchWs.dir}${switchWs.fallback ? " (DEFAULT_WORKSPACE fallback)" : ""}`,
);
res.writeHead(200, { "Content-Type": "application/json" });
return res.end(
Expand All @@ -477,6 +588,13 @@ export async function handleSwitchSession(req, res, ctx) {
id: target.id,
mcodeSessionId: cs.mcodeSessionId,
title: cs.sessionTitle,
// s39 (webui-parity ticket 39): surface the new workspace in
// the response so the client (url-restore + session-tree) can
// update its in-memory state without waiting for the SSE
// state-bus push to land — important for the file-tree panel
// that re-roots under the new workspaceDir on first render.
workspace: switchWs.dir,
workspaceFallback: !!switchWs.fallback,
// session-isolation/02 (run-mirror): switching back to the
// session that is mid-run must show what it produced so far.
// cs.chat holds the record's lines; the live turn's output is
Expand Down
2 changes: 1 addition & 1 deletion packages/webui/test/routes/chat-run-mirror.check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ async function waitFor(fn, what, timeoutMs = 2000) {
}
}

const WS = join(_tmpDataDir, "ws");
const WS = _tmpDataDir; // mkdtempSync already created the leaf — assertWorkspacePath's realpathSync requires an existing path.

function makeClient(cid, { sessionId = null, mcodeSessionId = null } = {}) {
const cs = sb.makeClientState();
Expand Down
Loading
Loading