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
28 changes: 26 additions & 2 deletions src/web-ui/src/app/layout/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { isMacOSDesktopRuntime } from '@/infrastructure/runtime';
import './AppLayout.scss';

const log = createLogger('AppLayout');
const ACP_SESSION_PENDING_TIMEOUT_MS = 75_000;

interface AppLayoutProps {
className?: string;
Expand All @@ -53,6 +54,7 @@ interface AcpSessionCreationEventDetail {
phase?: 'start' | 'finish';
clientId?: string;
action?: 'create' | 'restore';
requestId?: string;
}

interface WindowModeHint {
Expand Down Expand Up @@ -204,8 +206,10 @@ const AppLayout: React.FC<AppLayoutProps> = ({ className = '' }) => {
const [showAboutDialog, setShowAboutDialog] = useState(false);
const [showWorkspaceStatus, setShowWorkspaceStatus] = useState(false);
const [pendingAcpSessionClients, setPendingAcpSessionClients] = useState<Array<{
id: string;
clientId: string;
action: 'create' | 'restore';
startedAt: number;
}>>([]);
const handleOpenProject = useCallback(async () => {
try {
Expand Down Expand Up @@ -583,11 +587,18 @@ const AppLayout: React.FC<AppLayoutProps> = ({ className = '' }) => {
const detail = (event as CustomEvent<AcpSessionCreationEventDetail>).detail;
const clientId = detail?.clientId?.trim() || 'ACP';
const action = detail?.action === 'restore' ? 'restore' : 'create';
const id = detail?.requestId?.trim() || `${action}:${clientId}`;
if (detail?.phase === 'start') {
setPendingAcpSessionClients(prev => [...prev, { clientId, action }]);
setPendingAcpSessionClients(prev => [
...prev.filter(item => item.id !== id),
{ id, clientId, action, startedAt: Date.now() },
]);
} else if (detail?.phase === 'finish') {
setPendingAcpSessionClients(prev => {
const index = prev.findIndex(item => item.clientId === clientId && item.action === action);
const index = prev.findIndex(item =>
item.id === id ||
(!detail?.requestId && item.clientId === clientId && item.action === action)
);
if (index === -1) return prev;
return prev.filter((_, currentIndex) => currentIndex !== index);
});
Expand All @@ -597,6 +608,19 @@ const AppLayout: React.FC<AppLayoutProps> = ({ className = '' }) => {
return () => window.removeEventListener('bitfun:acp-session-creation', handler);
}, []);

React.useEffect(() => {
if (pendingAcpSessionClients.length === 0) return undefined;

const intervalId = window.setInterval(() => {
const expiresBefore = Date.now() - ACP_SESSION_PENDING_TIMEOUT_MS;
setPendingAcpSessionClients(prev =>
prev.filter(item => item.startedAt >= expiresBefore)
);
}, 5_000);

return () => window.clearInterval(intervalId);
}, [pendingAcpSessionClients.length]);

// Global drag-and-drop
React.useEffect(() => {
const handleDragStart = (e: DragEvent) => {
Expand Down
72 changes: 41 additions & 31 deletions src/web-ui/src/flow_chat/components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import { useDeepReviewConsent } from './DeepReviewConsentDialog';
import { useSessionReviewActivity } from '../hooks/useSessionReviewActivity';
import { shouldBlockDeepReviewCommand } from '../utils/deepReviewCommandGuard';
import { deriveDeepReviewSessionConcurrencyGuard } from '../utils/deepReviewCapacityGuard';
import { acpAgentTypeFromSession } from '../utils/acpSession';
import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI';
import './ChatInput.scss';

Expand Down Expand Up @@ -609,10 +610,15 @@ export const ChatInput: React.FC<ChatInputProps> = ({
const isAssistantWorkspace = workspace?.workspaceKind === WorkspaceKind.Assistant;
const currentMode = modeState.current;
const isModeDropdownOpen = modeState.dropdownOpen;
const acpTargetAgentType = useMemo(
() => acpAgentTypeFromSession(effectiveTargetSession),
[effectiveTargetSession]
);
const isAcpTargetSession = Boolean(acpTargetAgentType);
const activeSessionMode = effectiveTargetSessionId
? flowChatState.sessions.get(effectiveTargetSessionId)?.mode
? acpTargetAgentType || flowChatState.sessions.get(effectiveTargetSessionId)?.mode
: undefined;
const canSwitchModes = !isAssistantWorkspace && currentMode !== 'Cowork';
const canSwitchModes = !isAssistantWorkspace && currentMode !== 'Cowork' && !isAcpTargetSession;

// Session-level mode policy: Cowork sessions are fixed; code sessions should not switch into Cowork.
const switchableModes = useMemo(
Expand Down Expand Up @@ -717,7 +723,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({
// Composer mode is authoritative (synced from session on switch, updated in
// applyModeChange). Prefer it over session.mode so a stale store cannot force
// agentic when the user selected Team or another mode.
currentAgentType: modeState.current,
currentAgentType: acpTargetAgentType || modeState.current,
});

const modeInfoById = useMemo(
Expand Down Expand Up @@ -3237,23 +3243,25 @@ export const ChatInput: React.FC<ChatInputProps> = ({
<div className="bitfun-chat-input__actions">
<div className="bitfun-chat-input__actions-left">
<div className="bitfun-chat-input__agent-boost" ref={agentBoostRef}>
<Tooltip content={t('chatInput.addBoostTooltip')}>
<IconButton
className="bitfun-chat-input__agent-boost-add"
variant="ghost"
size="xs"
aria-haspopup="menu"
aria-expanded={modeState.dropdownOpen}
onClick={e => {
e.stopPropagation();
dispatchMode({ type: 'TOGGLE_DROPDOWN' });
}}
>
<Plus size={14} strokeWidth={2.25} />
</IconButton>
</Tooltip>
{!isAcpTargetSession && (
<Tooltip content={t('chatInput.addBoostTooltip')}>
<IconButton
className="bitfun-chat-input__agent-boost-add"
variant="ghost"
size="xs"
aria-haspopup="menu"
aria-expanded={modeState.dropdownOpen}
onClick={e => {
e.stopPropagation();
dispatchMode({ type: 'TOGGLE_DROPDOWN' });
}}
>
<Plus size={14} strokeWidth={2.25} />
</IconButton>
</Tooltip>
)}

{canSwitchModes && modeState.current !== 'agentic' && (
{(canSwitchModes || isAcpTargetSession) && modeState.current !== 'agentic' && (
<div
className={`bitfun-chat-input__agent-capsule bitfun-chat-input__agent-capsule--${modeState.current === 'debug' ? 'debug' : modeState.current}`}
>
Expand All @@ -3262,18 +3270,20 @@ export const ChatInput: React.FC<ChatInputProps> = ({
modeState.available.find(m => m.id === modeState.current)?.name ||
modeState.current}
</span>
<button
type="button"
className="bitfun-chat-input__agent-capsule-close"
aria-label={t('chatInput.resetToAgentic')}
onClick={e => {
e.stopPropagation();
applyModeChange('agentic');
dispatchMode({ type: 'CLOSE_DROPDOWN' });
}}
>
<X size={12} strokeWidth={2.5} />
</button>
{!isAcpTargetSession && (
<button
type="button"
className="bitfun-chat-input__agent-capsule-close"
aria-label={t('chatInput.resetToAgentic')}
onClick={e => {
e.stopPropagation();
applyModeChange('agentic');
dispatchMode({ type: 'CLOSE_DROPDOWN' });
}}
>
<X size={12} strokeWidth={2.5} />
</button>
)}
</div>
)}

Expand Down
42 changes: 32 additions & 10 deletions src/web-ui/src/flow_chat/components/ModelSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,12 @@ import type { AIModelConfig } from '@/infrastructure/config/types';
import { Tooltip } from '@/component-library';
import { FlowChatStore } from '../store/FlowChatStore';
import { getModelMaxTokens } from '../services/flow-chat-manager/SessionModule';
import { createLogger } from '@/shared/utils/logger';
import { acpClientIdFromAgentType } from '../utils/acpSession';
import { createLogger } from '@/shared/utils/logger';
import './ModelSelector.scss';

const log = createLogger('ModelSelector');
const ACP_SESSION_OPTIONS_TIMEOUT_MS = 65_000;

interface ModelSelectorProps {
/** Current mode ID. */
Expand Down Expand Up @@ -118,6 +119,22 @@ const buildAutoModelInfo = (
provider: 'auto',
});

function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
return new Promise((resolve, reject) => {
const timeoutId = window.setTimeout(() => reject(new Error(message)), timeoutMs);
promise.then(
value => {
window.clearTimeout(timeoutId);
resolve(value);
},
error => {
window.clearTimeout(timeoutId);
reject(error);
},
);
});
}

const syncAcpContextUsageToStore = (
sessionId: string | undefined,
options: AcpSessionOptions,
Expand Down Expand Up @@ -204,21 +221,26 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
}

const shouldShowRestoreToast = !acpOptionsRef.current && acpRestoreToastShownRef.current !== sessionId;
const restoreRequestId = `acp-options:${sessionId}:${acpClientId}`;
if (shouldShowRestoreToast) {
acpRestoreToastShownRef.current = sessionId;
window.dispatchEvent(new CustomEvent('bitfun:acp-session-creation', {
detail: { phase: 'start', clientId: acpClientId, action: 'restore' },
detail: { phase: 'start', clientId: acpClientId, action: 'restore', requestId: restoreRequestId },
}));
}

try {
const options = await ACPClientAPI.getSessionOptions({
sessionId,
clientId: acpClientId,
workspacePath: activeSession?.workspacePath || activeSession?.config.workspacePath,
remoteConnectionId: activeSession?.remoteConnectionId,
remoteSshHost: activeSession?.remoteSshHost,
});
const options = await withTimeout(
ACPClientAPI.getSessionOptions({
sessionId,
clientId: acpClientId,
workspacePath: activeSession?.workspacePath || activeSession?.config.workspacePath,
remoteConnectionId: activeSession?.remoteConnectionId,
remoteSshHost: activeSession?.remoteSshHost,
}),
ACP_SESSION_OPTIONS_TIMEOUT_MS,
`Timed out restoring ACP session options for ${acpClientId}`,
);
setAcpOptions(options);
syncAcpContextUsageToStore(sessionId, options);
} catch (error) {
Expand All @@ -227,7 +249,7 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
} finally {
if (shouldShowRestoreToast) {
window.dispatchEvent(new CustomEvent('bitfun:acp-session-creation', {
detail: { phase: 'finish', clientId: acpClientId, action: 'restore' },
detail: { phase: 'finish', clientId: acpClientId, action: 'restore', requestId: restoreRequestId },
}));
}
}
Expand Down
24 changes: 20 additions & 4 deletions src/web-ui/src/flow_chat/state-machine/SessionStateMachine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,13 +265,29 @@ export class SessionStateMachineImpl {

const sessionId = this.context.taskId;
const dialogTurnId = this.context.currentDialogTurnId;
const { agentAPI } = await import('@/infrastructure/api');
const { acpClientIdFromAgentType } = await import('@/flow_chat/utils/acpSession');
const session = flowChatStore.getState().sessions.get(sessionId);
const acpClientId =
acpClientIdFromAgentType(session?.config?.agentType) ||
acpClientIdFromAgentType(session?.mode);

try {
await agentAPI.cancelDialogTurn(sessionId, dialogTurnId);
log.debug('Backend cancellation completed', { sessionId, dialogTurnId });
if (acpClientId) {
const { ACPClientAPI } = await import('@/infrastructure/api/service-api/ACPClientAPI');
await ACPClientAPI.cancelDialogTurn({
sessionId,
clientId: acpClientId,
workspacePath: session?.workspacePath || session?.config?.workspacePath,
remoteConnectionId: session?.remoteConnectionId,
remoteSshHost: session?.remoteSshHost,
});
} else {
const { agentAPI } = await import('@/infrastructure/api');
await agentAPI.cancelDialogTurn(sessionId, dialogTurnId);
}
log.debug('Backend cancellation completed', { sessionId, dialogTurnId, acpClientId });
} catch (error) {
log.error('Backend cancellation failed', { sessionId, dialogTurnId, error });
log.error('Backend cancellation failed', { sessionId, dialogTurnId, acpClientId, error });
}
}

Expand Down
33 changes: 33 additions & 0 deletions src/web-ui/src/flow_chat/utils/acpSession.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';

import { acpAgentTypeFromSession, isAcpFlowSession } from './acpSession';

describe('acpSession utilities', () => {
it('resolves ACP agent type from session config first', () => {
expect(
acpAgentTypeFromSession({
config: { agentType: 'acp:opencode' },
mode: 'agentic',
} as any)
).toBe('acp:opencode');
});

it('falls back to ACP mode for older or partial session state', () => {
expect(
acpAgentTypeFromSession({
config: { agentType: 'agentic' },
mode: 'acp:codex',
} as any)
).toBe('acp:codex');
});

it('does not classify normal sessions as ACP sessions', () => {
const session = {
config: { agentType: 'agentic' },
mode: 'Plan',
} as any;

expect(acpAgentTypeFromSession(session)).toBeNull();
expect(isAcpFlowSession(session)).toBe(false);
});
});
15 changes: 11 additions & 4 deletions src/web-ui/src/flow_chat/utils/acpSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,20 @@ export function isAcpAgentType(agentType: string | null | undefined): boolean {
return acpClientIdFromAgentType(agentType) !== null;
}

export function acpAgentTypeFromSession(
session: Pick<Session, 'config' | 'mode'> | null | undefined,
): string | null {
const configClientId = acpClientIdFromAgentType(session?.config?.agentType);
if (configClientId) return `${ACP_AGENT_TYPE_PREFIX}${configClientId}`;

const modeClientId = acpClientIdFromAgentType(session?.mode);
return modeClientId ? `${ACP_AGENT_TYPE_PREFIX}${modeClientId}` : null;
}

export function isAcpFlowSession(
session: Pick<Session, 'config' | 'mode'> | null | undefined,
): boolean {
return Boolean(
isAcpAgentType(session?.config?.agentType) ||
isAcpAgentType(session?.mode),
);
return acpAgentTypeFromSession(session) !== null;
}

export interface AcpSessionRef {
Expand Down
Loading