From 7c1c0888a7f47349463fec9e46e6c3ebf4c89e83 Mon Sep 17 00:00:00 2001 From: wsp1911 Date: Wed, 24 Jun 2026 20:39:19 +0800 Subject: [PATCH] fix(flow-chat): resolve assistant mode from session workspace --- .../src/flow_chat/components/ChatInput.tsx | 16 ++- .../src/flow_chat/utils/chatInputMode.test.ts | 78 +++++++++++ .../src/flow_chat/utils/chatInputMode.ts | 130 ++++++++++++++++++ 3 files changed, 221 insertions(+), 3 deletions(-) diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index 60f3cf5479..c87accd125 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -27,8 +27,7 @@ import { AcpPlanPanel } from './AcpPlanPanel'; import type { FlowChatState } from '../types/flow-chat'; import type { FileContext, DirectoryContext, ImageContext } from '@/types/context.ts'; import { SmartRecommendations } from './smart-recommendations'; -import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; -import { WorkspaceKind } from '@/shared/types'; +import { useCurrentWorkspace, useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; import { createImageContextFromFile, createImageContextFromClipboard } from '../utils/imageUtils'; import { isSlashCommand, stripSlashCommand } from '../utils/slashCommand'; import { notificationService } from '@/shared/notification-system'; @@ -66,6 +65,7 @@ import { DEFAULT_CHAT_INPUT_MODE_CONFIG_PATH, normalizeUserDefaultChatInputModeId, resolveAvailableChatInputMode, + resolveSessionAssistantWorkspace, } from '../utils/chatInputMode'; import { useSceneStore } from '@/app/stores/sceneStore'; import type { SceneTabId } from '@/app/components/SceneBar/types'; @@ -618,6 +618,7 @@ export const ChatInput: React.FC = ({ const { transition, setQueuedInput } = useSessionStateMachineActions(effectiveTargetSessionId); const { workspace, workspacePath, workspaceName } = useCurrentWorkspace(); + const { openedWorkspaces } = useWorkspaceContext(); const chatStripRepositoryPath = useMemo(() => { const fromContext = (workspacePath || '').trim(); @@ -635,7 +636,16 @@ export const ChatInput: React.FC = ({ const [tokenUsage, setTokenUsage] = React.useState( getSessionContextUsageDisplay() ); - const isAssistantWorkspace = workspace?.workspaceKind === WorkspaceKind.Assistant; + const isAssistantWorkspace = useMemo( + () => resolveSessionAssistantWorkspace({ + currentWorkspace: workspace, + sessionWorkspaceId: effectiveTargetSession?.workspaceId, + sessionWorkspacePath: effectiveTargetSession?.workspacePath, + sessionRemoteConnectionId: effectiveTargetSession?.remoteConnectionId, + openedWorkspaces: openedWorkspaces.values(), + }), + [effectiveTargetSession, openedWorkspaces, workspace], + ); const currentMode = modeState.current; const isModeDropdownOpen = modeState.dropdownOpen; const acpTargetAgentType = useMemo( diff --git a/src/web-ui/src/flow_chat/utils/chatInputMode.test.ts b/src/web-ui/src/flow_chat/utils/chatInputMode.test.ts index 0dc80e6189..5c50ad60bb 100644 --- a/src/web-ui/src/flow_chat/utils/chatInputMode.test.ts +++ b/src/web-ui/src/flow_chat/utils/chatInputMode.test.ts @@ -3,8 +3,25 @@ import { describe, expect, it } from 'vitest'; import { normalizeUserDefaultChatInputModeId, resolveAvailableChatInputMode, + resolveSessionAssistantWorkspace, resolveWorkspaceChatInputMode, } from './chatInputMode'; +import { WorkspaceKind, type WorkspaceInfo, WorkspaceType } from '@/shared/types'; + +function createWorkspace(overrides: Partial): WorkspaceInfo { + return { + id: overrides.id ?? 'workspace-1', + name: overrides.name ?? 'Workspace', + rootPath: overrides.rootPath ?? 'D:/workspace/project', + workspaceType: overrides.workspaceType ?? WorkspaceType.SingleProject, + workspaceKind: overrides.workspaceKind ?? WorkspaceKind.Normal, + languages: overrides.languages ?? [], + openedAt: overrides.openedAt ?? new Date(0).toISOString(), + lastAccessed: overrides.lastAccessed ?? new Date(0).toISOString(), + tags: overrides.tags ?? [], + ...overrides, + }; +} describe('normalizeUserDefaultChatInputModeId', () => { it('normalizes non-empty strings and rejects blank values', () => { @@ -76,6 +93,67 @@ describe('resolveWorkspaceChatInputMode', () => { }); }); +describe('resolveSessionAssistantWorkspace', () => { + it('does not treat a project session as assistant during workspace scene transitions', () => { + const projectWorkspace = createWorkspace({ + id: 'project-1', + rootPath: 'E:/Projects/repos/claude-code', + workspaceKind: WorkspaceKind.Normal, + }); + const assistantWorkspace = createWorkspace({ + id: 'assistant-1', + rootPath: 'C:/Users/wsp/.bitfun/personal_assistant/workspace', + workspaceKind: WorkspaceKind.Assistant, + }); + + expect( + resolveSessionAssistantWorkspace({ + currentWorkspace: assistantWorkspace, + sessionWorkspaceId: projectWorkspace.id, + sessionWorkspacePath: projectWorkspace.rootPath, + openedWorkspaces: [projectWorkspace, assistantWorkspace], + }), + ).toBe(false); + }); + + it('recognizes assistant sessions from their own workspace scope even before current workspace catches up', () => { + const projectWorkspace = createWorkspace({ + id: 'project-1', + rootPath: 'E:/Projects/repos/claude-code', + workspaceKind: WorkspaceKind.Normal, + }); + const assistantWorkspace = createWorkspace({ + id: 'assistant-1', + rootPath: 'C:/Users/wsp/.bitfun/personal_assistant/workspace', + workspaceKind: WorkspaceKind.Assistant, + }); + + expect( + resolveSessionAssistantWorkspace({ + currentWorkspace: projectWorkspace, + sessionWorkspaceId: assistantWorkspace.id, + sessionWorkspacePath: assistantWorkspace.rootPath, + openedWorkspaces: [projectWorkspace, assistantWorkspace], + }), + ).toBe(true); + }); + + it('falls back to the current workspace kind when the session has no explicit workspace scope yet', () => { + const assistantWorkspace = createWorkspace({ + id: 'assistant-1', + rootPath: 'C:/Users/wsp/.bitfun/personal_assistant/workspace', + workspaceKind: WorkspaceKind.Assistant, + }); + + expect( + resolveSessionAssistantWorkspace({ + currentWorkspace: assistantWorkspace, + openedWorkspaces: [assistantWorkspace], + }), + ).toBe(true); + }); +}); + describe('resolveAvailableChatInputMode', () => { it('returns the synchronized session mode when it is still available', () => { expect( diff --git a/src/web-ui/src/flow_chat/utils/chatInputMode.ts b/src/web-ui/src/flow_chat/utils/chatInputMode.ts index 8891fb3a18..9bb8d2ed6b 100644 --- a/src/web-ui/src/flow_chat/utils/chatInputMode.ts +++ b/src/web-ui/src/flow_chat/utils/chatInputMode.ts @@ -1,5 +1,113 @@ +import { WorkspaceKind, type WorkspaceInfo } from '@/shared/types'; + export const DEFAULT_CHAT_INPUT_MODE_CONFIG_PATH = 'app.flow_chat.default_mode_id'; +type WorkspaceResolutionInfo = Pick< + WorkspaceInfo, + 'id' | 'rootPath' | 'workspaceKind' | 'connectionId' +>; + +function normalizeOptionalString(value: string | null | undefined): string | null { + if (typeof value !== 'string') { + return null; + } + + const trimmed = value.trim(); + return trimmed ? trimmed : null; +} + +function normalizeWorkspacePath(value: string | null | undefined): string | null { + const trimmed = normalizeOptionalString(value); + if (!trimmed) { + return null; + } + + return trimmed.replace(/[\\/]+$/, ''); +} + +function isWorkspaceConnectionCompatible( + workspaceConnectionId: string | null | undefined, + sessionRemoteConnectionId: string | null | undefined, +): boolean { + const normalizedWorkspaceConnectionId = normalizeOptionalString(workspaceConnectionId); + const normalizedSessionRemoteConnectionId = normalizeOptionalString(sessionRemoteConnectionId); + + if (normalizedSessionRemoteConnectionId && normalizedWorkspaceConnectionId) { + return normalizedWorkspaceConnectionId === normalizedSessionRemoteConnectionId; + } + + if (normalizedSessionRemoteConnectionId && !normalizedWorkspaceConnectionId) { + return false; + } + + return true; +} + +function resolveSessionWorkspaceMatch(params: { + currentWorkspace?: WorkspaceResolutionInfo | null; + sessionWorkspaceId?: string | null; + sessionWorkspacePath?: string | null; + sessionRemoteConnectionId?: string | null; + openedWorkspaces?: Iterable; +}): WorkspaceResolutionInfo | null { + const normalizedSessionWorkspaceId = normalizeOptionalString(params.sessionWorkspaceId); + const normalizedSessionWorkspacePath = normalizeWorkspacePath(params.sessionWorkspacePath); + const normalizedSessionRemoteConnectionId = normalizeOptionalString(params.sessionRemoteConnectionId); + const currentWorkspace = params.currentWorkspace ?? null; + const openedWorkspaces = params.openedWorkspaces ?? []; + + if (normalizedSessionWorkspaceId) { + if (currentWorkspace?.id === normalizedSessionWorkspaceId) { + return currentWorkspace; + } + + for (const workspace of openedWorkspaces) { + if (workspace.id === normalizedSessionWorkspaceId) { + return workspace; + } + } + } + + if (!normalizedSessionWorkspacePath) { + return null; + } + + const matchingWorkspaces: WorkspaceResolutionInfo[] = []; + const pushIfMatching = (workspace: WorkspaceResolutionInfo | null | undefined) => { + if (!workspace) { + return; + } + + if (normalizeWorkspacePath(workspace.rootPath) !== normalizedSessionWorkspacePath) { + return; + } + + if (!isWorkspaceConnectionCompatible(workspace.connectionId, normalizedSessionRemoteConnectionId)) { + return; + } + + if (!matchingWorkspaces.some(candidate => candidate.id === workspace.id)) { + matchingWorkspaces.push(workspace); + } + }; + + pushIfMatching(currentWorkspace); + for (const workspace of openedWorkspaces) { + pushIfMatching(workspace); + } + + if (normalizedSessionRemoteConnectionId) { + const exactConnectionMatch = matchingWorkspaces.find( + (workspace) => normalizeOptionalString(workspace.connectionId) === normalizedSessionRemoteConnectionId, + ); + if (exactConnectionMatch) { + return exactConnectionMatch; + } + } + + return matchingWorkspaces[0] ?? null; +} + export function normalizeUserDefaultChatInputModeId(value: unknown): string | null { if (typeof value !== 'string') { return null; @@ -9,6 +117,28 @@ export function normalizeUserDefaultChatInputModeId(value: unknown): string | nu return trimmed ? trimmed : null; } +export function resolveSessionAssistantWorkspace(params: { + currentWorkspace?: WorkspaceResolutionInfo | null; + sessionWorkspaceId?: string | null; + sessionWorkspacePath?: string | null; + sessionRemoteConnectionId?: string | null; + openedWorkspaces?: Iterable; +}): boolean { + const matchedWorkspace = resolveSessionWorkspaceMatch(params); + if (matchedWorkspace) { + return matchedWorkspace.workspaceKind === WorkspaceKind.Assistant; + } + + const hasExplicitSessionWorkspace = + normalizeOptionalString(params.sessionWorkspaceId) !== null + || normalizeWorkspacePath(params.sessionWorkspacePath) !== null; + if (hasExplicitSessionWorkspace) { + return false; + } + + return params.currentWorkspace?.workspaceKind === WorkspaceKind.Assistant; +} + export function resolveWorkspaceChatInputMode(params: { currentMode: string; isAssistantWorkspace: boolean;