Skip to content
Closed
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
71 changes: 71 additions & 0 deletions crates/agent-gateway/test/webui/history-chat-ui.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,77 @@ test("web edit-resend retains only persisted conversation references", async ()
assert.equal(calls[0].options.editMessageRef, messageRef);
});

// #787: picking a conversation that belongs to another workspace from the
// sidebar session tree must bring that workspace to the front so the right
// dock (file tree / terminal / git) follows; search already did this via
// beforeCommit, the plain click path did not.
function createSidebarSelectHarness({ isAgentMode, sidebarCwd, runtimeWorkdir }) {
const activated = [];
const opened = [];
const actions = createGatewayConversationActions({
isAgentMode,
activeView: "chat",
activateConversationWorkspace: (cwd) => activated.push(cwd),
activateSearchConversationWorkspace: () => {},
sidebarStore: {
peek: (id) => (id === "conversation-b" && sidebarCwd ? { id, cwd: sidebarCwd } : undefined),
upsertLocal: () => {},
},
conversationWorkdirsRef: {
current: new Map(runtimeWorkdir ? [["conversation-b", runtimeWorkdir]] : []),
},
openController: {
cancel: () => {},
open: (id, options) => opened.push({ id, options }),
},
getVisibleComposerConversationId: () => "conversation-a",
isLocalDraftConversationId: () => false,
prepareComposerForConversationChange: () => {},
restoreCachedComposerDraft: () => {},
setActiveView: () => {},
setSidebarOpen: () => {},
pendingDisplayedConversationAutoBottomRef: { current: null },
});
return { actions, activated, opened };
}

test("sidebar select opens immediately and activates the workspace only at commit", () => {
const { actions, activated, opened } = createSidebarSelectHarness({
isAgentMode: true,
sidebarCwd: "/workspace/project-b",
});
actions.handleSidebarSelectConversation("conversation-b");
// 点选瞬间只发起打开,不先切工作空间(否则作用域先行刷新会顶掉本次打开)。
assert.equal(opened.length, 1);
assert.equal(opened[0].id, "conversation-b");
assert.deepEqual(activated, []);
// 会话提交后才激活其工作空间。
opened[0].options.afterCommit();
assert.deepEqual(activated, ["/workspace/project-b"]);
});

test("sidebar select prefers the authoritative runtime workdir at commit time", () => {
const { actions, activated, opened } = createSidebarSelectHarness({
isAgentMode: true,
sidebarCwd: "/workspace/project-stale",
runtimeWorkdir: "/workspace/project-runtime",
});
actions.handleSidebarSelectConversation("conversation-b");
opened[0].options.afterCommit();
assert.deepEqual(activated, ["/workspace/project-runtime"]);
});

test("sidebar select leaves the workspace alone outside agent mode", () => {
const { actions, activated, opened } = createSidebarSelectHarness({
isAgentMode: false,
sidebarCwd: "/workspace/project-b",
});
actions.handleSidebarSelectConversation("conversation-b");
assert.equal(opened.length, 1);
opened[0].options.afterCommit();
assert.deepEqual(activated, []);
});

test("parseHistoryMessagesJson preserves Image tool result image content", () => {
const entries = chatUi.parseHistoryMessagesJson(JSON.stringify([
{
Expand Down
2 changes: 2 additions & 0 deletions crates/agent-gateway/web/src/app/GatewayApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,7 @@ function useGatewayAppController() {
>(() => "");
const {
activateWorkspaceProject,
activateConversationWorkspace,
activateSearchConversationWorkspace,
clearSearchConversationWorkspace,
searchConversationWorkdir,
Expand Down Expand Up @@ -1138,6 +1139,7 @@ function useGatewayAppController() {
handleSidebarSelectConversation,
startNewConversation,
} = createGatewayConversationActions({
activateConversationWorkspace,
activateSearchConversationWorkspace,
clearSearchConversationWorkspace,
activeView,
Expand Down
16 changes: 15 additions & 1 deletion crates/agent-gateway/web/src/app/gatewayConversationActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { isMobileSidebarLayout } from "./historyUtils";
import type { SendChatFn } from "./types";

type CreateGatewayConversationActionsOptions = {
activateConversationWorkspace: (cwd?: string) => void;
activateSearchConversationWorkspace: (cwd?: string) => void;
clearSearchConversationWorkspace: () => void;
activeView: ApplicationViewId;
Expand Down Expand Up @@ -168,7 +169,20 @@ export function createGatewayConversationActions(options: CreateGatewayConversat
options.restoreCachedComposerDraft(targetConversationId);
return;
}
options.openController.open(targetConversationId);
// 侧栏会话树跨工作空间点选:会话提交后(afterCommit)再把它所属的
// 工作空间置为当前,右侧文件树/终端/Git 随之切到该工作空间根目录
// (#787)。等提交而不是点选瞬间,避免作用域先行刷新与打开流程互相
// 打架;cwd 优先取本次历史响应写入的权威 workdir。
options.openController.open(targetConversationId, {
afterCommit: () => {
if (!options.isAgentMode) return;
options.activateConversationWorkspace(
options.conversationWorkdirsRef.current.get(targetConversationId)?.trim() ||
options.sidebarStore.peek(targetConversationId)?.cwd?.trim() ||
"",
);
},
});
options.restoreCachedComposerDraft(targetConversationId);
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -264,30 +264,38 @@ export function useGatewayWorkspaceProjects({
],
);

// Reading persisted history does not require the original directory to exist.
const activateSearchConversationWorkspace = useCallback(
// Bring the workspace that owns `cwd` to the front so the right dock (file
// tree / terminal / git) and the sidebar highlight follow the conversation
// being opened. Reading persisted history does not require the original
// directory to exist, so a missing directory is preserved rather than
// cleared. No-op when the conversation already belongs to the active
// workspace or has no cwd.
const activateConversationWorkspace = useCallback(
(cwd?: string) => {
const path = cwd?.trim() ?? "";
if (
path &&
workspaceProjectPathKey(path) !== workspaceProjectPathKey(activeWorkspaceProjectPath)
!path ||
workspaceProjectPathKey(path) === workspaceProjectPathKey(activeWorkspaceProjectPath)
) {
const project =
workspaceProjects.find(
(item) => workspaceProjectPathKey(item.path) === workspaceProjectPathKey(path),
) ?? createWorkspaceProjectFromPath(path, "history");
activateWorkspaceProject(project, { preserveMissing: true });
return;
}
const project =
workspaceProjects.find(
(item) => workspaceProjectPathKey(item.path) === workspaceProjectPathKey(path),
) ?? createWorkspaceProjectFromPath(path, "history");
activateWorkspaceProject(project, { preserveMissing: true });
},
[activeWorkspaceProjectPath, workspaceProjects, activateWorkspaceProject],
);

const activateSearchConversationWorkspace = useCallback(
(cwd?: string) => {
const path = cwd?.trim() ?? "";
activateConversationWorkspace(path);
setSearchNavigation({ mode: settings.system.executionMode, cwd: path });
sidebarStore.setScope(path ? { kind: "workdir", cwd: path } : { kind: "unscoped" });
},
[
activeWorkspaceProjectPath,
workspaceProjects,
activateWorkspaceProject,
settings.system.executionMode,
sidebarStore,
],
[activateConversationWorkspace, settings.system.executionMode, sidebarStore],
);

const handleSelectWorkspaceProject = useCallback(
Expand Down Expand Up @@ -631,6 +639,7 @@ export function useGatewayWorkspaceProjects({

return {
activateWorkspaceProject,
activateConversationWorkspace,
activateSearchConversationWorkspace,
clearSearchConversationWorkspace,
searchConversationWorkdir: searchCwd,
Expand Down
20 changes: 19 additions & 1 deletion crates/agent-gui/src/pages/ChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,7 @@ export function ChatPage(props: ChatPageProps) {
sidebarScope,
historyScopeKey,
activateWorkspaceProject,
activateConversationWorkspace,
activateSearchConversationWorkspace,
clearSearchConversationWorkspace,
searchConversationWorkdir,
Expand Down Expand Up @@ -1796,11 +1797,28 @@ export function ChatPage(props: ChatPageProps) {
});
} else {
prepareComposerForConversationChange();
openController.open(targetConversationId);
// 侧栏会话树跨工作空间点选:会话提交后再把它所属的工作空间置为
// 当前,右侧 dock(文件树/终端/Git)随 activeWorkspaceProjectPath
// 切到该工作空间根目录(#787)。必须等 afterCommit——先切工作空间
// 会让侧栏作用域先行刷新,旧的当前会话从列表消失会触发
// "会话被删"兜底新建草稿,反把这次打开顶掉(表现为要点两次)。
openController.open(targetConversationId, {
afterCommit: () => {
if (!isAgentMode) return;
const targetWorkdir =
conversationRuntimeCacheRef.current.get(targetConversationId)?.workdir?.trim() ||
sidebarStore.peek(targetConversationId)?.cwd?.trim() ||
"";
activateConversationWorkspace(targetWorkdir);
},
});
}
},
[
activateConversationWorkspace,
activateSearchConversationWorkspace,
conversationRuntimeCacheRef,
isAgentMode,
openController,
prepareComposerForConversationChange,
sidebarStore,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,10 @@ export function useConversationHistoryActions(params: UseConversationHistoryActi
entry: cached,
clearError: true,
});
// Cache-hit is a real commit: afterCommit must fire here too, or
// callers that defer work to the commit point (e.g. workspace
// activation for #787) silently skip on the warm path.
request?.afterCommit?.();
return "cache-hit";
}
conversationRuntimeCacheRef.current.delete(id);
Expand Down
41 changes: 25 additions & 16 deletions crates/agent-gui/src/pages/chat/workspace/useWorkspaceProjects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,30 +303,38 @@ export function useWorkspaceProjects(params: UseWorkspaceProjectsParams) {
[setSettings, workspaceProjects, activeWorkspaceProjectId, settings.system],
);

// Reading persisted history does not require the original directory to exist.
const activateSearchConversationWorkspace = useCallback(
// Bring the workspace that owns `cwd` to the front so the right dock (file
// tree / terminal / git) and the sidebar highlight follow the conversation
// being opened. Reading persisted history does not require the original
// directory to exist, so a missing directory is preserved rather than
// cleared. No-op when the conversation already belongs to the active
// workspace or has no cwd.
const activateConversationWorkspace = useCallback(
(cwd?: string) => {
const path = cwd?.trim() ?? "";
if (
path &&
workspaceProjectPathKey(path) !== workspaceProjectPathKey(activeWorkspaceProjectPath)
!path ||
workspaceProjectPathKey(path) === workspaceProjectPathKey(activeWorkspaceProjectPath)
) {
const project =
workspaceProjects.find(
(item) => workspaceProjectPathKey(item.path) === workspaceProjectPathKey(path),
) ?? createWorkspaceProjectFromPath(path, "history");
activateWorkspaceProject(project, { preserveMissing: true });
return;
}
const project =
workspaceProjects.find(
(item) => workspaceProjectPathKey(item.path) === workspaceProjectPathKey(path),
) ?? createWorkspaceProjectFromPath(path, "history");
activateWorkspaceProject(project, { preserveMissing: true });
},
[activeWorkspaceProjectPath, workspaceProjects, activateWorkspaceProject],
);

const activateSearchConversationWorkspace = useCallback(
(cwd?: string) => {
const path = cwd?.trim() ?? "";
activateConversationWorkspace(path);
setSearchNavigation({ mode: isAgentMode, cwd: path });
sidebarStore.setScope(path ? { kind: "workdir", cwd: path } : { kind: "unscoped" });
},
[
activeWorkspaceProjectPath,
workspaceProjects,
activateWorkspaceProject,
isAgentMode,
sidebarStore,
],
[activateConversationWorkspace, isAgentMode, sidebarStore],
);

const handleSelectWorkspaceProject = useCallback(
Expand Down Expand Up @@ -736,6 +744,7 @@ export function useWorkspaceProjects(params: UseWorkspaceProjectsParams) {
historyScopeKey,
checkWorkspaceProjectDirectory,
activateWorkspaceProject,
activateConversationWorkspace,
activateSearchConversationWorkspace,
clearSearchConversationWorkspace,
searchConversationWorkdir: searchCwd,
Expand Down
59 changes: 59 additions & 0 deletions docs/worklog/issue-787-sidebar-filetree-workspace-sync.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Issue #787:切换工作空间会话时右侧文件树未同步切换

日期:2026-09-16。状态:本地实现与自动验证完成,已在分支提交,尚未推送或创建 PR。

## 起因

v1.3.5(main @ 4eb6a4a8)复现:左侧会话树里点选另一个工作空间下的会话,会话内容切换了,但右侧文件树、终端、Git 面板仍停留在上一个工作空间。上游已有 Issue #787(v1.3.2 报的,open,无关联 PR),所以没有另开 issue,只在 #787 下留言确认复现并说明分支。

## 分支

fix/sidebar-filetree-sync,基线 main @ 4eb6a4a8。

## 根因

右侧 dock 的 `cwd` / `projectPathKey` 来自 `activeWorkspaceProjectPath`(Desktop 的 `useWorkspaceProjects`、WebUI 的 `useGatewayWorkspaceProjects`),而不是当前会话的 cwd。它只在用户点项目行、Workbench 聚焦 Pane、或者从搜索弹窗打开会话(#752 修的 `beforeCommit` 路径)时才被更新。PR #770 把侧栏改成跨工作空间的会话树以后,普通点选会话走的是 `openController.open(id)` 直开,没有任何一步把会话所属工作空间置为当前,dock 自然不动。

## 修复

两端一致:

- 从 `activateSearchConversationWorkspace` 里抽出 `activateConversationWorkspace(cwd)`,只做一件事,按 cwd 找到(或以 history 类型临时建)对应项目并 `activateWorkspaceProject(project, { preserveMissing: true })`;同工作空间或无 cwd 时直接返回,不写 settings。搜索路径改为调用它再做自己的 scope 处理,行为不变。
- Desktop `ChatPage.handleSelectConversation` 与 WebUI `handleSidebarSelectConversation` 的普通点选分支,把该激活挂在 `openController.open` 的 `afterCommit` 上:会话先提交(当前会话已切成目标会话),提交后再激活其工作空间。cwd 优先取运行时缓存/历史响应的权威 workdir,退到侧栏行 cwd。只在 agent 模式下做,text 模式不动工作空间。
- Desktop `useConversationHistoryActions.openInitial` 的 cache-hit 分支补调 `request?.afterCommit?.()`(热路径同样是一次真实提交,此前只有 painted 路径调用)。搜索路径不走 cache-hit 分支,不受影响。

### 为什么必须等 afterCommit(第一版返工的原因)

第一版在 `open` 之前同步激活工作空间,实测表现为"点一次只切工作空间,要再点一次才进会话"。机制:激活 → `sidebarScope` 先行切换 → 侧栏 store 重载后旧的当前会话从列表消失 → ChatPage 里"当前会话被删则新建草稿"的兜底 effect 触发 `startNewConversation` → `cancelConversationLoad` 顶掉了正在进行的这次打开,用户落在新工作空间的空白草稿上。改为提交后激活,此时当前会话已是目标会话(存在于新作用域列表中),兜底 effect 不再误判。

不改 `activateWorkspaceProject` 本身,不动搜索路径与 Workbench 路径的语义。文件树面板关闭时同样更新 `activeWorkspaceProjectPath`(RightDockPanel 的 `cwd`/`projectPathKey` 常驻传入),下次展开即为对应工作空间根目录。

## 涉及文件

- crates/agent-gui/src/pages/chat/workspace/useWorkspaceProjects.ts
- crates/agent-gui/src/pages/ChatPage.tsx
- crates/agent-gui/src/pages/chat/history/useConversationHistoryActions.ts
- crates/agent-gateway/web/src/app/hooks/useGatewayWorkspaceProjects.ts
- crates/agent-gateway/web/src/app/gatewayConversationActions.ts
- crates/agent-gateway/web/src/app/GatewayApp.tsx
- crates/agent-gateway/test/webui/history-chat-ui.test.mjs(新增 3 个用例)

## 自动验证

| 检查 | 结果 |
| --- | --- |
| tsc --noEmit(agent-gui) | 通过 |
| tsc --noEmit(gateway-webui) | 通过 |
| biome check 本次源码文件 | 通过 |
| pnpm test:webui | 721/721 通过(含新增 3 个) |
| pnpm test:gui | 3087/3095,8 个失败在基线 main @ 4eb6a4a8 上复测同样失败,与本次改动无关 |
| pnpm check:fast | Shared UI boundaries 项失败,报的 3 个文件本次未改动,是基线已有问题 |

## 已知边界

- 若当前是一个空白草稿会话,点选另一工作空间的会话时,工作空间先切换,`ChatPage` 里那条"空草稿跟随当前工作空间"的 effect 会把旧草稿的 workdir 也改过去。和用户直接点项目行时的行为一致,搜索路径同样如此,没有额外处理。
- 归档项目的会话不会在侧栏渲染,所以不会经这条路径把归档项目激活回来。

## 待办

- 用户在客户端验收后推送分支、开 PR 关联 #787。
Loading