diff --git a/index.html b/index.html index 504cfeb7..52eae395 100644 --- a/index.html +++ b/index.html @@ -45,7 +45,7 @@ } window.APP_DATA = { - apiUrl: "http://localhost:5002", + apiUrl: "", } diff --git a/src/frontend/components/ChatMessagesView.tsx b/src/frontend/components/ChatMessagesView.tsx index 760a8d73..5f92bd63 100644 --- a/src/frontend/components/ChatMessagesView.tsx +++ b/src/frontend/components/ChatMessagesView.tsx @@ -604,13 +604,20 @@ export function AIMessageRenderer({ message, pendingInterrupt, onInterruptResume
{toolCall.name} - {(toolCall as Record).content != null ? ( + {(toolCall as Record).status === 'error' ? ( +
-
Tool execution
+
).status === 'error' ? 'text-red-500 dark:text-red-400' : 'text-muted-foreground', + )}> + {(toolCall as Record).status === 'error' ? 'Tool execution failed' : 'Tool execution'} +
{isExpanded ? ( @@ -629,8 +636,15 @@ export function AIMessageRenderer({ message, pendingInterrupt, onInterruptResume {!needsApproval && ( <> -
- {(toolCall as Record).content ? 'Result' : 'Running...'} +
).status === 'error' ? 'text-red-500 dark:text-red-400' : 'text-muted-foreground', + )}> + {(toolCall as Record).status === 'error' + ? 'Error' + : (toolCall as Record).content != null + ? 'Result' + : 'Running...'}
{(() => { const raw = (toolCall as Record).content; diff --git a/src/frontend/components/InterruptBanner.test.tsx b/src/frontend/components/InterruptBanner.test.tsx index d72ab150..0b62d0a6 100644 --- a/src/frontend/components/InterruptBanner.test.tsx +++ b/src/frontend/components/InterruptBanner.test.tsx @@ -225,6 +225,40 @@ describe('InterruptBanner — MCP auth branch', () => { expect(screen.queryByRole('button', { name: /continue/i })).not.toBeInTheDocument(); }); + it('accepts mcp_oauth_done from the OAuth provider origin after Authenticate', async () => { + vi.mocked(open).mockReturnValue(window); + vi.mocked(fetch) + .mockResolvedValueOnce( + new Response(JSON.stringify({ authorize_url: 'https://oauth.example.com/auth' }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ) + .mockResolvedValue( + new Response(JSON.stringify({ connected: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ); + + render(); + await userEvent.click(screen.getByRole('button', { name: /authenticate/i })); + await waitFor(() => expect(vi.mocked(open)).toHaveBeenCalled()); + expect(screen.queryByText(/popup blocked by browser/i)).not.toBeInTheDocument(); + + fireEvent( + window, + new MessageEvent('message', { + data: { type: 'mcp_oauth_done', mcp_name: 'github' }, + origin: 'https://oauth.example.com', + }), + ); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /continue/i })).toBeInTheDocument(); + }); + }); + it('calls onResume("continue") when Continue is clicked', async () => { const onResume = vi.fn(); vi.mocked(fetch).mockResolvedValue( diff --git a/src/frontend/components/InterruptBanner.tsx b/src/frontend/components/InterruptBanner.tsx index 30ca45df..1899bf94 100644 --- a/src/frontend/components/InterruptBanner.tsx +++ b/src/frontend/components/InterruptBanner.tsx @@ -79,6 +79,7 @@ export function InterruptBanner({ interrupt, onResume, onDismiss }: InterruptBan const [oauthReady, setOauthReady] = useState(false); const [connecting, setConnecting] = useState(false); const [connectError, setConnectError] = useState(null); + const [oauthOrigin, setOauthOrigin] = useState(null); const mcpAuth = parseMcpAuthPayload(interrupt); @@ -94,7 +95,8 @@ export function InterruptBanner({ interrupt, onResume, onDismiss }: InterruptBan if (!mcpAuth) return undefined; const handler = (event: MessageEvent) => { - if (event.origin !== window.location.origin) return; + const allowedOrigins = [window.location.origin, oauthOrigin].filter(Boolean); + if (allowedOrigins.length > 0 && !allowedOrigins.includes(event.origin)) return; const data = event.data as { type?: string; mcp_name?: string } | null; if (data?.type === 'mcp_oauth_done' && data.mcp_name === mcpAuth.mcp_name) { void verifyAndSetReady(mcpAuth.mcp_name); @@ -113,10 +115,11 @@ export function InterruptBanner({ interrupt, onResume, onDismiss }: InterruptBan window.removeEventListener('message', handler); window.removeEventListener('focus', onFocus); }; - }, [mcpAuth, oauthReady, verifyAndSetReady]); + }, [mcpAuth, oauthReady, oauthOrigin, verifyAndSetReady]); const handleConnect = useCallback(async () => { if (!mcpAuth) return; + setOauthOrigin(null); setConnecting(true); setConnectError(null); try { @@ -133,7 +136,9 @@ export function InterruptBanner({ interrupt, onResume, onDismiss }: InterruptBan if (!body.authorize_url) { throw new Error('No authorize_url returned'); } - const popup = window.open(body.authorize_url, 'mcp-oauth', 'width=600,height=700'); + const authorizeUrl = new URL(body.authorize_url, window.location.origin); + setOauthOrigin(authorizeUrl.origin); + const popup = window.open(authorizeUrl.href, 'mcp-oauth', 'width=600,height=700'); if (!popup) { throw new Error('Popup blocked by browser'); } diff --git a/src/frontend/hooks/useStreamingAPI.ts b/src/frontend/hooks/useStreamingAPI.ts index 24642916..5d533659 100644 --- a/src/frontend/hooks/useStreamingAPI.ts +++ b/src/frontend/hooks/useStreamingAPI.ts @@ -539,6 +539,7 @@ export function useStreamingAPI(threadId: string) { chatId: threadId, toolCallId: m.tool_call_id, content: m.content, + status: (m as Record).status as string | undefined, }), ); dispatch( @@ -670,6 +671,7 @@ export function useStreamingAPI(threadId: string) { } setWasInterrupted(true); } else { + dispatch(resolveAllPendingToolCalls({ chatId: threadId, status: 'error' })); dispatch( updateStreamingState({ chatId: threadId, @@ -997,7 +999,7 @@ export function useStreamingAPI(threadId: string) { return; } if (m.type === 'tool') { - dispatch(mergeToolResult({ chatId: threadId, toolCallId: m.tool_call_id, content: m.content })); + dispatch(mergeToolResult({ chatId: threadId, toolCallId: m.tool_call_id, content: m.content, status: (m as Record).status as string | undefined })); dispatch(updateStreamingState({ chatId: threadId, state: { activeSubAgent: null } })); } }, @@ -1006,6 +1008,7 @@ export function useStreamingAPI(threadId: string) { dispatch(updateStreamingState({ chatId: threadId, state: { pendingInterrupt: enrichInterrupt(interrupt) } })); }, onError(error) { + dispatch(resolveAllPendingToolCalls({ chatId: threadId, status: 'error' })); resumeStreamHadError = true; // Queue decision to localStorage for replay when agent recovers let decisionQueued = false; @@ -1120,6 +1123,7 @@ export function useStreamingAPI(threadId: string) { chatId: threadId, toolCallId: m.tool_call_id, content: m.content, + status: (m as Record).status as string | undefined, }), ); return; @@ -1137,6 +1141,7 @@ export function useStreamingAPI(threadId: string) { ); }, onError(error) { + dispatch(resolveAllPendingToolCalls({ chatId: threadId })); dispatch( updateStreamingState({ chatId: threadId, @@ -1178,6 +1183,7 @@ export function useStreamingAPI(threadId: string) { const stop = useCallback(() => { userCancelledRef.current = true; managerRef.current?.cancel(); + dispatch(resolveAllPendingToolCalls({ chatId: threadId, status: 'cancelled' })); clearReconnectTimers(); if (recoveryIntervalRef.current) { clearInterval(recoveryIntervalRef.current); diff --git a/src/frontend/lib/streaming/StreamingManager.ts b/src/frontend/lib/streaming/StreamingManager.ts index 0f3bcb30..7e061fa3 100644 --- a/src/frontend/lib/streaming/StreamingManager.ts +++ b/src/frontend/lib/streaming/StreamingManager.ts @@ -136,7 +136,7 @@ export class StreamingManager { try { const body: Record = { message: request.resume - ? { decisions: request.resumeDecisions ?? [] } + ? (request.resumeDecisions?.length ? { decisions: request.resumeDecisions } : request.message) : request.message, thread_id: request.threadId || 'default-thread', session_id: request.threadId || 'default-session', diff --git a/src/frontend/redux/slices/chats.ts b/src/frontend/redux/slices/chats.ts index 17df3752..c64ecec8 100644 --- a/src/frontend/redux/slices/chats.ts +++ b/src/frontend/redux/slices/chats.ts @@ -58,7 +58,7 @@ const initialState: ChatsState = { error: null, }; -type ToolCallRecord = { id?: string; content?: unknown }; +type ToolCallRecord = { id?: string; content?: unknown; status?: string }; function deepClone(obj: T): T { return JSON.parse(JSON.stringify(obj)); @@ -121,8 +121,8 @@ const chatsSlice = createSlice({ } (last as { content: string }).content = prev + content; }, - mergeToolResult(state, action: PayloadAction<{ chatId: string; toolCallId: string; content: any }>) { - const { chatId, toolCallId, content } = action.payload; + mergeToolResult(state, action: PayloadAction<{ chatId: string; toolCallId: string; content: any; status?: string }>) { + const { chatId, toolCallId, content, status } = action.payload; const chat = state.chats.find((c) => c.id === chatId); if (!chat) { return; @@ -136,19 +136,24 @@ const chatsSlice = createSlice({ const match = toolCalls.find((tc) => tc?.id === toolCallId); if (match) { match.content = content; + if (status) { + match.status = status; + } return; } } }, - resolveAllPendingToolCalls(state, action: PayloadAction<{ chatId: string }>) { + resolveAllPendingToolCalls(state, action: PayloadAction<{ chatId: string; status?: string }>) { const chat = state.chats.find((c) => c.id === action.payload.chatId); if (!chat) return; + const terminalStatus = action.payload.status || 'error'; for (const message of chat.messages) { const msg = message as Message & { tool_calls?: ToolCallRecord[] }; if (!Array.isArray(msg.tool_calls)) continue; for (const tc of msg.tool_calls) { if (tc && tc.content == null) { tc.content = ''; + tc.status = terminalStatus; } } } diff --git a/src/server/utils/settings.ts b/src/server/utils/settings.ts index 665d879b..9159b34d 100644 --- a/src/server/utils/settings.ts +++ b/src/server/utils/settings.ts @@ -103,6 +103,7 @@ interface BrandingConfig { interface FeaturesConfig { debug_mode_default: boolean; auth_enabled: boolean; + mcp_dcr_enabled: boolean; } interface AgentConfig { @@ -156,6 +157,7 @@ const DEFAULTS: UISettings = { features: { debug_mode_default: false, auth_enabled: true, + mcp_dcr_enabled: true, }, agent: { endpoint: "", @@ -389,6 +391,9 @@ function applyEnvOverrides(config: UISettings): void { if (process.env.FEATURE_DEBUG_MODE_DEFAULT !== undefined) { config.features.debug_mode_default = process.env.FEATURE_DEBUG_MODE_DEFAULT === "true"; } + if (process.env.MCP_DCR_ENABLED !== undefined) { + config.features.mcp_dcr_enabled = process.env.MCP_DCR_ENABLED === "true"; + } // Agent overrides if (process.env.AGENT_ENDPOINT) { config.agent.endpoint = process.env.AGENT_ENDPOINT; diff --git a/vite.config.ts b/vite.config.ts index 9d6d7548..5245b904 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,6 +4,8 @@ import react from "@vitejs/plugin-react-swc"; import tailwindcss from "@tailwindcss/vite"; // https://vitejs.dev/config/ +const proxyTarget = `http://127.0.0.1:${process.env.PORT || 8080}`; + export default defineConfig({ plugins: [react(), tailwindcss()], build: { @@ -29,15 +31,15 @@ export default defineConfig({ server: { proxy: { "/api": { - target: "http://127.0.0.1:8080", + target: proxyTarget, changeOrigin: true, }, "/auth": { - target: "http://127.0.0.1:8080", + target: proxyTarget, changeOrigin: true, }, "/login": { - target: "http://127.0.0.1:8080", + target: proxyTarget, changeOrigin: true, }, },