From 044937bd1d6e419432445267c932788d89927095 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 7 Aug 2026 21:33:06 -0700 Subject: [PATCH] chore(mship): revert the credential-continue questions flow Reverts #6385. Companion revert in mothership (#414). --- apps/desktop/src/main/handoff.test.ts | 64 -- apps/desktop/src/main/handoff.ts | 78 +- apps/desktop/src/main/ipc.test.ts | 12 +- apps/desktop/src/main/ipc.ts | 14 +- .../app/api/auth/trello/authorize/route.ts | 17 - .../sim/app/api/auth/trello/callback/route.ts | 36 +- apps/sim/app/api/auth/trello/store/route.ts | 2 - .../components/agent-group/tool-call-item.tsx | 14 +- .../components/chat-content/chat-content.tsx | 8 - .../components/interaction-card.tsx | 129 ---- .../components/question/question.test.ts | 82 -- .../components/question/question.tsx | 234 +++--- .../components/special-tags/index.ts | 8 - .../special-tags/special-tags.test.ts | 95 +-- .../special-tags/special-tags.test.tsx | 495 +----------- .../components/special-tags/special-tags.tsx | 725 ++---------------- .../special-tags/use-oauth-chip-connection.ts | 357 --------- .../message-content/message-content.tsx | 8 - .../message-actions-visibility.test.ts | 6 +- .../message-actions-visibility.ts | 12 +- .../mothership-chat/mothership-chat.tsx | 77 +- .../client-credential-account-modal.tsx | 8 +- .../connect-service-account-modal.tsx | 17 +- .../token-service-account-modal.tsx | 3 +- .../hooks/queries/oauth/oauth-connections.ts | 3 +- apps/sim/hooks/use-oauth-return.ts | 96 +-- .../api/contracts/oauth-connections.test.ts | 19 - .../lib/api/contracts/oauth-connections.ts | 10 +- .../copilot/chat/sim-key-redaction.test.ts | 34 - .../sim/lib/copilot/chat/sim-key-redaction.ts | 55 +- .../lib/copilot/generated/tool-catalog-v1.ts | 2 +- .../lib/copilot/generated/tool-schemas-v1.ts | 2 +- .../tool-executor/register-handlers.ts | 3 - apps/sim/lib/copilot/tools/handlers/oauth.ts | 5 +- apps/sim/lib/credentials/draft-hooks.ts | 70 +- .../credentials/oauth-chat-attempt.test.ts | 194 ----- .../sim/lib/credentials/oauth-chat-attempt.ts | 260 ------- packages/desktop-bridge/src/index.ts | 7 - 38 files changed, 336 insertions(+), 2925 deletions(-) delete mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/interaction-card.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/use-oauth-chip-connection.ts delete mode 100644 apps/sim/lib/credentials/oauth-chat-attempt.test.ts delete mode 100644 apps/sim/lib/credentials/oauth-chat-attempt.ts diff --git a/apps/desktop/src/main/handoff.test.ts b/apps/desktop/src/main/handoff.test.ts index 12feb6395e8..f594bf2b562 100644 --- a/apps/desktop/src/main/handoff.test.ts +++ b/apps/desktop/src/main/handoff.test.ts @@ -8,11 +8,9 @@ import { buildRedeemScript, type ConnectHandoffCallback, createAuthFlow, - createConnectFlow, createHandoffManager, type HandoffCallback, type HandoffCallbacks, - type HandoffManager, type HandoffManagerDeps, } from '@/main/handoff' import type { EventRecorder } from '@/main/observability' @@ -243,24 +241,6 @@ describe('createHandoffManager', () => { expect(manager.consume(state, 'login')).toBe(false) expect(manager.consume(state, 'connect')).toBe(true) }) - - it('returns the chat attempt correlated with the accepted connect state', async () => { - const deps = makeDeps() - const manager = createHandoffManager(deps, makeCallbacks()) - await manager.beginConnect('google-email', { - workspaceId: 'workspace-1', - chatAttemptId: 'attempt-1', - }) - const state = new URL(vi.mocked(deps.openExternal).mock.calls[0][0]).searchParams.get( - 'state' - ) as string - - expect(manager.consumeConnect(state)).toEqual({ - workspaceId: 'workspace-1', - chatAttemptId: 'attempt-1', - }) - expect(manager.consumeConnect(state)).toBeNull() - }) }) describe('connect handoff account pinning', () => { @@ -293,50 +273,6 @@ describe('connect handoff account pinning', () => { }) }) -describe('connect completion correlation', () => { - function makeConnectManager(scope: { chatAttemptId?: string }): HandoffManager { - return { - begin: vi.fn(async () => true), - beginConnect: vi.fn(async () => true), - consume: vi.fn(() => true), - consumeConnect: vi.fn(() => scope), - clear: vi.fn(), - } - } - - it('echoes the accepted handoff chat attempt to the renderer', () => { - const notifyRenderer = vi.fn() - const flow = createConnectFlow({ - handoff: makeConnectManager({ chatAttemptId: 'attempt-1' }), - events: makeEvents(), - focusMainWindow: vi.fn(), - notifyRenderer, - }) - - flow.handleCallback({ state: VALID_STATE }) - - expect(notifyRenderer).toHaveBeenCalledWith({ ok: true, chatAttemptId: 'attempt-1' }) - }) - - it('marks ordinary integrations-page completions as explicitly uncorrelated', () => { - const notifyRenderer = vi.fn() - const flow = createConnectFlow({ - handoff: makeConnectManager({}), - events: makeEvents(), - focusMainWindow: vi.fn(), - notifyRenderer, - }) - - flow.handleCallback({ state: VALID_STATE, error: 'oauth_failed' }) - - expect(notifyRenderer).toHaveBeenCalledWith({ - ok: false, - error: 'oauth_failed', - chatAttemptId: null, - }) - }) -}) - describe('createAuthFlow window failures', () => { function makeAuthDeps(ensureMainWindow: () => Promise) { const events = makeEvents() diff --git a/apps/desktop/src/main/handoff.ts b/apps/desktop/src/main/handoff.ts index dc5d15b52e3..5d1313ebc56 100644 --- a/apps/desktop/src/main/handoff.ts +++ b/apps/desktop/src/main/handoff.ts @@ -67,14 +67,12 @@ export interface HandoffManagerDeps { export interface ConnectScope { workspaceId?: string credentialId?: string - chatAttemptId?: string } export interface HandoffManager { begin(): Promise beginConnect(providerId: string, scope?: ConnectScope): Promise consume(state: string, kind: HandoffKind): boolean - consumeConnect(state: string): ConnectScope | null clear(): void } @@ -94,12 +92,7 @@ export function createHandoffManager( const now = deps.now ?? Date.now let loopbackServer: Server | null = null let loopbackTimer: NodeJS.Timeout | undefined - let pending: { - state: string - createdAt: number - kind: HandoffKind - connectScope?: ConnectScope - } | null = null + let pending: { state: string; createdAt: number; kind: HandoffKind } | null = null const stopLoopback = () => { clearTimeout(loopbackTimer) @@ -221,23 +214,10 @@ export function createHandoffManager( pending = null } - const consumePending = (state: string, kind: HandoffKind): NonNullable | null => { - if (!pending || pending.kind !== kind) return null - if (now() - pending.createdAt > HANDOFF_TTL_MS) { - clear() - return null - } - if (!safeCompare(pending.state, state)) return null - const consumed = pending - clear() - return consumed - } - const beginFlow = async ( kind: HandoffKind, landingPath: string, - params: Record, - connectScope?: ConnectScope + params: Record ): Promise => { const state = generateShortId(STATE_LENGTH) // startLoopback() already tore down any prior server; if this bind fails, @@ -248,12 +228,7 @@ export function createHandoffManager( clear() return false } - pending = { - state, - createdAt: now(), - kind, - ...(connectScope ? { connectScope: { ...connectScope } } : {}), - } + pending = { state, createdAt: now(), kind } const landing = new URL(landingPath, deps.origin()) for (const [key, value] of Object.entries(params)) { landing.searchParams.set(key, value) @@ -284,24 +259,26 @@ export function createHandoffManager( // unknown (offline, signed out): the page then falls back to its normal // login redirect rather than blocking a connect on a failed probe. const userId = await deps.currentUserId() - return beginFlow( - 'connect', - '/desktop/connect', - { - provider: providerId, - ...(userId ? { user: userId } : {}), - ...(scope.workspaceId ? { workspaceId: scope.workspaceId } : {}), - ...(scope.credentialId ? { credentialId: scope.credentialId } : {}), - }, - scope - ) + return beginFlow('connect', '/desktop/connect', { + provider: providerId, + ...(userId ? { user: userId } : {}), + ...(scope.workspaceId ? { workspaceId: scope.workspaceId } : {}), + ...(scope.credentialId ? { credentialId: scope.credentialId } : {}), + }) }, consume(state: string, kind: HandoffKind) { - return consumePending(state, kind) !== null - }, - consumeConnect(state: string) { - const consumed = consumePending(state, 'connect') - return consumed ? { ...(consumed.connectScope ?? {}) } : null + if (!pending || pending.kind !== kind) { + return false + } + if (now() - pending.createdAt > HANDOFF_TTL_MS) { + clear() + return false + } + if (!safeCompare(pending.state, state)) { + return false + } + clear() + return true }, clear, } @@ -466,8 +443,6 @@ export function createAuthFlow(deps: AuthFlowDeps): AuthFlow { export interface ConnectHandoffResult { ok: boolean error?: string - /** Exact Mothership chat attempt, or null for ordinary integration flows. */ - chatAttemptId: string | null } export interface ConnectFlowDeps { @@ -501,24 +476,19 @@ export function createConnectFlow(deps: ConnectFlowDeps): ConnectFlow { return opened }, handleCallback(callback: ConnectHandoffCallback) { - const scope = deps.handoff.consumeConnect(callback.state) - if (!scope) { + if (!deps.handoff.consume(callback.state, 'connect')) { deps.events.record('connect_handoff_state_fail') return } if (callback.error === undefined) { deps.events.record('connect_handoff_ok') deps.focusMainWindow() - deps.notifyRenderer({ ok: true, chatAttemptId: scope.chatAttemptId ?? null }) + deps.notifyRenderer({ ok: true }) return } deps.events.record('connect_handoff_error', { error: callback.error }) deps.focusMainWindow() - deps.notifyRenderer({ - ok: false, - error: callback.error, - chatAttemptId: scope.chatAttemptId ?? null, - }) + deps.notifyRenderer({ ok: false, error: callback.error }) }, } } diff --git a/apps/desktop/src/main/ipc.test.ts b/apps/desktop/src/main/ipc.test.ts index 0376da1a065..0d2a554524c 100644 --- a/apps/desktop/src/main/ipc.test.ts +++ b/apps/desktop/src/main/ipc.test.ts @@ -337,20 +337,14 @@ describe('registerIpcHandlers', () => { // Chip-initiated connects carry workspace/credential scope; malformed // scopes (wrong types, unsafe ids) are rejected before the handoff. - expect( - await handler?.(appEvent, 'slack', { - workspaceId: 'ws1', - credentialId: 'cred_1', - chatAttemptId: 'attempt_1', - }) - ).toBe(true) + expect(await handler?.(appEvent, 'slack', { workspaceId: 'ws1', credentialId: 'cred_1' })).toBe( + true + ) expect(deps.beginOAuthConnect).toHaveBeenCalledWith('slack', { workspaceId: 'ws1', credentialId: 'cred_1', - chatAttemptId: 'attempt_1', }) expect(await handler?.(appEvent, 'slack', { workspaceId: 'ws/../evil' })).toBe(false) - expect(await handler?.(appEvent, 'slack', { chatAttemptId: '../wrong' })).toBe(false) expect(await handler?.(appEvent, 'slack', 'not-an-object')).toBe(false) }) diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 93f95b8d6b3..8d0c34ea876 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -90,7 +90,6 @@ function parseDesktopScope(raw: unknown): string | null { export interface OAuthConnectScope { workspaceId?: string credentialId?: string - chatAttemptId?: string } /** @@ -105,11 +104,7 @@ export function parseOAuthConnectScope(raw: unknown): OAuthConnectScope | undefi if (typeof raw !== 'object') { return undefined } - const { workspaceId, credentialId, chatAttemptId } = raw as { - workspaceId?: unknown - credentialId?: unknown - chatAttemptId?: unknown - } + const { workspaceId, credentialId } = raw as { workspaceId?: unknown; credentialId?: unknown } if ( workspaceId !== undefined && (typeof workspaceId !== 'string' || !ID_PATTERN.test(workspaceId)) @@ -122,16 +117,9 @@ export function parseOAuthConnectScope(raw: unknown): OAuthConnectScope | undefi ) { return undefined } - if ( - chatAttemptId !== undefined && - (typeof chatAttemptId !== 'string' || !ID_PATTERN.test(chatAttemptId)) - ) { - return undefined - } return { ...(workspaceId !== undefined ? { workspaceId } : {}), ...(credentialId !== undefined ? { credentialId } : {}), - ...(chatAttemptId !== undefined ? { chatAttemptId } : {}), } } diff --git a/apps/sim/app/api/auth/trello/authorize/route.ts b/apps/sim/app/api/auth/trello/authorize/route.ts index b69c6caed2e..fd6aa17d0f3 100644 --- a/apps/sim/app/api/auth/trello/authorize/route.ts +++ b/apps/sim/app/api/auth/trello/authorize/route.ts @@ -6,7 +6,6 @@ import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { env } from '@/lib/core/config/env' import { getBaseUrl } from '@/lib/core/utils/urls' -import { isSameOrigin } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getCanonicalScopesForProvider } from '@/lib/oauth/utils' @@ -15,7 +14,6 @@ const logger = createLogger('TrelloAuthorize') export const dynamic = 'force-dynamic' const TRELLO_STATE_COOKIE = 'trello_oauth_state' -const TRELLO_RETURN_URL_COOKIE = 'trello_return_url' const TRELLO_STATE_COOKIE_PATH = '/api/auth/trello' const TRELLO_STATE_COOKIE_MAX_AGE_SECONDS = 60 * 10 @@ -28,7 +26,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const parsed = await parseRequest(authorizeTrelloContract, request, {}) if (!parsed.success) return parsed.response - const { returnUrl: requestedReturnUrl } = parsed.data.query const apiKey = env.TRELLO_API_KEY @@ -60,20 +57,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { maxAge: TRELLO_STATE_COOKIE_MAX_AGE_SECONDS, path: TRELLO_STATE_COOKIE_PATH, }) - if (requestedReturnUrl && isSameOrigin(requestedReturnUrl)) { - response.cookies.set(TRELLO_RETURN_URL_COOKIE, requestedReturnUrl, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: TRELLO_STATE_COOKIE_MAX_AGE_SECONDS, - path: TRELLO_STATE_COOKIE_PATH, - }) - } else { - response.cookies.delete({ - name: TRELLO_RETURN_URL_COOKIE, - path: TRELLO_STATE_COOKIE_PATH, - }) - } return response } catch (error) { logger.error('Error initiating Trello authorization:', error) diff --git a/apps/sim/app/api/auth/trello/callback/route.ts b/apps/sim/app/api/auth/trello/callback/route.ts index 2d45e1dca3b..9ef74c2081e 100644 --- a/apps/sim/app/api/auth/trello/callback/route.ts +++ b/apps/sim/app/api/auth/trello/callback/route.ts @@ -3,7 +3,6 @@ import { type NextRequest, NextResponse } from 'next/server' import { trelloCallbackContract } from '@/lib/api/contracts/oauth-connections' import { parseRequest } from '@/lib/api/server' import { getBaseUrl } from '@/lib/core/utils/urls' -import { isSameOrigin } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('TrelloCallback') @@ -11,8 +10,6 @@ const logger = createLogger('TrelloCallback') export const dynamic = 'force-dynamic' const TRELLO_STATE_COOKIE = 'trello_oauth_state' -const TRELLO_RETURN_URL_COOKIE = 'trello_return_url' -const TRELLO_COOKIE_PATH = '/api/auth/trello' function escapeForJsString(value: string): string { return value.replace(/[\\'"<>&\r\n\u2028\u2029]/g, (ch) => { @@ -20,15 +17,9 @@ function escapeForJsString(value: string): string { }) } -function withResultParam(returnUrl: string, key: string, value: string): string { - const url = new URL(returnUrl) - url.searchParams.set(key, value) - return url.toString() -} - -function renderErrorPage(redirectUrl: string) { +function renderErrorPage(baseUrl: string, redirectQuery: string) { return new NextResponse( - `Trello connection failed

Trello connection failed. Redirecting...

`, + `Trello connection failed

Trello connection failed. Redirecting...

`, { status: 400, headers: { @@ -44,11 +35,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (!parsed.success) return parsed.response const baseUrl = getBaseUrl() - const requestedReturnUrl = request.cookies.get(TRELLO_RETURN_URL_COOKIE)?.value - const returnUrl = - requestedReturnUrl && isSameOrigin(requestedReturnUrl) - ? requestedReturnUrl - : `${baseUrl}/workspace` const queryState = parsed.data.query.state const cookieState = request.cookies.get(TRELLO_STATE_COOKIE)?.value @@ -57,20 +43,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => { hasQueryState: Boolean(queryState), hasCookieState: Boolean(cookieState), }) - const response = renderErrorPage(withResultParam(returnUrl, 'error', 'trello_state_mismatch')) - response.cookies.delete({ name: TRELLO_STATE_COOKIE, path: TRELLO_COOKIE_PATH }) - response.cookies.delete({ name: TRELLO_RETURN_URL_COOKIE, path: TRELLO_COOKIE_PATH }) + const response = renderErrorPage(baseUrl, 'error=trello_state_mismatch') + response.cookies.delete({ name: TRELLO_STATE_COOKIE, path: '/api/auth/trello' }) return response } const safeState = escapeForJsString(queryState) - const successReturnUrl = escapeForJsString(withResultParam(returnUrl, 'trello_connected', 'true')) - const storeFailureReturnUrl = escapeForJsString( - withResultParam(returnUrl, 'error', 'trello_failed') - ) - const authFailureReturnUrl = escapeForJsString( - withResultParam(returnUrl, 'error', 'trello_auth_failed') - ) return new NextResponse( ` @@ -164,7 +142,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (data.success) { statusEl.textContent = 'Success! Redirecting...'; setTimeout(function() { - window.location.href = '${successReturnUrl}'; + window.location.href = '${baseUrl}/workspace?trello_connected=true'; }, 500); } else { throw new Error(data.error || 'Failed to save connection'); @@ -175,7 +153,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { errorEl.style.display = 'block'; statusEl.textContent = 'Connection failed'; setTimeout(function() { - window.location.href = '${storeFailureReturnUrl}'; + window.location.href = '${baseUrl}/workspace?error=trello_failed'; }, 3000); }); @@ -184,7 +162,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { errorEl.style.display = 'block'; statusEl.textContent = 'Connection failed'; setTimeout(function() { - window.location.href = '${authFailureReturnUrl}'; + window.location.href = '${baseUrl}/workspace?error=trello_auth_failed'; }, 3000); } })(); diff --git a/apps/sim/app/api/auth/trello/store/route.ts b/apps/sim/app/api/auth/trello/store/route.ts index a9ca15b7ee4..156ed9a65d6 100644 --- a/apps/sim/app/api/auth/trello/store/route.ts +++ b/apps/sim/app/api/auth/trello/store/route.ts @@ -17,12 +17,10 @@ const logger = createLogger('TrelloStore') export const dynamic = 'force-dynamic' const TRELLO_STATE_COOKIE = 'trello_oauth_state' -const TRELLO_RETURN_URL_COOKIE = 'trello_return_url' const TRELLO_STATE_COOKIE_PATH = '/api/auth/trello' function clearStateCookie(response: NextResponse) { response.cookies.delete({ name: TRELLO_STATE_COOKIE, path: TRELLO_STATE_COOKIE_PATH }) - response.cookies.delete({ name: TRELLO_RETURN_URL_COOKIE, path: TRELLO_STATE_COOKIE_PATH }) return response } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx index 71d675e02d5..4e07b66f747 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx @@ -206,13 +206,11 @@ export function ToolCallItem({ return (
) @@ -222,7 +220,7 @@ export function ToolCallItem({ const reason = typeof params?.reason === 'string' ? params.reason.trim() : '' return (
- +
) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx index def9e6f7839..1fd9961a504 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx @@ -17,7 +17,6 @@ import { extractTextContent } from '@/lib/core/utils/react-node-text' import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon' import { type ContentSegment, - type CredentialSubmissionPayload, parseSpecialTags, SpecialTags, } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' @@ -395,12 +394,9 @@ const MARKDOWN_COMPONENTS = { interface ChatContentProps { content: string - messageId?: string isStreaming?: boolean /** Transcript-derived answers for this message's question card (renders the recap). */ questionAnswers?: string[] - /** Transcript-derived status payload for this message's credential card. */ - credentialSubmission?: CredentialSubmissionPayload onOptionSelect?: (id: string) => void onQuestionDismiss?: () => void onWorkspaceResourceSelect?: (resource: WorkspaceResourceRef) => void @@ -416,10 +412,8 @@ interface ChatContentProps { function ChatContentInner({ content, - messageId, isStreaming = false, questionAnswers, - credentialSubmission, onOptionSelect, onQuestionDismiss, onWorkspaceResourceSelect, @@ -642,9 +636,7 @@ function ChatContentInner({ diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/interaction-card.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/interaction-card.tsx deleted file mode 100644 index 3ad8133f722..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/interaction-card.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { forwardRef, type InputHTMLAttributes, type MouseEventHandler, type ReactNode } from 'react' -import { ArrowRight, cn } from '@sim/emcn' - -export const INTERACTION_CARD_ROW_CLASSES = - 'flex items-center gap-2 border-[var(--border)] px-2 py-2 text-left transition-colors' - -export const INTERACTION_CARD_TEXT_INPUT_CLASSES = - 'min-w-0 flex-1 border-0 bg-transparent p-0 text-[var(--text-body)] text-sm outline-none placeholder:text-[var(--text-muted)] disabled:cursor-not-allowed' - -export interface InteractionCardRecapItem { - label: string - values: readonly string[] -} - -interface InteractionCardProps { - children: ReactNode - title?: ReactNode - actions?: ReactNode - className?: string -} - -/** - * Shared chat-inline card chrome for terminal UI tags that need user input. - * Question choices and credential controls use this same shell so their - * spacing, border, surface, and header remain visually identical. - */ -export function InteractionCard({ children, title, actions, className }: InteractionCardProps) { - return ( -
- {title !== undefined && ( -
-

{title}

- {actions} -
- )} - {children} -
- ) -} - -interface InteractionCardRecapProps { - items: readonly InteractionCardRecapItem[] -} - -/** Shared answered-state layout used by questions and credential requests. */ -export function InteractionCardRecap({ items }: InteractionCardRecapProps) { - return ( - - {items.map((item, index) => ( -
-

{item.label}

-
- {item.values.map((value, valueIndex) => ( -

{value}

- ))} -
-
- ))} -
- ) -} - -export interface InteractionCardInputRowProps - extends Omit, 'className'> { - divided?: boolean - leading?: ReactNode - trailing?: ReactNode - inputClassName?: string -} - -/** Shared inline-input row used by question free text and credential secrets. */ -export const InteractionCardInputRow = forwardRef( - ({ divided = false, leading, trailing, inputClassName, ...inputProps }, ref) => ( -
- {leading} - - {trailing} -
- ) -) -InteractionCardInputRow.displayName = 'InteractionCardInputRow' - -interface InteractionCardActionRowProps { - label: string - leading?: ReactNode - disabled?: boolean - onClick: MouseEventHandler -} - -/** Shared terminal action row used for question and credential submission. */ -export function InteractionCardActionRow({ - label, - leading, - disabled = false, - onClick, -}: InteractionCardActionRowProps) { - return ( - - ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.test.ts index 5265b9f3668..c22bc12ac5b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.test.ts @@ -210,86 +210,4 @@ describe('QuestionDisplay', () => { act(() => root.unmount()) container.remove() }) - - it('uses Continue before the final multi-select page and Submit on the last page', () => { - ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true - const container = document.createElement('div') - document.body.appendChild(container) - const root = createRoot(container) - const finalQuestion: QuestionItem = { - type: 'multi_select', - prompt: 'Which format should the report use?', - options: [{ id: 'pdf', label: 'PDF' }], - } - - act(() => { - root.render( - createElement(QuestionDisplay, { - data: [QUESTIONS[2], finalQuestion], - onSelect: () => undefined, - }) - ) - }) - - const firstOption = Array.from(container.querySelectorAll('button')).find( - (button) => button.textContent === 'EST' - ) - act(() => firstOption?.click()) - - const continueButton = Array.from(container.querySelectorAll('button')).find( - (button) => button.textContent === 'Continue' - ) - expect(continueButton).toBeDefined() - act(() => continueButton?.click()) - - expect(container.textContent).toContain(finalQuestion.prompt) - expect( - Array.from(container.querySelectorAll('button')).some( - (button) => button.textContent === 'Submit' - ) - ).toBe(true) - - act(() => root.unmount()) - container.remove() - }) - - it('uses Continue before the final single-select page instead of advancing on selection', () => { - ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true - const container = document.createElement('div') - document.body.appendChild(container) - const root = createRoot(container) - const onSelect = vi.fn() - - act(() => { - root.render( - createElement(QuestionDisplay, { - data: QUESTIONS.slice(0, 2), - onSelect, - }) - ) - }) - - const firstOption = Array.from(container.querySelectorAll('button')).find( - (button) => button.textContent === 'Keep the newest entry' - ) - act(() => firstOption?.click()) - - expect(container.textContent).toContain(QUESTIONS[0].prompt) - expect(onSelect).not.toHaveBeenCalled() - const continueButton = Array.from(container.querySelectorAll('button')).find( - (button) => button.textContent === 'Continue' - ) - expect(continueButton?.disabled).toBe(false) - act(() => continueButton?.click()) - - expect(container.textContent).toContain(QUESTIONS[1].prompt) - expect( - Array.from(container.querySelectorAll('button')).some( - (button) => button.textContent === 'Submit' - ) - ).toBe(true) - - act(() => root.unmount()) - container.remove() - }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx index 8d7df45d5d1..5756f57c72f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx @@ -12,13 +12,6 @@ import { cn, X, } from '@sim/emcn' -import { - INTERACTION_CARD_ROW_CLASSES, - InteractionCard, - InteractionCardActionRow, - InteractionCardInputRow, - InteractionCardRecap, -} from '@/app/workspace/[workspaceId]/home/components/message-content/components/interaction-card' import type { QuestionItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' /** @@ -53,6 +46,9 @@ export function parseQuestionAnswerMessage( return answers } +const OPTION_ROW_CLASSES = + 'flex items-center gap-2 border-[var(--border)] px-2 py-2 text-left transition-colors' + /** Ghost icon-button chrome shared by the stepper chevrons and the dismiss X. */ const ICON_BUTTON_CLASSES = 'relative size-[14px] flex-shrink-0 p-0' @@ -130,6 +126,9 @@ export function QuestionDisplay({ const customFor = (i: number, customs: string[]): string => data[i].type === 'multi_select' && !(customCheckedByStep[i] ?? false) ? '' : (customs[i] ?? '') + const containerClasses = + 'rounded-2xl border border-[var(--border-1)] bg-[var(--white)] px-2.5 py-2 dark:bg-[var(--surface-4)]' + // Transcript answers win over local state: they survive reloads (local // phase does not) and keep live + rehydrated renders identical. const localAnswers = @@ -141,12 +140,18 @@ export function QuestionDisplay({ const recapAnswers = transcriptAnswers ?? localAnswers if (data.length > 0 && recapAnswers) { return ( - ({ - label: question.prompt, - values: answerPartsForDisplay(question, recapAnswers[index] ?? ''), - }))} - /> +
+ {data.map((question, i) => ( +
+

{question.prompt}

+
+ {answerPartsForDisplay(question, recapAnswers[i] ?? '').map((answer, answerIndex) => ( +

{answer}

+ ))} +
+
+ ))} +
) } @@ -157,7 +162,6 @@ export function QuestionDisplay({ const options = question.options const selected = selectedByStep[step] ?? [] const isMulti = question.type === 'multi_select' - const usesStepAction = isMulti || data.length > 1 const commitCustom = (): string[] => { const next = [...customByStep] @@ -197,7 +201,7 @@ export function QuestionDisplay({ customs[step] = '' setCustomByStep(customs) setFreeText('') - if (!usesStepAction) finishStep(selections, customs) + finishStep(selections, customs) } const handleMultiToggle = (label: string) => { @@ -209,17 +213,9 @@ export function QuestionDisplay({ setSelectedByStep(selections) } - /** Confirms the current page, then advances or submits the whole batch. */ - const submitCurrentStep = () => { - const customs = commitCustom() - if (isMulti) { - finishStep(selectedByStep, customs) - return - } - const selections = [...selectedByStep] - if ((customs[step] ?? '').trim()) selections[step] = [] - setSelectedByStep(selections) - finishStep(selections, customs) + /** multi_select confirm: commits selections and/or typed text, then advances. */ + const submitMultiStep = () => { + finishStep(selectedByStep, commitCustom()) } /** Sets whether the typed "Something else" text counts — never touches the text. */ @@ -251,12 +247,14 @@ export function QuestionDisplay({ return data[i].type === 'multi_select' ? (customCheckedByStep[i] ?? false) : true } - const canSubmitStep = !disabled && stepAnswered(step) + const canSubmitStep = !disabled && (isMulti ? stepAnswered(step) : freeText.trim().length > 0) return ( - +
+

+ {question.prompt} +

{data.length > 1 && (
@@ -311,8 +309,7 @@ export function QuestionDisplay({ )}
- } - > +
{options.map((option, i) => { const isSelected = selected.includes(option.label) @@ -325,7 +322,7 @@ export function QuestionDisplay({ isMulti ? handleMultiToggle(option.label) : handleSingleSelect(option.label) } className={cn( - INTERACTION_CARD_ROW_CLASSES, + OPTION_ROW_CLASSES, disabled ? 'cursor-not-allowed' : 'hover-hover:bg-[var(--surface-5)]', i > 0 && 'border-t', isSelected && 'bg-[var(--surface-5)]' @@ -339,92 +336,105 @@ export function QuestionDisplay({ ) })} - 0} - leading={ - isMulti ? ( -
- -
- ) : undefined - } - trailing={ - !usesStepAction ? ( +
0 && 'border-t')}> + {isMulti && ( +
- ) : undefined - } - type='text' - value={freeText} - placeholder='Something else' - disabled={disabled} - onFocus={() => { - if (isMulti) setCustomChecked(true) - }} - onChange={(event) => setFreeText(event.target.value)} - onBlur={(event) => { - if ( - isMulti && - event.relatedTarget !== freeTextCheckboxRef.current && - freeText.trim().length === 0 - ) { - setCustomChecked(false) - } - }} - onKeyDown={(event) => { - if (event.key === 'Escape') { - event.currentTarget.blur() - return - } - if (event.key === 'Enter' && canSubmitStep) { - event.preventDefault() - if (usesStepAction) submitCurrentStep() - else submitSingleFreeText() - } - }} - aria-label={question.prompt} - /> - {usesStepAction && ( - } +
+ )} + { + if (isMulti) setCustomChecked(true) + }} + onChange={(e) => setFreeText(e.target.value)} + onBlur={(event) => { + if ( + isMulti && + event.relatedTarget !== freeTextCheckboxRef.current && + freeText.trim().length === 0 + ) { + setCustomChecked(false) + } + }} + onKeyDown={(e) => { + if (e.key === 'Escape') { + e.currentTarget.blur() + return + } + if (e.key === 'Enter' && canSubmitStep) { + e.preventDefault() + if (isMulti) { + submitMultiStep() + } else { + submitSingleFreeText() + } + } + }} + aria-label={question.prompt} + className='min-w-0 flex-1 border-0 bg-transparent p-0 text-[var(--text-body)] text-sm outline-none placeholder:text-[var(--text-muted)] disabled:cursor-not-allowed' /> + {!isMulti && ( + + )} +
+ {isMulti && ( + )}
- +
) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/index.ts index 030af87c6dd..dff2706b01d 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/index.ts @@ -1,7 +1,5 @@ export type { ContentSegment, - CredentialItemData, - CredentialSubmissionPayload, CredentialTagData, CredentialTagType, FileTagData, @@ -21,15 +19,9 @@ export type { export { CREDENTIAL_TAG_TYPES, CredentialDisplay, - credentialTagHasVisibleCard, - formatCredentialSubmissionMessage, PendingTagIndicator, - parseCredentialSubmissionMessage, - parseCredentialSubmissionProgress, - parseCredentialTagBody, parseFileTag, parseJsonTagBody, - parseLastCredentialTag, parseLastQuestionTag, parseQuestionTagBody, parseSpecialTags, diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts index e28bf310594..bdd324efa15 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.ts @@ -17,17 +17,10 @@ vi.mock('@/lib/auth/auth-client', () => ({ import { scalingRatioOver4x } from '@/app/workspace/[workspaceId]/home/components/message-content/components/scaling-test-helpers' import type { ContentSegment, - CredentialItemData, IndexOfCache, } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags' import { - credentialTagHasVisibleCard, - formatCredentialSubmissionMessage, memoizedIndexOf, - parseCredentialSubmissionMessage, - parseCredentialSubmissionProgress, - parseCredentialTagBody, - parseLastCredentialTag, parseQuestionTagBody, parseSpecialTags, SPECIAL_TAG_NAMES, @@ -42,88 +35,6 @@ function renderedText(segments: ContentSegment[]): string { return segments.map((segment) => ('content' in segment ? segment.content : '')).join('') } -describe('parseCredentialTagBody', () => { - const secret: CredentialItemData = { type: 'secret_input', name: 'OPENAI_API_KEY' } - const oauth: CredentialItemData = { - type: 'link', - provider: 'google-email', - value: 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email', - } - - it('normalizes a singleton credential object to one row', () => { - expect(parseCredentialTagBody(JSON.stringify(secret))).toEqual([secret]) - }) - - it('preserves a mixed credential-input batch in one tag', () => { - expect(parseCredentialTagBody(JSON.stringify([secret, oauth]))).toEqual([secret, oauth]) - }) - - it('rejects empty arrays and batches containing an invalid row', () => { - expect(parseCredentialTagBody('[]')).toBeNull() - expect(parseCredentialTagBody(JSON.stringify([secret, { type: 'link' }]))).toBeNull() - }) - - it('formats and strictly pairs the safe continuation without secret values', () => { - const data = [oauth, secret] - const message = formatCredentialSubmissionMessage(data) - - expect(message).toBe( - 'Credential setup submitted — {"integrations":[{"name":"google-email","status":"connected"}],"secrets":[{"name":"OPENAI_API_KEY","status":"saved"}]}' - ) - expect(parseCredentialSubmissionMessage(data, message)).toBe(true) - expect(parseCredentialSubmissionProgress(data, message)).toEqual({ - integrations: [{ name: 'google-email', status: 'connected' }], - secrets: [{ name: 'OPENAI_API_KEY', status: 'saved' }], - }) - expect(parseCredentialSubmissionMessage(data, `${message}!`)).toBe(false) - }) - - it('reports skipped rows without leaking secret values', () => { - const data = [oauth, secret] - const message = formatCredentialSubmissionMessage(data, { - connectedIntegrationIndexes: new Set(), - savedSecretIndexes: new Set(), - }) - - expect(message).toBe( - 'Credential setup submitted — {"integrations":[{"name":"google-email","status":"skipped"}],"secrets":[{"name":"OPENAI_API_KEY","status":"skipped"}]}' - ) - expect(parseCredentialSubmissionMessage(data, message)).toBe(true) - }) - - it('still pairs legacy completed setup messages after reload', () => { - expect( - parseCredentialSubmissionMessage( - [oauth, secret], - 'Credential setup complete — integrations: google-email; secrets: OPENAI_API_KEY' - ) - ).toBe(true) - }) - - it('extracts the last complete credential batch for transcript pairing', () => { - const content = `First ${JSON.stringify(secret)} then ${JSON.stringify([oauth, secret])}` - expect(parseLastCredentialTag(content)).toEqual([oauth, secret]) - }) - - it('only reserves message actions when a credential card is visible to this member', () => { - const workspaceSecret: CredentialItemData = { - type: 'secret_input', - name: 'WORKSPACE_KEY', - scope: 'workspace', - } - const personalSecret: CredentialItemData = { - type: 'secret_input', - name: 'PERSONAL_KEY', - scope: 'personal', - } - - expect(credentialTagHasVisibleCard([workspaceSecret], false)).toBe(false) - expect(credentialTagHasVisibleCard([personalSecret], false)).toBe(true) - expect(credentialTagHasVisibleCard([oauth], false)).toBe(false) - expect(credentialTagHasVisibleCard([oauth], true)).toBe(true) - }) -}) - /** * What the reader can actually see. Mirrors chat-content.tsx: adjacent text * segments concatenate, a `thinking` segment renders NOTHING, and every other @@ -910,7 +821,7 @@ describe('service_account credential tag', () => { expect(credential).toBeDefined() expect(credential).toMatchObject({ type: 'credential', - data: [{ type: 'service_account', provider: 'slack' }], + data: { type: 'service_account', provider: 'slack' }, }) }) @@ -919,7 +830,7 @@ describe('service_account credential tag', () => { const { segments } = parseSpecialTags(`${body}`, false) const credential = segments.find((segment) => segment.type === 'credential') - expect((credential as { data: Array<{ value?: string }> }).data[0].value).toBeUndefined() + expect((credential as { data: { value?: string } }).data.value).toBeUndefined() }) it('suppresses the tag while it is still streaming', () => { @@ -965,7 +876,7 @@ describe('service_account tag validation', () => { const credential = segments.find((segment) => segment.type === 'credential') expect(credential).toMatchObject({ type: 'credential', - data: [{ type: 'service_account', provider: 'notion', credentialId: 'cred_abc123' }], + data: { type: 'service_account', provider: 'notion', credentialId: 'cred_abc123' }, }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx index 69cb9c3bda9..8791ddd3d05 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx @@ -5,22 +5,9 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockRefetchPersonalEnvironment, - mockRefetchWorkspaceCredentials, - mockSavePersonalEnvironment, - mockUpsertWorkspaceEnvironment, - mockUseUserPermissionsContext, - mockUseWorkspaceCredential, - mockUseWorkspaceCredentials, -} = vi.hoisted(() => ({ - mockRefetchPersonalEnvironment: vi.fn(async () => ({ data: {} })), - mockRefetchWorkspaceCredentials: vi.fn(async () => ({ data: [] })), - mockSavePersonalEnvironment: vi.fn(async () => undefined), - mockUpsertWorkspaceEnvironment: vi.fn(async () => undefined), +const { mockUseUserPermissionsContext, mockUseWorkspaceCredential } = vi.hoisted(() => ({ mockUseUserPermissionsContext: vi.fn(), mockUseWorkspaceCredential: vi.fn(), - mockUseWorkspaceCredentials: vi.fn(), })) vi.mock('@/app/workspace/[workspaceId]/providers/workspace-permissions-provider', () => ({ @@ -33,29 +20,9 @@ vi.mock('next/navigation', () => ({ vi.mock('@/hooks/queries/credentials', () => ({ useWorkspaceCredential: mockUseWorkspaceCredential, - useWorkspaceCredentials: mockUseWorkspaceCredentials, })) -vi.mock('@/hooks/queries/environment', () => ({ - usePersonalEnvironment: () => ({ - data: {}, - refetch: mockRefetchPersonalEnvironment, - }), - useSavePersonalEnvironment: () => ({ - isPending: false, - mutateAsync: mockSavePersonalEnvironment, - }), - useUpsertWorkspaceEnvironment: () => ({ - isPending: false, - mutateAsync: mockUpsertWorkspaceEnvironment, - }), -})) - -import { - createOAuthChatAttempt, - setOAuthChatAttemptStatus, -} from '@/lib/credentials/oauth-chat-attempt' -import type { CredentialItemData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags' +import type { CredentialTagData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags' import { parseSpecialTags, SpecialTags, @@ -65,34 +32,21 @@ import { * Minimal dependency-free render harness (the repo has no `@testing-library/react`). Mounts the * component in a real React 19 root under jsdom, matching the pattern in `use-autosave.test.tsx`. */ -function renderCredentialLink(data: CredentialItemData | CredentialItemData[]): { - container: HTMLDivElement - root: Root -} { +function renderCredentialLink(data: CredentialTagData): { container: HTMLDivElement; root: Root } { ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true const container = document.createElement('div') const root: Root = createRoot(container) act(() => { - root.render( - - ) + root.render() }) return { container, root } } describe('CredentialDisplay link tag', () => { beforeEach(() => { - ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true vi.clearAllMocks() - window.localStorage.clear() - window.history.replaceState({}, '', '/workspace/workspace-1/chat/chat-1') mockUseUserPermissionsContext.mockReturnValue({ canEdit: true }) mockUseWorkspaceCredential.mockReturnValue({ data: null }) - mockUseWorkspaceCredentials.mockReturnValue({ - data: [], - isFetched: true, - refetch: mockRefetchWorkspaceCredentials, - }) }) it('does not render an anchor for a javascript: scheme value', () => { @@ -103,7 +57,6 @@ describe('CredentialDisplay link tag', () => { }) expect(container.querySelector('a')).toBeNull() - expect(container.textContent).toBe('') act(() => root.unmount()) }) @@ -115,7 +68,6 @@ describe('CredentialDisplay link tag', () => { }) expect(container.querySelector('a')).toBeNull() - expect(container.textContent).toBe('') act(() => root.unmount()) }) @@ -131,289 +83,6 @@ describe('CredentialDisplay link tag', () => { expect(link).not.toBeNull() expect(link?.getAttribute('href')).toBe(url) expect(container.textContent).toContain('Connect Google Drive') - expect(container.textContent).toContain('Connect integrations') - act(() => root.unmount()) - }) - - it('continues the chat with provider names after integration setup', async () => { - const container = document.createElement('div') - const root: Root = createRoot(container) - const onOptionSelect = vi.fn() - const data: CredentialItemData[] = [ - { - type: 'link', - provider: 'google-email', - value: 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email', - }, - ] - const attempt = createOAuthChatAttempt({ - workspaceId: 'workspace-1', - providerId: 'google-email', - baseProviderId: 'google', - displayName: 'Gmail', - controlId: 'credential-card:0', - baselineCredentialIds: [], - }) - window.history.replaceState({}, '', `?oauthAttempt=${attempt.id}`) - - act(() => { - root.render( - - ) - }) - - const submitButton = Array.from(container.querySelectorAll('button')).find( - (button) => button.textContent === 'Submit' - ) - expect(submitButton?.disabled).toBe(false) - act(() => { - setOAuthChatAttemptStatus(attempt.id, 'connected') - }) - await act(async () => { - submitButton?.click() - }) - - expect(onOptionSelect).toHaveBeenCalledWith( - 'Credential setup submitted — {"integrations":[{"name":"google-email","status":"connected"}],"secrets":[]}' - ) - expect(container.textContent).toContain('Gmail') - expect(container.textContent).toContain('Connected') - act(() => root.unmount()) - }) - - it('shows not connected after an unfinished OAuth tab returns focus', async () => { - const attempt = createOAuthChatAttempt({ - workspaceId: 'workspace-1', - providerId: 'google-email', - baseProviderId: 'google', - displayName: 'Gmail', - controlId: 'credential-card:0', - baselineCredentialIds: [], - }) - window.history.replaceState({}, '', `?oauthAttempt=${attempt.id}`) - const { container, root } = renderCredentialLink({ - type: 'link', - provider: 'google-email', - value: 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email', - }) - - expect(container.textContent).toContain('Waiting for Gmail connection') - - await act(async () => { - window.dispatchEvent(new Event('blur')) - window.dispatchEvent(new Event('focus')) - }) - - expect(container.textContent).toContain('Not connected — connect Gmail') - act(() => root.unmount()) - }) - - it('keeps a new-account link actionable when Gmail already has a credential', () => { - mockUseWorkspaceCredentials.mockReturnValue({ - data: [{ id: 'existing-gmail', providerId: 'google' }], - isFetched: true, - refetch: mockRefetchWorkspaceCredentials, - }) - const { container, root } = renderCredentialLink({ - type: 'link', - provider: 'google-email', - value: 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email', - }) - - expect(container.textContent).toContain('Connect another Gmail') - expect(container.textContent).not.toContain('Connected Gmail') - expect(container.querySelector('a')?.getAttribute('aria-disabled')).toBe('false') - act(() => root.unmount()) - }) - - it('marks the row connected when a new matching credential appears elsewhere', async () => { - const existingCredential = { - id: 'existing-gmail', - providerId: 'google', - updatedAt: '2026-08-07T10:00:00Z', - } - let credentials = [existingCredential] - mockUseWorkspaceCredentials.mockImplementation(() => ({ - data: credentials, - isFetched: true, - refetch: mockRefetchWorkspaceCredentials, - })) - const data: CredentialItemData = { - type: 'link', - provider: 'google-email', - value: 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email', - } - const { container, root } = renderCredentialLink(data) - - expect(container.textContent).toContain('Connect another Gmail') - credentials = [ - existingCredential, - { id: 'new-gmail', providerId: 'google', updatedAt: '2026-08-07T10:05:00Z' }, - ] - await act(async () => { - root.render() - }) - - expect(container.textContent).toContain('Connected Gmail') - // A workspace-wide change cannot be attributed to one row, so it shows the - // row as satisfied without disabling it — see the sibling-row case below. - expect(container.querySelector('a')?.getAttribute('aria-disabled')).toBe('false') - act(() => root.unmount()) - }) - - it('refetches after returning from another tab and detects a new matching credential', async () => { - const existingCredential = { - id: 'existing-gmail', - providerId: 'google', - updatedAt: '2026-08-07T10:00:00Z', - } - mockUseWorkspaceCredentials.mockReturnValue({ - data: [existingCredential], - isFetched: true, - refetch: mockRefetchWorkspaceCredentials, - }) - mockRefetchWorkspaceCredentials.mockResolvedValueOnce({ - data: [ - existingCredential, - { id: 'new-gmail', providerId: 'google', updatedAt: '2026-08-07T10:05:00Z' }, - ], - }) - const { container, root } = renderCredentialLink({ - type: 'link', - provider: 'google-email', - value: 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email', - }) - - await act(async () => { - window.dispatchEvent(new Event('blur')) - window.dispatchEvent(new Event('focus')) - }) - - expect(mockRefetchWorkspaceCredentials).toHaveBeenCalledOnce() - expect(container.textContent).toContain('Connected Gmail') - act(() => root.unmount()) - }) - - it('does not treat an unrelated update to a reconnect target as OAuth completion', async () => { - let credentials = [{ id: 'cred-1', providerId: 'google', updatedAt: '2026-08-07T10:00:00Z' }] - mockUseWorkspaceCredentials.mockImplementation(() => ({ - data: credentials, - isFetched: true, - refetch: mockRefetchWorkspaceCredentials, - })) - const data: CredentialItemData = { - type: 'link', - provider: 'google-email', - value: - 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email&credentialId=cred-1', - } - const { container, root } = renderCredentialLink(data) - - credentials = [{ ...credentials[0], updatedAt: '2026-08-07T10:05:00Z' }] - await act(async () => { - root.render() - }) - - expect(container.textContent).toContain('Reconnect Gmail') - expect(container.textContent).not.toContain('Connected Gmail') - act(() => root.unmount()) - }) - - it('waits for the credential baseline before opening an OAuth link', () => { - mockUseWorkspaceCredentials.mockReturnValue({ - data: undefined, - isFetched: false, - refetch: mockRefetchWorkspaceCredentials, - }) - const { container, root } = renderCredentialLink({ - type: 'link', - provider: 'google-email', - value: 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email', - }) - - const link = container.querySelector('a') - expect(container.textContent).toContain('Checking Gmail connections') - expect(link?.getAttribute('aria-disabled')).toBe('true') - expect(link?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))).toBe( - false - ) - act(() => root.unmount()) - }) - - it('keeps a sibling row for the same provider clickable when one connects', async () => { - const existingCredential = { - id: 'existing-gmail', - providerId: 'google', - updatedAt: '2026-08-07T10:00:00Z', - } - let credentials = [existingCredential] - mockUseWorkspaceCredentials.mockImplementation(() => ({ - data: credentials, - isFetched: true, - refetch: mockRefetchWorkspaceCredentials, - })) - const data: CredentialItemData[] = [ - { - type: 'link', - provider: 'google-email', - value: 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email', - }, - { - type: 'link', - provider: 'google-email', - value: 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email', - }, - ] - const { container, root } = renderCredentialLink(data) - - credentials = [ - existingCredential, - { id: 'new-gmail', providerId: 'google', updatedAt: '2026-08-07T10:05:00Z' }, - ] - await act(async () => { - root.render() - }) - - const rows = container.querySelectorAll('a') - expect(rows).toHaveLength(2) - for (const row of rows) { - expect(row.getAttribute('aria-disabled')).toBe('false') - } - act(() => root.unmount()) - }) - - it('applies OAuth completion only to the card that launched it', () => { - const data: CredentialItemData[] = [ - { - type: 'link', - provider: 'google-email', - value: 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email', - }, - ] - const attempt = createOAuthChatAttempt({ - workspaceId: 'workspace-1', - providerId: 'google-email', - baseProviderId: 'google', - displayName: 'Gmail', - controlId: 'message-2:0:0', - baselineCredentialIds: [], - }) - window.history.replaceState({}, '', `?oauthAttempt=${attempt.id}`) - const container = document.createElement('div') - const root: Root = createRoot(container) - - act(() => { - root.render( - <> - - - - ) - setOAuthChatAttemptStatus(attempt.id, 'connected') - }) - - expect(container.textContent?.match(/Connected Gmail/g)).toHaveLength(1) - expect(container.textContent?.match(/Connect Gmail/g)).toHaveLength(1) act(() => root.unmount()) }) @@ -426,7 +95,6 @@ describe('CredentialDisplay link tag', () => { }) expect(container.querySelector('a')).toBeNull() - expect(container.textContent).toBe('') act(() => root.unmount()) }) @@ -457,162 +125,13 @@ describe('CredentialDisplay link tag', () => { expect(container.textContent).toContain('Reconnect Gmail') act(() => root.unmount()) }) - - it('renders integrations and secrets in one card and saves secrets on Submit', async () => { - const container = document.createElement('div') - const root: Root = createRoot(container) - const onOptionSelect = vi.fn() - const data: CredentialItemData[] = [ - { - type: 'secret_input', - name: 'OPENAI_API_KEY', - }, - { - type: 'link', - provider: 'google-email', - value: 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email', - }, - ] - const attempt = createOAuthChatAttempt({ - workspaceId: 'workspace-1', - providerId: 'google-email', - baseProviderId: 'google', - displayName: 'Gmail', - controlId: 'credential-card:0', - baselineCredentialIds: [], - }) - window.history.replaceState({}, '', `?oauthAttempt=${attempt.id}`) - - act(() => { - root.render( - - ) - }) - - expect(container.textContent).toContain('Set up credentials') - expect(container.textContent).not.toContain('1 of 2') - expect(container.querySelectorAll('a')).toHaveLength(1) - const secretInput = container.querySelector('input') - expect(secretInput?.getAttribute('placeholder')).toBe('Paste OPENAI_API_KEY') - expect(secretInput?.className).toContain('border-0 bg-transparent p-0') - expect(secretInput?.parentElement?.className).toContain('px-2 py-2') - expect(secretInput?.parentElement?.className).not.toContain('rounded') - expect(container.querySelector('svg rect')).toBeNull() - expect(container.querySelector('button[aria-label="Save"]')).toBeNull() - - act(() => secretInput?.focus()) - act(() => { - if (!secretInput) return - const valueSetter = Object.getOwnPropertyDescriptor( - window.HTMLInputElement.prototype, - 'value' - )?.set - valueSetter?.call(secretInput, 'sk-test-key') - secretInput.dispatchEvent(new Event('input', { bubbles: true })) - }) - - const submitButton = Array.from(container.querySelectorAll('button')).find( - (button) => button.textContent === 'Submit' - ) - expect(submitButton?.disabled).toBe(false) - expect(submitButton?.querySelector('div')).toBeNull() - await act(async () => { - submitButton?.click() - }) - - expect(mockUpsertWorkspaceEnvironment).toHaveBeenCalledWith({ - workspaceId: 'workspace-1', - variables: { OPENAI_API_KEY: 'sk-test-key' }, - }) - expect(onOptionSelect).toHaveBeenCalledWith( - 'Credential setup submitted — {"integrations":[{"name":"google-email","status":"skipped"}],"secrets":[{"name":"OPENAI_API_KEY","status":"saved"}]}' - ) - expect(container.textContent).toContain('Gmail') - expect(container.textContent).toContain('Skipped') - expect(container.textContent).toContain('OPENAI_API_KEY') - expect(container.textContent).toContain('Added') - act(() => root.unmount()) - }) - - it('keeps canonical secret indexes when permission filtering hides a workspace row', async () => { - mockUseUserPermissionsContext.mockReturnValue({ canEdit: false }) - const container = document.createElement('div') - const root = createRoot(container) - const onOptionSelect = vi.fn() - const data: CredentialItemData[] = [ - { type: 'secret_input', name: 'WORKSPACE_KEY', scope: 'workspace' }, - { type: 'secret_input', name: 'PERSONAL_KEY', scope: 'personal' }, - ] - - act(() => { - root.render( - - ) - }) - - const input = container.querySelector('input') - expect(input?.getAttribute('placeholder')).toBe('Paste PERSONAL_KEY') - act(() => { - if (!input) return - const valueSetter = Object.getOwnPropertyDescriptor( - window.HTMLInputElement.prototype, - 'value' - )?.set - valueSetter?.call(input, 'personal-secret') - input.dispatchEvent(new Event('input', { bubbles: true })) - }) - const submitButton = Array.from(container.querySelectorAll('button')).find( - (button) => button.textContent === 'Submit' - ) - await act(async () => submitButton?.click()) - - expect(mockSavePersonalEnvironment).toHaveBeenCalledWith({ - variables: { PERSONAL_KEY: 'personal-secret' }, - }) - expect(onOptionSelect).toHaveBeenCalledWith( - 'Credential setup submitted — {"integrations":[],"secrets":[{"name":"WORKSPACE_KEY","status":"skipped"},{"name":"PERSONAL_KEY","status":"saved"}]}' - ) - act(() => root.unmount()) - }) - - it('renders one status recap from a transcript submission', () => { - const container = document.createElement('div') - const root: Root = createRoot(container) - const data: CredentialItemData[] = [ - { - type: 'link', - provider: 'google-email', - value: 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email', - }, - { type: 'secret_input', name: 'OPENAI_API_KEY' }, - ] - - act(() => { - root.render( - - ) - }) - - expect(container.textContent).not.toContain('Credential setup') - expect(container.textContent).not.toContain('Set up credentials') - expect(container.textContent).toContain('GmailConnected') - expect(container.textContent).toContain('OPENAI_API_KEYSkipped') - expect(container.querySelector('input')).toBeNull() - act(() => root.unmount()) - }) }) describe('parseSpecialTags sim_key placeholder', () => { it('accepts a value-less {"type":"sim_key"} tag as a credential segment', () => { const { segments } = parseSpecialTags('{"type":"sim_key"}', false) const credential = segments.find((s) => s.type === 'credential') - expect(credential).toEqual({ type: 'credential', data: [{ type: 'sim_key' }] }) + expect(credential).toEqual({ type: 'credential', data: { type: 'sim_key' } }) }) it('still accepts the legacy {"redacted":true} form as a value-less sim_key placeholder', () => { @@ -623,8 +142,8 @@ describe('parseSpecialTags sim_key placeholder', () => { const credential = segments.find((s) => s.type === 'credential') expect(credential?.type).toBe('credential') if (credential?.type === 'credential') { - expect(credential.data[0].type).toBe('sim_key') - expect(credential.data[0].value).toBeUndefined() + expect(credential.data.type).toBe('sim_key') + expect(credential.data.value).toBeUndefined() } }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx index 35474f2f564..ec3b1f52d1a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx @@ -3,11 +3,12 @@ import { createElement, lazy, Suspense, useMemo, useState } from 'react' import { ArrowRight, - Check, + Button, ChevronDown, cn, Expandable, ExpandableContent, + SecretInput, SecretReveal, SquareArrowUpRight, Tooltip, @@ -33,15 +34,7 @@ import { getServiceConfigByProviderId } from '@/lib/oauth/utils' import { finishTerminalHandoff, isTerminalAvailable } from '@/lib/terminal/transport' import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context' import { ContextMentionIcon } from '@/app/workspace/[workspaceId]/home/components/context-mention-icon' -import { - INTERACTION_CARD_ROW_CLASSES, - InteractionCard, - InteractionCardActionRow, - InteractionCardInputRow, - InteractionCardRecap, -} from '@/app/workspace/[workspaceId]/home/components/message-content/components/interaction-card' import { QuestionDisplay } from '@/app/workspace/[workspaceId]/home/components/message-content/components/question' -import { useOAuthChipConnection } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/use-oauth-chip-connection' import type { ChatMessageContext, MothershipResource, @@ -117,7 +110,7 @@ export const SECRET_INPUT_SCOPES = ['personal', 'workspace'] as const export type SecretInputScope = (typeof SECRET_INPUT_SCOPES)[number] -export interface CredentialItemData { +export interface CredentialTagData { value?: string type: CredentialTagType provider?: string @@ -136,120 +129,6 @@ export interface CredentialItemData { credentialId?: string } -/** - * Normalized `` payload. A singleton object remains valid for old - * messages, while an array lets one terminal tag render several controls as - * rows in a single card. - */ -export type CredentialTagData = CredentialItemData[] - -export interface CredentialSubmissionProgress { - connectedIntegrationIndexes: ReadonlySet - savedSecretIndexes: ReadonlySet -} - -export interface CredentialSubmissionPayload { - integrations: Array<{ name: string; status: 'connected' | 'skipped' }> - secrets: Array<{ name: string; status: 'saved' | 'skipped' }> -} - -/** - * Safe user-turn payload emitted by the credential question card. It carries - * only the requested provider and environment-variable names; secret values - * remain in Sim's credential stores and never enter the transcript. - */ -export function formatCredentialSubmissionMessage( - data: CredentialTagData, - progress?: CredentialSubmissionProgress -): string { - const integrations = data - .filter((item) => item.type === 'link' || item.type === 'service_account') - .map((item) => item.provider?.trim()) - .filter((provider): provider is string => Boolean(provider)) - const secrets = data - .filter((item) => item.type === 'secret_input') - .map((item) => item.name?.trim()) - .filter((name): name is string => Boolean(name)) - const payload: CredentialSubmissionPayload = { - integrations: integrations.map((name, index) => ({ - name, - status: - !progress || progress.connectedIntegrationIndexes.has(index) ? 'connected' : 'skipped', - })), - secrets: secrets.map((name, index) => ({ - name, - status: !progress || progress.savedSecretIndexes.has(index) ? 'saved' : 'skipped', - })), - } - return `Credential setup submitted — ${JSON.stringify(payload)}` -} - -export function parseCredentialSubmissionProgress( - data: CredentialTagData, - content: string -): CredentialSubmissionPayload | null { - const legacyIntegrations = data - .filter((item) => item.type === 'link' || item.type === 'service_account') - .map((item) => item.provider?.trim()) - .filter((provider): provider is string => Boolean(provider)) - const legacySecrets = data - .filter((item) => item.type === 'secret_input') - .map((item) => item.name?.trim()) - .filter((name): name is string => Boolean(name)) - const legacyParts = [ - legacyIntegrations.length > 0 ? `integrations: ${legacyIntegrations.join(', ')}` : null, - legacySecrets.length > 0 ? `secrets: ${legacySecrets.join(', ')}` : null, - ].filter((part): part is string => part !== null) - const legacyMessage = `Credential setup complete${legacyParts.length > 0 ? ` — ${legacyParts.join('; ')}` : ''}` - if (content === legacyMessage) { - return { - integrations: legacyIntegrations.map((name) => ({ name, status: 'connected' })), - secrets: legacySecrets.map((name) => ({ name, status: 'saved' })), - } - } - - const prefix = 'Credential setup submitted — ' - if (!content.startsWith(prefix)) return null - - try { - const payload = JSON.parse(content.slice(prefix.length)) as CredentialSubmissionPayload - const expectedIntegrations = data - .filter((item) => item.type === 'link' || item.type === 'service_account') - .map((item) => item.provider?.trim()) - .filter((provider): provider is string => Boolean(provider)) - const expectedSecrets = data - .filter((item) => item.type === 'secret_input') - .map((item) => item.name?.trim()) - .filter((name): name is string => Boolean(name)) - - const valid = - Array.isArray(payload.integrations) && - payload.integrations.length === expectedIntegrations.length && - payload.integrations.every( - (item, index) => - item.name === expectedIntegrations[index] && - (item.status === 'connected' || item.status === 'skipped') - ) && - Array.isArray(payload.secrets) && - payload.secrets.length === expectedSecrets.length && - payload.secrets.every( - (item, index) => - item.name === expectedSecrets[index] && - (item.status === 'saved' || item.status === 'skipped') - ) - return valid ? payload : null - } catch { - return null - } -} - -export function parseCredentialSubmissionMessage( - data: CredentialTagData, - content: string -): boolean { - return parseCredentialSubmissionProgress(data, content) !== null -} - export interface MothershipErrorTagData { message: string code?: string @@ -370,7 +249,7 @@ function isUsageUpgradeTagData(value: unknown): value is UsageUpgradeTagData { ) } -function isCredentialItemData(value: unknown): value is CredentialItemData { +function isCredentialTagData(value: unknown): value is CredentialTagData { if (!isRecord(value)) return false if ( typeof value.type !== 'string' || @@ -422,28 +301,6 @@ function isCredentialItemData(value: unknown): value is CredentialItemData { return typeof value.value === 'string' } -/** - * Parses a `` body and normalizes a singleton object to one row. - * Empty arrays and arrays containing one invalid control reject the whole card. - */ -export function parseCredentialTagBody(body: string): CredentialTagData | null { - try { - const parsed = JSON.parse(body) as unknown - const items = Array.isArray(parsed) ? parsed : [parsed] - return items.length > 0 && items.every(isCredentialItemData) ? items : null - } catch { - return null - } -} - -/** Last complete credential batch, used to pair its Submit turn on reload. */ -export function parseLastCredentialTag(content: string): CredentialTagData | null { - const matches = content.match(/([\s\S]*?)<\/credential>/g) - if (!matches || matches.length === 0) return null - const last = matches[matches.length - 1] - return parseCredentialTagBody(last.slice(''.length, -''.length)) -} - function isMothershipErrorTagData(value: unknown): value is MothershipErrorTagData { if (!isRecord(value)) return false return ( @@ -657,7 +514,7 @@ function parseSpecialTagData( } if (tagName === 'credential') { - const data = parseCredentialTagBody(body) + const data = parseJsonTagBody(body, isCredentialTagData) return data ? { type: 'credential', data } : null } @@ -1440,12 +1297,8 @@ export function parseSpecialTags(content: string, isStreaming: boolean): ParsedS interface SpecialTagsProps { segment: Exclude - /** Stable identity for interaction state owned by this message/tag. */ - interactionId?: string /** Transcript-derived answers for this message's question card (renders the recap). */ questionAnswers?: string[] - /** Transcript-derived status payload for this message's credential card. */ - credentialSubmission?: CredentialSubmissionPayload onOptionSelect?: (id: string) => void onQuestionDismiss?: () => void onWorkspaceResourceSelect?: (resource: WorkspaceResourceRef) => void @@ -1457,9 +1310,7 @@ interface SpecialTagsProps { */ export function SpecialTags({ segment, - interactionId, questionAnswers, - credentialSubmission, onOptionSelect, onQuestionDismiss, onWorkspaceResourceSelect, @@ -1472,14 +1323,7 @@ export function SpecialTags({ case 'usage_upgrade': return case 'credential': - return ( - - ) + return case 'mothership-error': return case 'workspace_resource': @@ -1694,14 +1538,6 @@ function getCredentialIcon(provider: string): React.ComponentType<{ className?: return null } -function getCredentialProviderDisplayName(provider: string): string { - return ( - getServiceConfigByProviderId(provider)?.name ?? - OAUTH_PROVIDERS[provider.toLowerCase()]?.name ?? - provider - ) -} - const LockIcon = (props: { className?: string }) => ( ( * workspace (default) or personal environment variables under `name` and never * flows back through the chat transcript. */ -interface CredentialControlProps { - data: CredentialItemData - controlId?: string - embedded?: boolean - divided?: boolean - secretValue?: string - onSecretValueChange?: (value: string) => void - onSaved?: () => void - onConnected?: () => void -} - -function SecretInputDisplay({ data, divided = false, onSaved }: CredentialControlProps) { +function SecretInputDisplay({ data }: { data: CredentialTagData }) { const { workspaceId } = useParams<{ workspaceId: string }>() const secretName = (data.name ?? '').trim() const scope: SecretInputScope = data.scope === 'personal' ? 'personal' : 'workspace' const [value, setValue] = useState('') - const [isFocused, setIsFocused] = useState(false) const [saved, setSaved] = useState(false) const upsertWorkspace = useUpsertWorkspaceEnvironment() @@ -1780,7 +1604,6 @@ function SecretInputDisplay({ data, divided = false, onSaved }: CredentialContro } setValue('') setSaved(true) - onSaved?.() toast.success(`Saved ${secretName}`) } catch { toast.error(`Couldn't save ${secretName}. Please try again.`) @@ -1794,45 +1617,29 @@ function SecretInputDisplay({ data, divided = false, onSaved }: CredentialContro if (!canManage) return null return ( - setIsFocused(true)} - onBlur={() => setIsFocused(false)} - onChange={(event) => { - if (isFocused) setValue(event.target.value) - }} - onKeyDown={(event) => { - if (event.key === 'Escape') { - event.currentTarget.blur() - return - } - if (event.key === 'Enter' && canSave) { - event.preventDefault() + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() void handleSave() } }} - trailing={ + endAdornment={ - + + {isSaving ? 'Saving…' : 'Save'} @@ -1841,43 +1648,6 @@ function SecretInputDisplay({ data, divided = false, onSaved }: CredentialContro ) } -interface CredentialSecretInputRowProps { - name: string - value: string - divided?: boolean - onChange: (value: string) => void -} - -/** Secret draft field for the unified card; the card's final Submit owns persistence. */ -function CredentialSecretInputRow({ - name, - value, - divided = false, - onChange, -}: CredentialSecretInputRowProps) { - const [isFocused, setIsFocused] = useState(false) - - return ( - setIsFocused(true)} - onBlur={() => setIsFocused(false)} - onChange={(event) => { - const maskedValue = '•'.repeat(value.length) - if (isFocused || event.target.value !== maskedValue) onChange(event.target.value) - }} - onKeyDown={(event) => { - if (event.key === 'Escape') event.currentTarget.blur() - }} - /> - ) -} - /** * Folder icon for the local-folder grant chip (matches the credential chip * icon sizing). @@ -1900,7 +1670,7 @@ const FolderGrantIcon = ({ className }: { className?: string }) => ( * same flow as the Desktop settings folder picker). Renders nothing outside the * desktop app — there is no local filesystem bridge to grant against. */ -function FolderAccessDisplay({ data }: { data: CredentialItemData }) { +function FolderAccessDisplay({ data }: { data: CredentialTagData }) { const [picking, setPicking] = useState(false) const [grantedName, setGrantedName] = useState(null) @@ -1959,7 +1729,7 @@ function FolderAccessDisplay({ data }: { data: CredentialItemData }) { * agent browser back to Sim. Renders nothing outside the desktop app — there * is no agent browser to hand back. */ -function BrowserTakeoverDisplay({ data }: { data: CredentialItemData }) { +function BrowserTakeoverDisplay({ data }: { data: CredentialTagData }) { const { workspaceId } = useParams<{ workspaceId: string }>() const { chatId } = useChatSurface() const [handedBack, setHandedBack] = useState(false) @@ -2000,16 +1770,10 @@ function BrowserTakeoverDisplay({ data }: { data: CredentialItemData }) { * the integrations page — the user stays in the conversation that asked for * the credential, and comes back to it with the credential in hand. */ -function ServiceAccountConnectDisplay({ - data, - embedded = false, - divided = false, - onConnected, -}: CredentialControlProps) { +function ServiceAccountConnectDisplay({ data }: { data: CredentialTagData }) { const { workspaceId } = useParams<{ workspaceId: string }>() const { canEdit } = useUserPermissionsContext() const [open, setOpen] = useState(false) - const [locallyConnected, setLocallyConnected] = useState(false) const match = useMemo( () => (data.provider ? resolveServiceAccountIntegration(data.provider) : null), @@ -2026,7 +1790,6 @@ function ServiceAccountConnectDisplay({ // account in place rather than creating a new one — the modal keeps its id. const reconnectCredentialId = data.credentialId const { data: reconnectCredential } = useWorkspaceCredential(reconnectCredentialId) - const connected = locallyConnected // Creating a credential mutates the workspace — hide it from read-only // members, and honour the provider's own preview gate (custom Slack bots @@ -2037,31 +1800,17 @@ function ServiceAccountConnectDisplay({ const label = reconnectCredentialId ? `Reconnect ${reconnectCredential?.displayName ?? target.serviceName}` : `${target.label} for ${target.serviceName}` - const displayLabel = connected ? `Connected ${target.serviceName}` : label return ( <> {displayLabel} - {connected ? ( - - ) : ( - - )} + {label} + {open && ( @@ -2074,10 +1823,6 @@ function ServiceAccountConnectDisplay({ serviceIcon={target.serviceIcon} credentialId={reconnectCredentialId} credentialDisplayName={reconnectCredential?.displayName ?? undefined} - onCreated={() => { - setLocallyConnected(true) - onConnected?.() - }} /> )} @@ -2085,30 +1830,19 @@ function ServiceAccountConnectDisplay({ ) } -function CredentialLinkDisplay({ - data, - controlId = 'credential-link', - embedded = false, - divided = false, - onConnected, -}: CredentialControlProps) { +function CredentialLinkDisplay({ data }: { data: CredentialTagData }) { const { canEdit } = useUserPermissionsContext() - const integrationName = getCredentialProviderDisplayName(data.provider ?? '') - const { - reconnectCredentialId, - status, - connected, - connectedFromAttempt, - hasExistingCredential, - isReady, - onConnectClick, - } = useOAuthChipConnection({ - connectUrl: data.value, - provider: data.provider, - displayName: integrationName, - controlId, - onConnected, - }) + + // A connect URL carrying a credentialId re-authorizes that existing + // credential in place (reconnect) rather than creating a new one. + const reconnectCredentialId = useMemo(() => { + if (!data.value) return undefined + try { + return new URL(data.value).searchParams.get('credentialId') ?? undefined + } catch { + return undefined + } + }, [data.value]) const { data: reconnectCredential } = useWorkspaceCredential(reconnectCredentialId) // Connecting a credential mutates the workspace — hide it from read-only members. @@ -2117,48 +1851,47 @@ function CredentialLinkDisplay({ // render it as a clickable link when it resolves to a real http(s) URL. if (!data.value || !isSafeHttpUrl(data.value)) return null const Icon = getCredentialIcon(data.provider) ?? LockIcon + const integrationName = + getServiceConfigByProviderId(data.provider)?.name ?? + OAUTH_PROVIDERS[data.provider.toLowerCase()]?.name ?? + data.provider const label = reconnectCredentialId ? `Reconnect ${reconnectCredential?.displayName ?? integrationName}` - : hasExistingCredential - ? `Connect another ${integrationName}` - : `Connect ${integrationName}` - const retryLabel = reconnectCredentialId - ? `Not connected — reconnect ${reconnectCredential?.displayName ?? integrationName}` - : hasExistingCredential - ? `Not connected — connect another ${integrationName}` - : `Not connected — connect ${integrationName}` - const displayLabel = connected - ? `Connected ${integrationName}` - : !isReady - ? `Checking ${integrationName} connections…` - : status === 'pending' - ? `Waiting for ${integrationName} connection…` - : status === 'failed' - ? retryLabel - : label + : `Connect ${integrationName}` + + /** + * Desktop app: OAuth cannot run in an embedded window — not in the app + * window (better-auth binds the flow's state to the initiating browser's + * cookies) and not in the Sim browser panel (its partition isn't signed in + * to Sim, and Google/Microsoft reject embedded user agents outright). So + * the chip hands the whole flow to the system browser via the connect + * handoff, carrying the workspace/credential scope from the authorize URL; + * completion returns through the app's loopback and refreshes credentials. + */ + const handleClick = (event: React.MouseEvent) => { + const bridge = getDesktopBridge() + if (!bridge?.beginOAuthConnect || !data.value) return + event.preventDefault() + const url = new URL(data.value) + const providerId = url.searchParams.get('providerId') ?? data.provider + if (!providerId) return + void bridge.beginOAuthConnect(providerId, { + workspaceId: url.searchParams.get('workspaceId') ?? undefined, + credentialId: url.searchParams.get('credentialId') ?? undefined, + }) + } return ( {createElement(Icon, { className: 'size-[16px] shrink-0' })} - {displayLabel} - {connected ? ( - - ) : ( - - )} + {label} + ) } @@ -2171,7 +1904,7 @@ function CredentialLinkDisplay({ * handoff they are done; the terminal id rides in `value` so the click reaches * the right shell. Renders nothing outside the desktop app. */ -function TerminalHandoffDisplay({ data }: { data: CredentialItemData }) { +function TerminalHandoffDisplay({ data }: { data: CredentialTagData }) { const { workspaceId } = useParams<{ workspaceId: string }>() const { chatId } = useChatSurface() const [handedBack, setHandedBack] = useState(false) @@ -2204,57 +1937,9 @@ function TerminalHandoffDisplay({ data }: { data: CredentialItemData }) { ) } -const CREDENTIAL_CARD_TYPES: ReadonlySet = new Set([ - 'secret_input', - 'link', - 'service_account', - 'sim_key', -]) - -function isCredentialCardItemVisible(item: CredentialItemData, canEdit: boolean): boolean { - if (item.type === 'sim_key') return true - if (item.type === 'secret_input') return item.scope === 'personal' || canEdit - if (item.type === 'link') { - return canEdit && Boolean(item.provider) && Boolean(item.value && isSafeHttpUrl(item.value)) - } - return canEdit -} - -/** Whether a terminal credential tag produces the shared question-style card. */ -export function credentialTagHasVisibleCard(data: CredentialTagData, canEdit: boolean): boolean { - return ( - data.length > 0 && - data.every((item) => CREDENTIAL_CARD_TYPES.has(item.type)) && - data.some((item) => isCredentialCardItemVisible(item, canEdit)) - ) -} - -function CredentialItemDisplay({ - data, - controlId, - embedded = false, - divided = false, - secretValue, - onSecretValueChange, - onSaved, - onConnected, -}: CredentialControlProps) { +export function CredentialDisplay({ data }: { data: CredentialTagData }) { if (data.type === 'secret_input') { - const secretName = data.name?.trim() - if (embedded) { - if (!secretName || !onSecretValueChange) return null - return ( - - ) - } - return ( - - ) + return } if (data.type === 'folder_access') { @@ -2270,26 +1955,11 @@ function CredentialItemDisplay({ } if (data.type === 'link') { - return ( - - ) + return } if (data.type === 'service_account') { - return ( - - ) + return } if (data.type === 'sim_key') { @@ -2302,253 +1972,6 @@ function CredentialItemDisplay({ return null } -/** - * Credential input and OAuth controls use the same InteractionCard primitives - * as QuestionDisplay. Integrations come first and secrets follow in one card - * with one Submit; legacy/system actions retain their standalone presentation. - */ -function CredentialInputCard({ - data, - interactionId, - submitted, - onContinue, -}: { - data: CredentialTagData - interactionId?: string - submitted?: CredentialSubmissionPayload - onContinue?: (message: string) => void -}) { - const { workspaceId } = useParams<{ workspaceId: string }>() - const { canEdit } = useUserPermissionsContext() - const upsertWorkspace = useUpsertWorkspaceEnvironment() - const savePersonal = useSavePersonalEnvironment() - const personalQuery = usePersonalEnvironment() - const [secretDrafts, setSecretDrafts] = useState>({}) - const [savedSecretRows, setSavedSecretRows] = useState>(() => new Set()) - const [connectedIntegrationRows, setConnectedIntegrationRows] = useState>( - () => new Set() - ) - const [locallySubmitted, setLocallySubmitted] = useState(false) - const [isSubmitting, setIsSubmitting] = useState(false) - let integrationIndex = 0 - let secretIndex = 0 - const indexedRows = data.map((item, dataIndex) => ({ - item, - dataIndex, - integrationIndex: - item.type === 'link' || item.type === 'service_account' ? integrationIndex++ : undefined, - secretIndex: item.type === 'secret_input' ? secretIndex++ : undefined, - })) - const visibleRows = indexedRows.filter(({ item }) => isCredentialCardItemVisible(item, canEdit)) - if (visibleRows.length === 0) return null - - const integrationRows = visibleRows.filter( - ({ item }) => item.type === 'link' || item.type === 'service_account' - ) - const secretRows = visibleRows.filter( - ({ item }) => item.type === 'secret_input' || item.type === 'sim_key' - ) - const requiredSecretRows = secretRows.filter(({ item }) => item.type === 'secret_input') - const title = - integrationRows.length > 0 && secretRows.length > 0 - ? 'Set up credentials' - : integrationRows.length > 0 - ? 'Connect integrations' - : requiredSecretRows.length > 0 - ? 'Add secrets' - : 'API key' - const rows = [ - ...integrationRows.map(({ item, dataIndex, integrationIndex }, index) => ( - 0} - onConnected={() => - setConnectedIntegrationRows((current) => { - if (integrationIndex === undefined || current.has(integrationIndex)) return current - const next = new Set(current) - next.add(integrationIndex) - return next - }) - } - /> - )), - ...secretRows.map(({ item, dataIndex, secretIndex }, index) => { - return ( - 0 || index > 0} - secretValue={ - item.type === 'secret_input' ? (secretDrafts[secretIndex ?? -1] ?? '') : undefined - } - onSecretValueChange={ - item.type === 'secret_input' && secretIndex !== undefined - ? (value) => - setSecretDrafts((current) => ({ - ...current, - [secretIndex]: value, - })) - : undefined - } - /> - ) - }), - ] - - const submitCredentialSetup = async (): Promise => { - if (!onContinue) return false - - const workspaceVariables: Record = {} - const personalVariables: Record = {} - const enteredSecretIndexes: number[] = [] - - for (const { item, secretIndex } of requiredSecretRows) { - if (secretIndex === undefined) continue - const name = item.name?.trim() - const value = secretDrafts[secretIndex] ?? '' - if (!name || value.trim().length === 0) continue - const target = item.scope === 'personal' ? personalVariables : workspaceVariables - target[name] = value - enteredSecretIndexes.push(secretIndex) - } - - try { - const saves: Promise[] = [] - if (Object.keys(workspaceVariables).length > 0) { - saves.push(upsertWorkspace.mutateAsync({ workspaceId, variables: workspaceVariables })) - } - if (Object.keys(personalVariables).length > 0) { - saves.push( - (async () => { - const { data: latest } = await personalQuery.refetch() - const merged: Record = {} - for (const [key, entry] of Object.entries(latest ?? personalQuery.data ?? {})) { - merged[key] = entry.value - } - Object.assign(merged, personalVariables) - await savePersonal.mutateAsync({ variables: merged }) - })() - ) - } - await Promise.all(saves) - } catch { - toast.error(`Couldn't save secrets. Please try again.`) - return false - } - - const nextSavedSecretRows = new Set(savedSecretRows) - for (const index of enteredSecretIndexes) nextSavedSecretRows.add(index) - setSavedSecretRows(nextSavedSecretRows) - - onContinue( - formatCredentialSubmissionMessage(data, { - connectedIntegrationIndexes: connectedIntegrationRows, - savedSecretIndexes: nextSavedSecretRows, - }) - ) - return true - } - - const needsContinuation = integrationRows.length > 0 || requiredSecretRows.length > 0 - const credentialSummary = [ - ...integrationRows.map(({ item, integrationIndex }) => ({ - label: getCredentialProviderDisplayName(item.provider ?? 'Integration'), - status: ( - submitted - ? submitted.integrations[integrationIndex ?? -1]?.status === 'connected' - : connectedIntegrationRows.has(integrationIndex ?? -1) - ) - ? ('Connected' as const) - : ('Skipped' as const), - })), - ...secretRows.map(({ item, secretIndex }) => { - if (item.type === 'sim_key') { - return { label: item.name ?? 'Sim API key', status: 'Added' as const } - } - const saved = submitted - ? submitted.secrets[secretIndex ?? -1]?.status === 'saved' - : savedSecretRows.has(secretIndex ?? -1) - return { - label: item.name ?? 'Secret', - status: saved ? ('Added' as const) : ('Skipped' as const), - } - }), - ] - - if (submitted || locallySubmitted) { - return ( - ({ label: item.label, values: [item.status] }))} - /> - ) - } - - const handleSubmit = async () => { - if (isSubmitting) return - setIsSubmitting(true) - try { - if (await submitCredentialSetup()) setLocallySubmitted(true) - } finally { - setIsSubmitting(false) - } - } - - return ( - -
- {rows} - {needsContinuation && onContinue && ( - void handleSubmit()} - /> - )} -
-
- ) -} - -export function CredentialDisplay({ - data, - interactionId, - submitted, - onContinue, -}: { - data: CredentialTagData - interactionId?: string - submitted?: CredentialSubmissionPayload - onContinue?: (message: string) => void -}) { - const usesCredentialCard = data.every((item) => CREDENTIAL_CARD_TYPES.has(item.type)) - - if (usesCredentialCard) { - return ( - - ) - } - - return ( -
1 && 'space-y-3')}> - {data.map((item, index) => ( - - ))} -
- ) -} - function MothershipErrorDisplay({ data }: { data: MothershipErrorTagData }) { const detail = data.code ? `${data.message} (${data.code})` : data.message diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/use-oauth-chip-connection.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/use-oauth-chip-connection.ts deleted file mode 100644 index 74c4b95ebb4..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/use-oauth-chip-connection.ts +++ /dev/null @@ -1,357 +0,0 @@ -'use client' - -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { useParams } from 'next/navigation' -import { - addOAuthChatAttemptToAuthorizeUrl, - clearActiveDesktopOAuthChatAttempt, - createOAuthChatAttempt, - getOAuthCredentialBaseline, - hasOAuthCredentialChanged, - hasOAuthCredentialForTarget, - OAUTH_CHAT_ATTEMPT_EVENT, - OAUTH_CHAT_ATTEMPT_PARAM, - type OAuthChatAttempt, - type OAuthChatAttemptStatus, - readLatestOAuthChatAttempt, - readOAuthChatAttempt, - setActiveDesktopOAuthChatAttempt, - setOAuthChatAttemptStatus, -} from '@/lib/credentials/oauth-chat-attempt' -import { getDesktopBridge } from '@/lib/desktop' -import type { OAuthProvider } from '@/lib/oauth/types' -import { parseProvider } from '@/lib/oauth/utils' -import { useWorkspaceCredentials } from '@/hooks/queries/credentials' - -interface UseOAuthChipConnectionParams { - /** Authorize URL streamed by the agent; provider and reconnect scope are read from it. */ - connectUrl?: string - /** Provider slug from the tag, used when the URL carries none. */ - provider?: string - /** Human-facing integration name, stored on the attempt for its toasts. */ - displayName: string - /** Identifies the row within the message, so sibling chips stay independent. */ - controlId: string - onConnected?: () => void -} - -export interface OAuthChipConnection { - providerId: string - /** Present when the URL re-authorizes an existing credential in place. */ - reconnectCredentialId?: string - status: OAuthChatAttemptStatus | null - /** True when the row should read as connected, from any signal. */ - connected: boolean - /** - * True only when *this row's* attempt completed. Workspace-wide observation - * cannot be attributed to one row, so this — not {@link connected} — is what - * may lock the control. - */ - connectedFromAttempt: boolean - /** The workspace already holds a credential this row would connect. */ - hasExistingCredential: boolean - /** The credential list has loaded, so a click can capture a real baseline. */ - isReady: boolean - onConnectClick: (event: React.MouseEvent) => void -} - -/** - * Tracks whether the credential a chat credential chip offers is connected. - * - * Connection is never inferred from a credential merely existing — the row - * records an *attempt* when clicked, capturing a baseline of the credentials - * that already matched, and the OAuth return verifies against that baseline. - * The attempt is keyed by workspace, provider, reconnect target, and - * `controlId`, so it survives a reload and cannot be claimed by a sibling row - * for the same provider. - */ -export function useOAuthChipConnection({ - connectUrl, - provider, - displayName, - controlId, - onConnected, -}: UseOAuthChipConnectionParams): OAuthChipConnection { - const { workspaceId } = useParams<{ workspaceId: string }>() - - // A connect URL carrying a credentialId re-authorizes that existing - // credential in place (reconnect) rather than creating a new one. - const reconnectCredentialId = useMemo(() => { - if (!connectUrl) return undefined - try { - return new URL(connectUrl).searchParams.get('credentialId') ?? undefined - } catch { - return undefined - } - }, [connectUrl]) - - const providerId = useMemo(() => { - if (!connectUrl) return provider ?? '' - try { - const url = new URL(connectUrl) - if (url.pathname === '/api/auth/instagram/authorize') return 'instagram' - if (url.pathname === '/api/auth/shopify/authorize') return 'shopify' - if (url.pathname === '/api/auth/trello/authorize') return 'trello' - return url.searchParams.get('providerId') ?? provider ?? '' - } catch { - return provider ?? '' - } - }, [connectUrl, provider]) - - const baseProviderId = parseProvider(providerId as OAuthProvider).baseProvider - const { - data: workspaceOAuthCredentials = [], - isFetched, - refetch: refetchWorkspaceOAuthCredentials, - } = useWorkspaceCredentials({ - workspaceId, - type: 'oauth', - enabled: Boolean(providerId), - }) - - const [activeAttemptId, setActiveAttemptId] = useState(() => { - if (typeof window === 'undefined') return undefined - return new URL(window.location.href).searchParams.get(OAUTH_CHAT_ATTEMPT_PARAM) ?? undefined - }) - const [connectionStatus, setConnectionStatus] = useState(null) - const [connectedFromWorkspaceChange, setConnectedFromWorkspaceChange] = useState(false) - const onConnectedRef = useRef(onConnected) - const oauthWindowWasAwayRef = useRef(false) - const workspaceCredentialBaselineRef = useRef<{ - scope: string - baseline: ReturnType - } | null>(null) - - const credentialTarget = useMemo( - () => ({ providerId, baseProviderId, credentialId: reconnectCredentialId }), - [baseProviderId, providerId, reconnectCredentialId] - ) - const credentialScope = `${workspaceId}:${providerId}:${reconnectCredentialId ?? ''}` - const hasExistingCredential = hasOAuthCredentialForTarget( - credentialTarget, - workspaceOAuthCredentials - ) - const connectedFromAttempt = connectionStatus === 'connected' - const connected = connectedFromAttempt || connectedFromWorkspaceChange - - useEffect(() => { - onConnectedRef.current = onConnected - }, [onConnected]) - - /** - * A credential for this row can also appear without the row launching it — - * the integrations page in another tab, or a desktop flow that never comes - * back through the return URL. Diffing the workspace list against the - * baseline captured for this scope surfaces that. - * - * This signal is workspace-wide, so it cannot be attributed to one row: - * sibling chips for the same provider all see the same change. It therefore - * only ever *shows* the row as satisfied — {@link connectedFromAttempt} is - * what locks it, so a second same-provider row stays clickable. - */ - useEffect(() => { - if (!isFetched) return - const storedBaseline = workspaceCredentialBaselineRef.current - if (!storedBaseline || storedBaseline.scope !== credentialScope) { - workspaceCredentialBaselineRef.current = { - scope: credentialScope, - baseline: getOAuthCredentialBaseline(credentialTarget, workspaceOAuthCredentials), - } - setConnectedFromWorkspaceChange(false) - return - } - // Workspace-wide observation can reliably identify a newly-added account - // by id. It cannot prove that an existing credential was reauthorized (an - // unrelated metadata edit can also update it), so reconnect completion is - // accepted only from the OAuth return attempt verifier. - if (reconnectCredentialId) { - setConnectedFromWorkspaceChange(false) - return - } - // Recomputed rather than latched, so a credential that goes away (deleted, - // access revoked) takes the row's connected state with it. - setConnectedFromWorkspaceChange( - hasOAuthCredentialChanged( - { ...credentialTarget, ...storedBaseline.baseline }, - workspaceOAuthCredentials - ) - ) - }, [ - credentialScope, - credentialTarget, - isFetched, - reconnectCredentialId, - workspaceOAuthCredentials, - ]) - - /** - * This row's attempt: the one named by the return URL when we came back from - * the provider, else the last one stored for this exact row. The stored - * lookup is what survives a reload — and what covers the transcript - * rendering only after the return hook has already stripped the URL param. - * Both are scoped to the row, so a sibling chip for the same provider can - * never claim this one's result. - */ - const readRowAttempt = useCallback((): OAuthChatAttempt | null => { - const active = activeAttemptId ? readOAuthChatAttempt(activeAttemptId) : null - if ( - active && - active.workspaceId === workspaceId && - active.providerId === providerId && - active.credentialId === reconnectCredentialId && - active.controlId === controlId - ) { - return active - } - return readLatestOAuthChatAttempt({ - workspaceId, - providerId, - controlId, - credentialId: reconnectCredentialId, - }) - }, [activeAttemptId, controlId, providerId, reconnectCredentialId, workspaceId]) - - useEffect(() => { - const syncStatus = () => setConnectionStatus(readRowAttempt()?.status ?? null) - window.addEventListener(OAUTH_CHAT_ATTEMPT_EVENT, syncStatus) - window.addEventListener('storage', syncStatus) - syncStatus() - return () => { - window.removeEventListener(OAUTH_CHAT_ATTEMPT_EVENT, syncStatus) - window.removeEventListener('storage', syncStatus) - } - }, [readRowAttempt]) - - useEffect(() => { - const markAway = () => { - oauthWindowWasAwayRef.current = true - } - const verifyAfterReturn = async () => { - if (!oauthWindowWasAwayRef.current || document.visibilityState !== 'visible') return - oauthWindowWasAwayRef.current = false - const attempt = readRowAttempt() - const result = await refetchWorkspaceOAuthCredentials() - const credentials = result.data ?? [] - - // Refetching on every return closes the other-tab gap even when this row - // did not launch the connection. Query state normally drives the effect - // above; updating here as well makes the result immediate and deterministic. - const storedBaseline = workspaceCredentialBaselineRef.current - if (!reconnectCredentialId && storedBaseline?.scope === credentialScope) { - setConnectedFromWorkspaceChange( - hasOAuthCredentialChanged( - { ...credentialTarget, ...storedBaseline.baseline }, - credentials - ) - ) - } - - if (!attempt || attempt.status !== 'pending') return - // A reconnect is verified by the callback path, which has proof that the - // OAuth flow returned. A plain focus event cannot distinguish it from an - // unrelated edit to the same credential. - const attemptConnected = reconnectCredentialId - ? false - : hasOAuthCredentialChanged(attempt, credentials) - setOAuthChatAttemptStatus(attempt.id, attemptConnected ? 'connected' : 'failed') - } - const handleVisibilityChange = () => { - if (document.visibilityState === 'hidden') markAway() - else void verifyAfterReturn() - } - - window.addEventListener('blur', markAway) - window.addEventListener('focus', verifyAfterReturn) - document.addEventListener('visibilitychange', handleVisibilityChange) - return () => { - window.removeEventListener('blur', markAway) - window.removeEventListener('focus', verifyAfterReturn) - document.removeEventListener('visibilitychange', handleVisibilityChange) - } - }, [ - credentialScope, - credentialTarget, - readRowAttempt, - reconnectCredentialId, - refetchWorkspaceOAuthCredentials, - ]) - - useEffect(() => { - if (connected) onConnectedRef.current?.() - }, [connected]) - - /** - * Desktop app: OAuth cannot run in an embedded window — not in the app - * window (better-auth binds the flow's state to the initiating browser's - * cookies) and not in the Sim browser panel (its partition isn't signed in - * to Sim, and Google/Microsoft reject embedded user agents outright). So - * the chip hands the whole flow to the system browser via the connect - * handoff, carrying the workspace/credential scope from the authorize URL; - * completion returns through the app's loopback and refreshes credentials. - */ - const onConnectClick = useCallback( - (event: React.MouseEvent) => { - if (!connectUrl || !isFetched || connectedFromAttempt) { - event.preventDefault() - return - } - const attempt = createOAuthChatAttempt({ - workspaceId, - providerId, - baseProviderId, - displayName, - controlId, - credentialId: reconnectCredentialId, - ...getOAuthCredentialBaseline(credentialTarget, workspaceOAuthCredentials), - }) - setActiveAttemptId(attempt.id) - setConnectionStatus('pending') - - const bridge = getDesktopBridge() - if (bridge?.beginOAuthConnect) { - event.preventDefault() - const url = new URL(connectUrl) - setActiveDesktopOAuthChatAttempt(attempt.id) - void bridge - .beginOAuthConnect(providerId, { - workspaceId: url.searchParams.get('workspaceId') ?? workspaceId, - credentialId: url.searchParams.get('credentialId') ?? undefined, - chatAttemptId: attempt.id, - }) - .then((opened) => { - if (!opened) { - clearActiveDesktopOAuthChatAttempt(attempt.id) - setOAuthChatAttemptStatus(attempt.id, 'failed') - } - }) - return - } - - event.currentTarget.href = addOAuthChatAttemptToAuthorizeUrl(connectUrl, attempt.id) - }, - [ - baseProviderId, - connectUrl, - connectedFromAttempt, - controlId, - credentialTarget, - displayName, - isFetched, - providerId, - reconnectCredentialId, - workspaceId, - workspaceOAuthCredentials, - ] - ) - - return { - providerId, - reconnectCredentialId, - status: connectionStatus, - connected, - connectedFromAttempt, - hasExistingCredential, - isReady: isFetched, - onConnectClick, - } -} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index a2462865053..f0293c42872 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -21,7 +21,6 @@ import { humanizeToolName, } from '@/lib/copilot/tools/tool-display' import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context' -import type { CredentialSubmissionPayload } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' import type { ContentBlock, OptionItem, ToolCallData } from '../../types' import { SUBAGENT_LABELS } from '../../types' import type { AgentGroupItem } from './components' @@ -790,7 +789,6 @@ export function deriveThinkingLabel(blocks: ContentBlock[]): string | null { interface MessageContentProps { blocks: ContentBlock[] fallbackContent: string - messageId?: string isStreaming: boolean /** * True for the last message in the transcript. The last turn keeps a @@ -800,8 +798,6 @@ interface MessageContentProps { isLast?: boolean /** Transcript-derived answers for this message's question card (renders the recap). */ questionAnswers?: string[] - /** Transcript-derived status payload for this message's credential card. */ - credentialSubmission?: CredentialSubmissionPayload onOptionSelect?: (id: string) => void onQuestionDismiss?: () => void onPhaseChange?: (phase: MessagePhase) => void @@ -818,11 +814,9 @@ interface MessageContentProps { function MessageContentInner({ blocks, fallbackContent, - messageId, isStreaming = false, isLast = false, questionAnswers, - credentialSubmission, onOptionSelect, onQuestionDismiss, onPhaseChange, @@ -918,14 +912,12 @@ function MessageContentInner({ { shouldShowAssistantMessageActions({ phase: 'settled', hasContent: true, - endsWithInteraction: true, + endsWithQuestion: true, questionDismissed: true, }) ).toBe(true) @@ -21,7 +21,7 @@ describe('shouldShowAssistantMessageActions', () => { shouldShowAssistantMessageActions({ phase: 'settled', hasContent: true, - endsWithInteraction: true, + endsWithQuestion: true, questionDismissed: false, }) ).toBe(false) @@ -32,7 +32,7 @@ describe('shouldShowAssistantMessageActions', () => { shouldShowAssistantMessageActions({ phase: 'streaming', hasContent: true, - endsWithInteraction: true, + endsWithQuestion: true, questionDismissed: true, }) ).toBe(false) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/message-actions-visibility.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/message-actions-visibility.ts index 76f11127cc6..0c84c1481b0 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/message-actions-visibility.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/message-actions-visibility.ts @@ -3,20 +3,20 @@ import type { MessagePhase } from '@/app/workspace/[workspaceId]/home/components interface AssistantMessageActionsVisibility { phase: MessagePhase hasContent: boolean - endsWithInteraction: boolean + endsWithQuestion: boolean questionDismissed: boolean } /** - * Terminal question and credential cards replace the normal message actions - * while active or answered. Dismissing a question restores those actions for - * the settled assistant message underneath it. + * Question cards replace the normal message actions while they are active or + * answered. Dismissing an active card restores those actions for the settled + * assistant message underneath it. */ export function shouldShowAssistantMessageActions({ phase, hasContent, - endsWithInteraction, + endsWithQuestion, questionDismissed, }: AssistantMessageActionsVisibility): boolean { - return phase === 'settled' && hasContent && (!endsWithInteraction || questionDismissed) + return phase === 'settled' && hasContent && (!endsWithQuestion || questionDismissed) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx index 8a1d82acd7e..4ed0236df5a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx @@ -22,13 +22,7 @@ import { type MessagePhase, } from '@/app/workspace/[workspaceId]/home/components/message-content' import { parseQuestionAnswerMessage } from '@/app/workspace/[workspaceId]/home/components/message-content/components/question' -import { - type CredentialSubmissionPayload, - credentialTagHasVisibleCard, - parseCredentialSubmissionProgress, - parseLastCredentialTag, - parseLastQuestionTag, -} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' +import { parseLastQuestionTag } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' import { QueuedMessages } from '@/app/workspace/[workspaceId]/home/components/queued-messages' import { UserInput, @@ -44,7 +38,6 @@ import type { QueuedMessage, WorkspaceResourceRef, } from '@/app/workspace/[workspaceId]/home/types' -import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useAutoScroll } from '@/hooks/use-auto-scroll' import type { ChatContext } from '@/stores/panel' import { MothershipChatSkeleton } from './components/mothership-chat-skeleton' @@ -189,8 +182,6 @@ interface AssistantMessageRowProps { precedingUserContent?: string /** Transcript-derived answers for this message's question card (renders the recap). */ questionAnswers?: string[] - /** Transcript-derived status payload for this message's credential card. */ - credentialSubmission?: CredentialSubmissionPayload rowClassName: string onOptionSelect?: (id: string) => void onAnimatingChange?: (animating: boolean) => void @@ -202,12 +193,10 @@ const AssistantMessageRow = memo(function AssistantMessageRow({ isLast, precedingUserContent, questionAnswers, - credentialSubmission, rowClassName, onOptionSelect, onAnimatingChange, }: AssistantMessageRowProps) { - const { canEdit } = useUserPermissionsContext() const blocks = message.contentBlocks ?? EMPTY_BLOCKS const hasAnyBlocks = blocks.length > 0 const trimmedContent = message.content?.trim() ?? '' @@ -226,15 +215,10 @@ const AssistantMessageRow = memo(function AssistantMessageRow({ return null } - // A trailing question or credential card replaces the copy/thumbs row while - // active or answered. A question's raw tag is its dismissal identity so a - // later question added to the same turn cannot inherit an earlier dismissal. + // A trailing question card replaces the copy/thumbs row while active or + // answered. Its raw tag is the dismissal identity so a later question added + // to the same turn cannot inherit an earlier card's dismissed state. const endsWithQuestion = trimmedContent.endsWith('') - const endsWithCredential = trimmedContent.endsWith('
') - const trailingCredentials = endsWithCredential ? parseLastCredentialTag(trimmedContent) : null - const showsCredentialCard = trailingCredentials - ? credentialTagHasVisibleCard(trailingCredentials, canEdit) - : false const questionTag = endsWithQuestion ? trimmedContent.slice(trimmedContent.lastIndexOf('')) : null @@ -248,27 +232,25 @@ const AssistantMessageRow = memo(function AssistantMessageRow({ const actionsEligible = shouldShowAssistantMessageActions({ phase: 'settled', hasContent: Boolean(message.content) || hasAnyBlocks, - endsWithInteraction: endsWithQuestion || showsCredentialCard, + endsWithQuestion, questionDismissed, }) - // A visible interaction card (active or answered recap) sits 12px below the + // A visible question card (active or answered recap) sits 12px below the // preceding prose (chat-content's `space-y-3`). The row's default `pb-6` // would leave 24px underneath — asymmetric. Shrink the trailing gap to match // so the card breathes equally top and bottom. Dismissed cards fall back to // the normal message rhythm (they render the standard actions row instead). - const showsInteractionCard = (endsWithQuestion && !questionDismissed) || showsCredentialCard + const showsQuestionCard = endsWithQuestion && !questionDismissed return ( -
+