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
16 changes: 13 additions & 3 deletions src/web-ui/src/flow_chat/components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -618,6 +618,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({
const { transition, setQueuedInput } = useSessionStateMachineActions(effectiveTargetSessionId);

const { workspace, workspacePath, workspaceName } = useCurrentWorkspace();
const { openedWorkspaces } = useWorkspaceContext();

const chatStripRepositoryPath = useMemo(() => {
const fromContext = (workspacePath || '').trim();
Expand All @@ -635,7 +636,16 @@ export const ChatInput: React.FC<ChatInputProps> = ({
const [tokenUsage, setTokenUsage] = React.useState<ContextUsageDisplay>(
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(
Expand Down
78 changes: 78 additions & 0 deletions src/web-ui/src/flow_chat/utils/chatInputMode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>): 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', () => {
Expand Down Expand Up @@ -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(
Expand Down
130 changes: 130 additions & 0 deletions src/web-ui/src/flow_chat/utils/chatInputMode.ts
Original file line number Diff line number Diff line change
@@ -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>;
}): 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;
Expand All @@ -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<WorkspaceResolutionInfo>;
}): 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;
Expand Down
Loading