From 817ca0d0d7fa44a4b642fdf618507c35b1cf6535 Mon Sep 17 00:00:00 2001 From: Siddharth Ganesan Date: Fri, 7 Aug 2026 11:51:33 -0700 Subject: [PATCH 1/2] credentials continue --- .../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 | 352 +++++++++ .../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 | 134 ++++ .../sim/lib/credentials/oauth-chat-attempt.ts | 235 ++++++ 33 files changed, 2688 insertions(+), 308 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/interaction-card.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/use-oauth-chip-connection.ts create mode 100644 apps/sim/lib/credentials/oauth-chat-attempt.test.ts create mode 100644 apps/sim/lib/credentials/oauth-chat-attempt.ts diff --git a/apps/sim/app/api/auth/trello/authorize/route.ts b/apps/sim/app/api/auth/trello/authorize/route.ts index fd6aa17d0f3..b69c6caed2e 100644 --- a/apps/sim/app/api/auth/trello/authorize/route.ts +++ b/apps/sim/app/api/auth/trello/authorize/route.ts @@ -6,6 +6,7 @@ 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' @@ -14,6 +15,7 @@ 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 @@ -26,6 +28,7 @@ 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 @@ -57,6 +60,20 @@ 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 9ef74c2081e..2d45e1dca3b 100644 --- a/apps/sim/app/api/auth/trello/callback/route.ts +++ b/apps/sim/app/api/auth/trello/callback/route.ts @@ -3,6 +3,7 @@ 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') @@ -10,6 +11,8 @@ 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) => { @@ -17,9 +20,15 @@ function escapeForJsString(value: string): string { }) } -function renderErrorPage(baseUrl: string, redirectQuery: 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) { return new NextResponse( - `Trello connection failed

Trello connection failed. Redirecting...

`, + `Trello connection failed

Trello connection failed. Redirecting...

`, { status: 400, headers: { @@ -35,6 +44,11 @@ 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 @@ -43,12 +57,20 @@ export const GET = withRouteHandler(async (request: NextRequest) => { hasQueryState: Boolean(queryState), hasCookieState: Boolean(cookieState), }) - const response = renderErrorPage(baseUrl, 'error=trello_state_mismatch') - response.cookies.delete({ name: TRELLO_STATE_COOKIE, path: '/api/auth/trello' }) + 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 }) 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( ` @@ -142,7 +164,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (data.success) { statusEl.textContent = 'Success! Redirecting...'; setTimeout(function() { - window.location.href = '${baseUrl}/workspace?trello_connected=true'; + window.location.href = '${successReturnUrl}'; }, 500); } else { throw new Error(data.error || 'Failed to save connection'); @@ -153,7 +175,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { errorEl.style.display = 'block'; statusEl.textContent = 'Connection failed'; setTimeout(function() { - window.location.href = '${baseUrl}/workspace?error=trello_failed'; + window.location.href = '${storeFailureReturnUrl}'; }, 3000); }); @@ -162,7 +184,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { errorEl.style.display = 'block'; statusEl.textContent = 'Connection failed'; setTimeout(function() { - window.location.href = '${baseUrl}/workspace?error=trello_auth_failed'; + window.location.href = '${authFailureReturnUrl}'; }, 3000); } })(); diff --git a/apps/sim/app/api/auth/trello/store/route.ts b/apps/sim/app/api/auth/trello/store/route.ts index 156ed9a65d6..a9ca15b7ee4 100644 --- a/apps/sim/app/api/auth/trello/store/route.ts +++ b/apps/sim/app/api/auth/trello/store/route.ts @@ -17,10 +17,12 @@ 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 4e07b66f747..71d675e02d5 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,11 +206,13 @@ export function ToolCallItem({ return (
) @@ -220,7 +222,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 1fd9961a504..def9e6f7839 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,6 +17,7 @@ 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' @@ -394,9 +395,12 @@ 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 @@ -412,8 +416,10 @@ interface ChatContentProps { function ChatContentInner({ content, + messageId, isStreaming = false, questionAnswers, + credentialSubmission, onOptionSelect, onQuestionDismiss, onWorkspaceResourceSelect, @@ -636,7 +642,9 @@ 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 new file mode 100644 index 00000000000..3ad8133f722 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/interaction-card.tsx @@ -0,0 +1,129 @@ +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 c22bc12ac5b..5265b9f3668 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,4 +210,86 @@ 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 5756f57c72f..8d7df45d5d1 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,6 +12,13 @@ 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' /** @@ -46,9 +53,6 @@ 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' @@ -126,9 +130,6 @@ 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 = @@ -140,18 +141,12 @@ export function QuestionDisplay({ const recapAnswers = transcriptAnswers ?? localAnswers if (data.length > 0 && recapAnswers) { return ( -
- {data.map((question, i) => ( -
-

{question.prompt}

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

{answer}

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

- {question.prompt} -

+ {data.length > 1 && (
@@ -309,7 +311,8 @@ export function QuestionDisplay({ )}
-
+ } + >
{options.map((option, i) => { const isSelected = selected.includes(option.label) @@ -322,7 +325,7 @@ export function QuestionDisplay({ isMulti ? handleMultiToggle(option.label) : handleSingleSelect(option.label) } className={cn( - OPTION_ROW_CLASSES, + INTERACTION_CARD_ROW_CLASSES, disabled ? 'cursor-not-allowed' : 'hover-hover:bg-[var(--surface-5)]', i > 0 && 'border-t', isSelected && 'bg-[var(--surface-5)]' @@ -336,105 +339,92 @@ export function QuestionDisplay({ ) })} -
0 && 'border-t')}> - {isMulti && ( -
+ 0} + leading={ + isMulti ? ( +
+ +
+ ) : undefined + } + trailing={ + !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 && ( - + onClick={submitCurrentStep} + leading={
} + /> )}
-
+ ) } 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 dff2706b01d..030af87c6dd 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,5 +1,7 @@ export type { ContentSegment, + CredentialItemData, + CredentialSubmissionPayload, CredentialTagData, CredentialTagType, FileTagData, @@ -19,9 +21,15 @@ 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 bdd324efa15..e28bf310594 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,10 +17,17 @@ 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, @@ -35,6 +42,88 @@ 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 @@ -821,7 +910,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' }], }) }) @@ -830,7 +919,7 @@ describe('service_account credential tag', () => { const { segments } = parseSpecialTags(`${body}`, false) const credential = segments.find((segment) => segment.type === 'credential') - expect((credential as { data: { value?: string } }).data.value).toBeUndefined() + expect((credential as { data: Array<{ value?: string }> }).data[0].value).toBeUndefined() }) it('suppresses the tag while it is still streaming', () => { @@ -876,7 +965,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 8791ddd3d05..69cb9c3bda9 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,9 +5,22 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockUseUserPermissionsContext, mockUseWorkspaceCredential } = vi.hoisted(() => ({ +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), mockUseUserPermissionsContext: vi.fn(), mockUseWorkspaceCredential: vi.fn(), + mockUseWorkspaceCredentials: vi.fn(), })) vi.mock('@/app/workspace/[workspaceId]/providers/workspace-permissions-provider', () => ({ @@ -20,9 +33,29 @@ vi.mock('next/navigation', () => ({ vi.mock('@/hooks/queries/credentials', () => ({ useWorkspaceCredential: mockUseWorkspaceCredential, + useWorkspaceCredentials: mockUseWorkspaceCredentials, })) -import type { CredentialTagData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags' +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 { parseSpecialTags, SpecialTags, @@ -32,21 +65,34 @@ 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: CredentialTagData): { container: HTMLDivElement; root: Root } { +function renderCredentialLink(data: CredentialItemData | CredentialItemData[]): { + 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', () => { @@ -57,6 +103,7 @@ describe('CredentialDisplay link tag', () => { }) expect(container.querySelector('a')).toBeNull() + expect(container.textContent).toBe('') act(() => root.unmount()) }) @@ -68,6 +115,7 @@ describe('CredentialDisplay link tag', () => { }) expect(container.querySelector('a')).toBeNull() + expect(container.textContent).toBe('') act(() => root.unmount()) }) @@ -83,6 +131,289 @@ 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()) }) @@ -95,6 +426,7 @@ describe('CredentialDisplay link tag', () => { }) expect(container.querySelector('a')).toBeNull() + expect(container.textContent).toBe('') act(() => root.unmount()) }) @@ -125,13 +457,162 @@ 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', () => { @@ -142,8 +623,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.type).toBe('sim_key') - expect(credential.data.value).toBeUndefined() + expect(credential.data[0].type).toBe('sim_key') + expect(credential.data[0].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 fbfdf7a8fdd..4805c87295c 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,12 +3,11 @@ import { createElement, lazy, Suspense, useMemo, useState } from 'react' import { ArrowRight, - Button, + Check, ChevronDown, cn, Expandable, ExpandableContent, - SecretInput, SecretReveal, SquareArrowUpRight, Tooltip, @@ -34,7 +33,15 @@ 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, @@ -110,7 +117,7 @@ export const SECRET_INPUT_SCOPES = ['personal', 'workspace'] as const export type SecretInputScope = (typeof SECRET_INPUT_SCOPES)[number] -export interface CredentialTagData { +export interface CredentialItemData { value?: string type: CredentialTagType provider?: string @@ -129,6 +136,120 @@ export interface CredentialTagData { 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 @@ -249,7 +370,7 @@ function isUsageUpgradeTagData(value: unknown): value is UsageUpgradeTagData { ) } -function isCredentialTagData(value: unknown): value is CredentialTagData { +function isCredentialItemData(value: unknown): value is CredentialItemData { if (!isRecord(value)) return false if ( typeof value.type !== 'string' || @@ -301,6 +422,28 @@ function isCredentialTagData(value: unknown): value is CredentialTagData { 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 ( @@ -514,7 +657,7 @@ function parseSpecialTagData( } if (tagName === 'credential') { - const data = parseJsonTagBody(body, isCredentialTagData) + const data = parseCredentialTagBody(body) return data ? { type: 'credential', data } : null } @@ -1297,8 +1440,12 @@ 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 @@ -1310,7 +1457,9 @@ interface SpecialTagsProps { */ export function SpecialTags({ segment, + interactionId, questionAnswers, + credentialSubmission, onOptionSelect, onQuestionDismiss, onWorkspaceResourceSelect, @@ -1323,7 +1472,14 @@ export function SpecialTags({ case 'usage_upgrade': return case 'credential': - return + return ( + + ) case 'mothership-error': return case 'workspace_resource': @@ -1538,6 +1694,14 @@ 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. */ -function SecretInputDisplay({ data }: { data: CredentialTagData }) { +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) { 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() @@ -1604,6 +1780,7 @@ function SecretInputDisplay({ data }: { data: CredentialTagData }) { } setValue('') setSaved(true) + onSaved?.() toast.success(`Saved ${secretName}`) } catch { toast.error(`Couldn't save ${secretName}. Please try again.`) @@ -1617,29 +1794,45 @@ function SecretInputDisplay({ data }: { data: CredentialTagData }) { if (!canManage) return null return ( - { - if (e.key === 'Enter') { - e.preventDefault() + autoComplete='off' + aria-label={secretName} + onFocus={() => 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() void handleSave() } }} - endAdornment={ + trailing={ - + + {isSaving ? 'Saving…' : 'Save'} @@ -1648,6 +1841,43 @@ function SecretInputDisplay({ data }: { data: CredentialTagData }) { ) } +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). @@ -1670,7 +1900,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: CredentialTagData }) { +function FolderAccessDisplay({ data }: { data: CredentialItemData }) { const [picking, setPicking] = useState(false) const [grantedName, setGrantedName] = useState(null) @@ -1729,7 +1959,7 @@ function FolderAccessDisplay({ data }: { data: CredentialTagData }) { * agent browser back to Sim. Renders nothing outside the desktop app — there * is no agent browser to hand back. */ -function BrowserTakeoverDisplay({ data }: { data: CredentialTagData }) { +function BrowserTakeoverDisplay({ data }: { data: CredentialItemData }) { const { workspaceId } = useParams<{ workspaceId: string }>() const { chatId } = useChatSurface() const [handedBack, setHandedBack] = useState(false) @@ -1770,10 +2000,16 @@ function BrowserTakeoverDisplay({ data }: { data: CredentialTagData }) { * 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 }: { data: CredentialTagData }) { +function ServiceAccountConnectDisplay({ + data, + embedded = false, + divided = false, + onConnected, +}: CredentialControlProps) { 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), @@ -1790,6 +2026,7 @@ function ServiceAccountConnectDisplay({ data }: { data: CredentialTagData }) { // 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 @@ -1800,17 +2037,31 @@ function ServiceAccountConnectDisplay({ data }: { data: CredentialTagData }) { const label = reconnectCredentialId ? `Reconnect ${reconnectCredential?.displayName ?? target.serviceName}` : `${target.label} for ${target.serviceName}` + const displayLabel = connected ? `Connected ${target.serviceName}` : label return ( <> {label} - + {displayLabel} + {connected ? ( + + ) : ( + + )} {open && ( @@ -1823,6 +2074,10 @@ function ServiceAccountConnectDisplay({ data }: { data: CredentialTagData }) { serviceIcon={target.serviceIcon} credentialId={reconnectCredentialId} credentialDisplayName={reconnectCredential?.displayName ?? undefined} + onCreated={() => { + setLocallyConnected(true) + onConnected?.() + }} /> )} @@ -1830,19 +2085,30 @@ function ServiceAccountConnectDisplay({ data }: { data: CredentialTagData }) { ) } -function CredentialLinkDisplay({ data }: { data: CredentialTagData }) { +function CredentialLinkDisplay({ + data, + controlId = 'credential-link', + embedded = false, + divided = false, + onConnected, +}: CredentialControlProps) { const { canEdit } = useUserPermissionsContext() - - // 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 integrationName = getCredentialProviderDisplayName(data.provider ?? '') + const { + reconnectCredentialId, + status, + connected, + connectedFromAttempt, + hasExistingCredential, + isReady, + onConnectClick, + } = useOAuthChipConnection({ + connectUrl: data.value, + provider: data.provider, + displayName: integrationName, + controlId, + onConnected, + }) const { data: reconnectCredential } = useWorkspaceCredential(reconnectCredentialId) // Connecting a credential mutates the workspace — hide it from read-only members. @@ -1851,47 +2117,48 @@ function CredentialLinkDisplay({ data }: { data: CredentialTagData }) { // 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}` - : `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, - }) - } + : 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 return ( {createElement(Icon, { className: 'size-[16px] shrink-0' })} - {label} - + {displayLabel} + {connected ? ( + + ) : ( + + )} ) } @@ -1904,7 +2171,7 @@ function CredentialLinkDisplay({ data }: { data: CredentialTagData }) { * 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: CredentialTagData }) { +function TerminalHandoffDisplay({ data }: { data: CredentialItemData }) { const { workspaceId } = useParams<{ workspaceId: string }>() const { chatId } = useChatSurface() const [handedBack, setHandedBack] = useState(false) @@ -1937,9 +2204,57 @@ function TerminalHandoffDisplay({ data }: { data: CredentialTagData }) { ) } -export function CredentialDisplay({ data }: { data: CredentialTagData }) { +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) { if (data.type === 'secret_input') { - return + const secretName = data.name?.trim() + if (embedded) { + if (!secretName || !onSecretValueChange) return null + return ( + + ) + } + return ( + + ) } if (data.type === 'folder_access') { @@ -1955,11 +2270,26 @@ export function CredentialDisplay({ data }: { data: CredentialTagData }) { } if (data.type === 'link') { - return + return ( + + ) } if (data.type === 'service_account') { - return + return ( + + ) } if (data.type === 'sim_key') { @@ -1972,6 +2302,253 @@ export function CredentialDisplay({ data }: { data: CredentialTagData }) { 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 new file mode 100644 index 00000000000..0cf00a30a3c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/use-oauth-chip-connection.ts @@ -0,0 +1,352 @@ +'use client' + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useParams } from 'next/navigation' +import { + addOAuthChatAttemptToAuthorizeUrl, + 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, + }) + .then((opened) => { + if (!opened) 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 f0293c42872..a2462865053 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,6 +21,7 @@ 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' @@ -789,6 +790,7 @@ 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 @@ -798,6 +800,8 @@ 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 @@ -814,9 +818,11 @@ interface MessageContentProps { function MessageContentInner({ blocks, fallbackContent, + messageId, isStreaming = false, isLast = false, questionAnswers, + credentialSubmission, onOptionSelect, onQuestionDismiss, onPhaseChange, @@ -912,12 +918,14 @@ function MessageContentInner({ { shouldShowAssistantMessageActions({ phase: 'settled', hasContent: true, - endsWithQuestion: true, + endsWithInteraction: true, questionDismissed: true, }) ).toBe(true) @@ -21,7 +21,7 @@ describe('shouldShowAssistantMessageActions', () => { shouldShowAssistantMessageActions({ phase: 'settled', hasContent: true, - endsWithQuestion: true, + endsWithInteraction: true, questionDismissed: false, }) ).toBe(false) @@ -32,7 +32,7 @@ describe('shouldShowAssistantMessageActions', () => { shouldShowAssistantMessageActions({ phase: 'streaming', hasContent: true, - endsWithQuestion: true, + endsWithInteraction: 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 0c84c1481b0..76f11127cc6 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 - endsWithQuestion: boolean + endsWithInteraction: boolean questionDismissed: boolean } /** - * 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. + * 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. */ export function shouldShowAssistantMessageActions({ phase, hasContent, - endsWithQuestion, + endsWithInteraction, questionDismissed, }: AssistantMessageActionsVisibility): boolean { - return phase === 'settled' && hasContent && (!endsWithQuestion || questionDismissed) + return phase === 'settled' && hasContent && (!endsWithInteraction || 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 4ed0236df5a..8a1d82acd7e 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,7 +22,13 @@ import { type MessagePhase, } from '@/app/workspace/[workspaceId]/home/components/message-content' import { parseQuestionAnswerMessage } from '@/app/workspace/[workspaceId]/home/components/message-content/components/question' -import { parseLastQuestionTag } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' +import { + type CredentialSubmissionPayload, + credentialTagHasVisibleCard, + parseCredentialSubmissionProgress, + parseLastCredentialTag, + parseLastQuestionTag, +} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' import { QueuedMessages } from '@/app/workspace/[workspaceId]/home/components/queued-messages' import { UserInput, @@ -38,6 +44,7 @@ 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' @@ -182,6 +189,8 @@ 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 @@ -193,10 +202,12 @@ 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() ?? '' @@ -215,10 +226,15 @@ const AssistantMessageRow = memo(function AssistantMessageRow({ return null } - // 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. + // 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. 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 @@ -232,25 +248,27 @@ const AssistantMessageRow = memo(function AssistantMessageRow({ const actionsEligible = shouldShowAssistantMessageActions({ phase: 'settled', hasContent: Boolean(message.content) || hasAnyBlocks, - endsWithQuestion, + endsWithInteraction: endsWithQuestion || showsCredentialCard, questionDismissed, }) - // A visible question card (active or answered recap) sits 12px below the + // A visible interaction 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 showsQuestionCard = endsWithQuestion && !questionDismissed + const showsInteractionCard = (endsWithQuestion && !questionDismissed) || showsCredentialCard return ( -
+