fix(chat): inherit workspace when creating a new chat (#1186)

This commit is contained in:
paisley
2026-07-22 14:03:02 +08:00
committed by GitHub
parent 5d0099abaa
commit d501bad05d
3 changed files with 114 additions and 14 deletions
+17 -1
View File
@@ -1,13 +1,29 @@
import { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { resolveEffectiveWorkspace } from '@/lib/workspace-context';
import { useChatStore } from '@/stores/chat';
import { useSettingsStore } from '@/stores/settings';
export function useNewChatAction(): () => void {
const navigate = useNavigate();
const newSession = useChatStore((state) => state.newSession);
const setChatWorkspacePath = useSettingsStore((state) => state.setChatWorkspacePath);
return useCallback(() => {
const { currentSessionKey, sessions } = useChatStore.getState();
const selectedSession = sessions.find((session) => session.key === currentSessionKey);
// Start the draft in the selected conversation's effective workspace while
// keeping the workspace picker editable until the first message creates it.
if (selectedSession) {
const selectedWorkspacePath = resolveEffectiveWorkspace({
session: selectedSession,
globalWorkspace: useSettingsStore.getState().chatWorkspacePath,
}).cwd;
setChatWorkspacePath(selectedWorkspacePath);
}
newSession();
navigate('/');
}, [navigate, newSession]);
}, [navigate, newSession, setChatWorkspacePath]);
}
+60 -12
View File
@@ -12,7 +12,6 @@ const SESSION_KEY = 'agent:main:session-a';
const SESSION_WORKSPACE = '/Users/e2e/workspace/ClawX';
const SESSION_WORKSPACE_LABEL = '~/workspace/ClawX';
const GLOBAL_WORKSPACE = '/Users/e2e/workspace/GlobalProject';
const GLOBAL_WORKSPACE_LABEL = '~/workspace/GlobalProject';
const DEFAULT_WORKSPACE = '~/.openclaw/workspace';
const AUTO_TITLE_WITH_CWD = `[Working directory: ${DEFAULT_WORKSPACE}]\n\nWorkspace chat`;
const SESSIONS_LIST_PAYLOAD = {
@@ -106,11 +105,16 @@ async function installWorkspaceMocks(app: ElectronApplication, options: Workspac
const nowMs = Date.now();
const gatewayStatus = { state: 'running', gatewayReady: true, port: 18789, pid: 12345, connectedAt: nowMs };
const sessionHistory = options.sessionHistory ?? [];
const recentWorkspacePaths = options.recentWorkspacePaths ?? [DEFAULT_WORKSPACE];
const inheritedRecentWorkspacePaths = [
SESSION_WORKSPACE,
...recentWorkspacePaths.filter((path) => path !== SESSION_WORKSPACE),
].slice(0, 10);
const settingsSnapshot = {
language: 'en',
setupComplete: true,
chatWorkspacePath: options.chatWorkspacePath ?? DEFAULT_WORKSPACE,
recentWorkspacePaths: options.recentWorkspacePaths ?? [DEFAULT_WORKSPACE],
recentWorkspacePaths,
workspaceLabels: options.workspaceLabels ?? {},
};
const sessionRow = {
@@ -163,6 +167,12 @@ async function installWorkspaceMocks(app: ElectronApplication, options: Workspac
[stableStringify(['settings', 'setMany', {
patch: { workspaceLabels: { [SESSION_WORKSPACE]: 'Renamed workspace' } },
}])]: { success: true },
[stableStringify(['settings', 'setMany', {
patch: {
chatWorkspacePath: SESSION_WORKSPACE,
recentWorkspacePaths: inheritedRecentWorkspacePaths,
},
}])]: { success: true },
[stableStringify(['settings', 'setMany', {
patch: {
chatWorkspacePath: options.chatWorkspacePath ?? DEFAULT_WORKSPACE,
@@ -285,6 +295,44 @@ test.describe('ClawX chat workspace context', () => {
}
});
test('new chat inherits the selected conversation workspace', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
try {
await installWorkspaceMocks(app);
const page = await getStableWindow(app);
try {
await page.reload();
} catch (error) {
if (!String(error).includes('ERR_FILE_NOT_FOUND')) {
throw error;
}
}
const workspaceSelector = page.getByTestId('chat-workspace-selector');
await expect(workspaceSelector).toHaveText(SESSION_WORKSPACE_LABEL, { timeout: 30_000 });
await expect(workspaceSelector).toHaveAttribute('aria-disabled', 'true');
await page.getByTestId('sidebar-new-chat').click();
await expect.poll(async () => {
const invocations = await getRecordedHostInvocations(app);
return invocations.some((entry) => (
entry.module === 'settings'
&& entry.action === 'setMany'
&& entry.payload?.patch?.chatWorkspacePath === SESSION_WORKSPACE
));
}).toBe(true);
await expect(page.getByTestId('acp-chat-empty-state')).toBeVisible();
await expect(workspaceSelector).toHaveText(SESSION_WORKSPACE_LABEL);
await expect(workspaceSelector).toHaveAttribute('title', SESSION_WORKSPACE);
await expect(workspaceSelector).not.toHaveAttribute('aria-disabled', 'true');
} finally {
await closeElectronApp(app);
}
});
test('host summary title stays clean when a derived title is unavailable', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
@@ -313,7 +361,7 @@ test.describe('ClawX chat workspace context', () => {
}
});
test('new unbound chat stays hidden until it has content and then appears under the selected global workspace group', async ({ launchElectronApp }) => {
test('new unbound chat stays hidden until it has content and prefers the selected conversation workspace over the global workspace', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
try {
@@ -335,17 +383,17 @@ test.describe('ClawX chat workspace context', () => {
await expect(workspaceSelector).toHaveText(SESSION_WORKSPACE_LABEL, { timeout: 30_000 });
const sidebar = page.getByTestId('sidebar');
const globalWorkspaceGroup = sidebar.getByTestId(workspaceSessionGroupTestId(GLOBAL_WORKSPACE));
const selectedWorkspaceGroup = sidebar.getByTestId(workspaceSessionGroupTestId(SESSION_WORKSPACE));
await expect(async () => {
await page.getByTestId('sidebar-new-chat').click();
await expect(globalWorkspaceGroup.getByText(/agent:main:session-/)).toHaveCount(0, { timeout: 500 });
await expect(selectedWorkspaceGroup.getByText(/agent:main:session-/)).toHaveCount(0, { timeout: 500 });
}).toPass({ timeout: 30_000 });
await expect(page.getByTestId('acp-chat-empty-state')).toBeVisible();
await expect(workspaceSelector).toHaveText(GLOBAL_WORKSPACE_LABEL);
await expect(workspaceSelector).toHaveAttribute('title', GLOBAL_WORKSPACE);
await expect(workspaceSelector).toHaveText(SESSION_WORKSPACE_LABEL);
await expect(workspaceSelector).toHaveAttribute('title', SESSION_WORKSPACE);
await expect(workspaceSelector).not.toHaveAttribute('aria-disabled', 'true');
const defaultWorkspaceGroupWithPendingSession = sidebar.getByTestId(workspaceSessionGroupTestId(DEFAULT_WORKSPACE))
@@ -356,14 +404,14 @@ test.describe('ClawX chat workspace context', () => {
}
});
test('missing global workspace prompts for another folder without attempting ACP creation', async ({ launchElectronApp }) => {
test('missing inherited workspace prompts for another folder without attempting ACP creation', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
try {
await installWorkspaceMocks(app, {
chatWorkspacePath: GLOBAL_WORKSPACE,
recentWorkspacePaths: [GLOBAL_WORKSPACE, DEFAULT_WORKSPACE],
unavailableWorkspacePath: GLOBAL_WORKSPACE,
unavailableWorkspacePath: SESSION_WORKSPACE,
});
const page = await getStableWindow(app);
@@ -378,7 +426,7 @@ test.describe('ClawX chat workspace context', () => {
});
await expect(async () => {
await page.getByTestId('sidebar-new-chat').click();
await expect(page.getByTestId('chat-workspace-selector')).toHaveText(GLOBAL_WORKSPACE_LABEL, {
await expect(page.getByTestId('chat-workspace-selector')).toHaveText(SESSION_WORKSPACE_LABEL, {
timeout: 1_000,
});
}).toPass({ timeout: 30_000 });
@@ -386,7 +434,7 @@ test.describe('ClawX chat workspace context', () => {
const banner = page.getByTestId('workspace-unavailable-banner');
await expect(banner).toBeVisible();
await expect(banner).toContainText('Workspace unavailable');
await expect(banner).toContainText(GLOBAL_WORKSPACE);
await expect(banner).toContainText(SESSION_WORKSPACE);
await expect(banner.getByRole('button', { name: 'Choose workspace' })).toBeVisible();
await expect.poll(async () => {
@@ -394,7 +442,7 @@ test.describe('ClawX chat workspace context', () => {
return invocations.filter((entry) => (
entry.module === 'chat'
&& entry.action === 'loadAcpSession'
&& entry.payload?.cwd === GLOBAL_WORKSPACE
&& entry.payload?.cwd === SESSION_WORKSPACE
)).length;
}).toBe(0);
} finally {
+37 -1
View File
@@ -1,12 +1,18 @@
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { chatState, navigateMock } = vi.hoisted(() => ({
const { chatState, navigateMock, settingsState } = vi.hoisted(() => ({
chatState: {
messages: [] as unknown[],
currentSessionKey: 'agent:main:main',
sessions: [] as Array<{ key: string; workspacePath?: string; createdLocally?: boolean }>,
newSession: vi.fn(),
},
navigateMock: vi.fn(),
settingsState: {
chatWorkspacePath: '~/.openclaw/workspace',
setChatWorkspacePath: vi.fn(),
},
}));
vi.mock('react-router-dom', () => ({
@@ -21,10 +27,22 @@ vi.mock('@/stores/chat', () => {
return { useChatStore };
});
vi.mock('@/stores/settings', () => {
const useSettingsStore = Object.assign(
(selector: (state: typeof settingsState) => unknown) => selector(settingsState),
{ getState: () => settingsState },
);
return { useSettingsStore };
});
describe('useNewChatAction', () => {
beforeEach(() => {
chatState.messages = [];
chatState.currentSessionKey = 'agent:main:main';
chatState.sessions = [];
chatState.newSession.mockReset();
settingsState.chatWorkspacePath = '~/.openclaw/workspace';
settingsState.setChatWorkspacePath.mockReset();
navigateMock.mockReset();
});
@@ -34,6 +52,24 @@ describe('useNewChatAction', () => {
act(() => result.current());
expect(settingsState.setChatWorkspacePath).not.toHaveBeenCalled();
expect(chatState.newSession).toHaveBeenCalledTimes(1);
expect(navigateMock).toHaveBeenCalledWith('/');
});
it('inherits the selected conversation workspace', async () => {
chatState.currentSessionKey = 'agent:main:session-a';
chatState.sessions = [{
key: chatState.currentSessionKey,
workspacePath: '/Users/e2e/workspace/ClawX',
}];
const { useNewChatAction } = await import('@/components/layout/use-new-chat-action');
const { result } = renderHook(() => useNewChatAction());
act(() => result.current());
expect(settingsState.setChatWorkspacePath).toHaveBeenCalledWith('/Users/e2e/workspace/ClawX');
expect(chatState.newSession).toHaveBeenCalledTimes(1);
expect(navigateMock).toHaveBeenCalledWith('/');
});