From 1680803f3960b3ef4e7b861c661653aff96b0f07 Mon Sep 17 00:00:00 2001 From: kasiazjc Date: Wed, 1 Jul 2026 16:14:48 +0000 Subject: [PATCH 1/8] feat(ui): replace raw "/login" error with Connect-AI empty state Detects "no credential resolved for this session's provider" task failures structurally (reusing resolveApiKey's resolution order via a new classifyMissingCredentialFailure daemon hook) instead of matching the raw stderr-passthrough error text, and renders a friendly, actionable MissingCredentialPanel in its place. The panel is scoped live per-viewing-user via the existing check-auth service, and its primary CTA deep-links into Settings' per-provider Agentic Tools tab (UserSettingsModal gains an initialTab prop for this). --- .../src/hooks/classify-missing-credential.ts | 86 ++++++++++ apps/agor-daemon/src/register-hooks.ts | 7 + apps/agor-ui/src/components/App/App.tsx | 14 +- .../ConversationView/ConversationView.tsx | 17 +- .../components/MessageBlock/MessageBlock.tsx | 17 ++ .../MissingCredentialPanel.tsx | 160 ++++++++++++++++++ .../MissingCredentialPanel/index.ts | 2 + .../OnboardingWizard/OnboardingWizard.tsx | 15 +- .../SessionPanel/SessionPanelContent.tsx | 2 + .../SettingsModal/UserSettingsModal.tsx | 19 +-- .../src/components/TaskBlock/TaskBlock.tsx | 6 +- .../src/contexts/AppActionsContext.tsx | 10 +- packages/core/src/types/agentic-tool.ts | 28 +++ packages/core/src/types/message.ts | 13 ++ .../src/handlers/sdk/base-executor.ts | 3 + 15 files changed, 367 insertions(+), 32 deletions(-) create mode 100644 apps/agor-daemon/src/hooks/classify-missing-credential.ts create mode 100644 apps/agor-ui/src/components/MissingCredentialPanel/MissingCredentialPanel.tsx create mode 100644 apps/agor-ui/src/components/MissingCredentialPanel/index.ts diff --git a/apps/agor-daemon/src/hooks/classify-missing-credential.ts b/apps/agor-daemon/src/hooks/classify-missing-credential.ts new file mode 100644 index 000000000..000e2635a --- /dev/null +++ b/apps/agor-daemon/src/hooks/classify-missing-credential.ts @@ -0,0 +1,86 @@ +/** + * Before-create hook on the `messages` service that classifies a task's + * failure notice as a "missing credential" failure — without ever matching + * the underlying error text (which is a raw passthrough of the provider + * CLI/SDK's stderr and is fragile to upstream wording changes). + * + * Detection reuses `resolveApiKey` — the exact same resolution chain + * (per-user encrypted key -> config.yaml -> env var -> native CLI/OAuth) + * that `apps/agor-daemon/src/services/check-auth.ts` uses for the + * onboarding wizard's "Test Connection" button. If the task already failed + * while attempting the native-auth fallback, that failed attempt IS the + * proof native auth doesn't work — so no second SDK probe is needed here. + * + * Only acts on messages the executor marks with `metadata.is_task_failure` + * (see `packages/executor/src/handlers/sdk/base-executor.ts`), so unrelated + * system messages (daemon restart, rate limit, etc.) are never touched. + */ + +import { resolveApiKey } from '@agor/core/config'; +import { + type SessionRepository, + TaskRepository, + type TenantScopeAwareDatabase, +} from '@agor/core/db'; +import type { HookContext, Message, TaskID, UserID } from '@agor/core/types'; +import { TOOL_API_KEY_NAMES } from '@agor/core/types'; + +/** Friendly, jargon-free fallback for any consumer that renders `content` + * raw without checking `metadata.error_kind` (mobile app, gateway relays, + * CLI `agor session get`, etc). The web UI renders its own copy from the + * MissingCredentialPanel component instead of this string. */ +function fallbackContent(toolDisplayName: string): string { + return `This session needs to be connected to ${toolDisplayName} before it can run.`; +} + +export function classifyMissingCredentialFailure( + db: TenantScopeAwareDatabase, + sessionsRepository: Pick, + toolDisplayNames: Record +) { + const taskRepo = new TaskRepository(db); + + return async (context: HookContext): Promise => { + const data = context.data as Partial | undefined; + if (!data?.metadata?.is_task_failure || !data.task_id || !data.session_id) { + return context; + } + + try { + const [task, session] = await Promise.all([ + taskRepo.findById(data.task_id as TaskID), + sessionsRepository.findById(data.session_id), + ]); + if (!task || !session) return context; + + const tool = session.agentic_tool; + const keyName = TOOL_API_KEY_NAMES[tool]; + // Tools with no mapped key (opencode: always ready; anything + // unmapped/future: a structurally different problem) fall through + // untouched to the existing generic failure handling. + if (!keyName) return context; + + const { apiKey } = await resolveApiKey(keyName, { + userId: task.created_by as UserID, + db, + tool, + }); + if (apiKey) return context; // A credential DID resolve — some other failure. + + context.data = { + ...data, + content: fallbackContent(toolDisplayNames[tool] ?? tool), + content_preview: fallbackContent(toolDisplayNames[tool] ?? tool).substring(0, 200), + metadata: { + ...data.metadata, + error_kind: 'missing_credential', + tool, + }, + }; + } catch (err) { + console.error('[classifyMissingCredentialFailure] classification failed:', err); + } + + return context; + }; +} diff --git a/apps/agor-daemon/src/register-hooks.ts b/apps/agor-daemon/src/register-hooks.ts index 6ebcfb7a8..1095e63dc 100755 --- a/apps/agor-daemon/src/register-hooks.ts +++ b/apps/agor-daemon/src/register-hooks.ts @@ -67,6 +67,7 @@ import type { UserID, } from '@agor/core/types'; import { + AGENTIC_TOOL_DISPLAY_NAMES, GATEWAY_REDACTED_SENTINEL, GATEWAY_SENSITIVE_CONFIG_FIELDS, hasMinimumRole, @@ -78,6 +79,7 @@ import type { MessagesServiceImpl, SessionsServiceImpl, } from './declarations.js'; +import { classifyMissingCredentialFailure } from './hooks/classify-missing-credential.js'; import { gatewayRouteHook } from './hooks/gateway-route.js'; import { resolveForUserIdWithGate } from './oauth-auth-helpers.js'; import type { ArtifactsService } from './services/artifacts.js'; @@ -750,6 +752,11 @@ export function registerHooks(ctx: RegisterHooksContext): void { ensureCanPromptInSession(superadminOpts), // Require 'prompt' (or 'session' for own sessions) ] : []), + // Detect "no credential resolved for this session's provider" task + // failures structurally (reusing resolveApiKey's resolution order), + // never by matching the raw stderr-passthrough error text. Drives + // the Connect-AI empty state instead of a raw "/login" message. + classifyMissingCredentialFailure(db, sessionsRepository, AGENTIC_TOOL_DISPLAY_NAMES), ], patch: [ requireMinimumRole(ROLES.MEMBER, 'update messages'), diff --git a/apps/agor-ui/src/components/App/App.tsx b/apps/agor-ui/src/components/App/App.tsx index c68374fc9..155490fbe 100644 --- a/apps/agor-ui/src/components/App/App.tsx +++ b/apps/agor-ui/src/components/App/App.tsx @@ -1,4 +1,5 @@ import type { + AgenticToolName, AgorClient, Artifact, Board, @@ -403,6 +404,12 @@ export const App: React.FC = ({ const [leftPanelTab, setLeftPanelTab] = useState('teammate'); const [userSettingsOpen, setUserSettingsOpen] = useState(false); + // Deep-links UserSettingsModal to a specific Agentic Tools tab (Connect-AI + // empty state CTA). Cleared on close so a plain settings-menu click later + // doesn't re-land on a stale tool tab. + const [userSettingsInitialTool, setUserSettingsInitialTool] = useState< + AgenticToolName | undefined + >(undefined); const [viewportWidth, setViewportWidth] = useState(() => typeof window === 'undefined' ? 1440 : window.innerWidth ); @@ -1195,6 +1202,10 @@ export const App: React.FC = ({ onSessionClick: handleSessionClick, onOpenBranch: handleOpenBranchModal, onOpenTerminal: canOpenTerminal ? handleOpenTerminal : undefined, + onOpenAgenticToolSettings: (tool: AgenticToolName) => { + setUserSettingsInitialTool(tool); + setUserSettingsOpen(true); + }, }), [ stableOnSendPrompt, @@ -1713,9 +1724,10 @@ export const App: React.FC = ({ setThemeEditorOpen(false)} /> { setUserSettingsOpen(false); + setUserSettingsInitialTool(undefined); onUserSettingsClose?.(); }} user={user || null} diff --git a/apps/agor-ui/src/components/ConversationView/ConversationView.tsx b/apps/agor-ui/src/components/ConversationView/ConversationView.tsx index 41b8d6b9c..8be5b4dc3 100644 --- a/apps/agor-ui/src/components/ConversationView/ConversationView.tsx +++ b/apps/agor-ui/src/components/ConversationView/ConversationView.tsx @@ -10,7 +10,14 @@ * - Auto-scrolling to latest content */ -import type { AgorClient, Message, PermissionScope, SessionID, User } from '@agor-live/client'; +import type { + AgenticToolName, + AgorClient, + Message, + PermissionScope, + SessionID, + User, +} from '@agor-live/client'; import { shortId, TaskStatus } from '@agor-live/client'; import { BranchesOutlined, CopyOutlined, ForkOutlined } from '@ant-design/icons'; import { Alert, Button, Spin, Typography, theme } from 'antd'; @@ -125,6 +132,12 @@ export interface ConversationViewProps { * When true, all task blocks are force-expanded (used by in-session search) */ forceExpandAll?: boolean; + + /** + * Opens Settings deep-linked to a provider's Agentic Tools tab, powering the + * Connect-AI empty state's primary CTA. + */ + onOpenAgenticToolSettings?: (tool: AgenticToolName) => void; } export const ConversationView = React.memo( @@ -145,6 +158,7 @@ export const ConversationView = React.memo( genealogy, teammateEmoji, forceExpandAll = false, + onOpenAgenticToolSettings, }) => { const { token } = theme.useToken(); const [copied, copy] = useCopyToClipboard(); @@ -505,6 +519,7 @@ export const ConversationView = React.memo( teammateEmoji={teammateEmoji} isLatestTask={taskIndex === tasks.length - 1} client={client} + onOpenAgenticToolSettings={onOpenAgenticToolSettings} /> ))} diff --git a/apps/agor-ui/src/components/MessageBlock/MessageBlock.tsx b/apps/agor-ui/src/components/MessageBlock/MessageBlock.tsx index da6ab8613..8b0ee69ea 100644 --- a/apps/agor-ui/src/components/MessageBlock/MessageBlock.tsx +++ b/apps/agor-ui/src/components/MessageBlock/MessageBlock.tsx @@ -10,6 +10,7 @@ */ import { + type AgenticToolName, type AgorClient, type ContentBlock as CoreContentBlock, type DiffEnrichment, @@ -33,6 +34,7 @@ import { AgorAvatar } from '../AgorAvatar'; import { CollapsibleMarkdown } from '../CollapsibleText/CollapsibleMarkdown'; import { CopyableContent } from '../CopyableContent'; import { MarkdownRenderer } from '../MarkdownRenderer'; +import { MissingCredentialPanel } from '../MissingCredentialPanel'; import { PermissionRequestBlock } from '../PermissionRequestBlock'; import { SystemMessage } from '../SystemMessage'; import { ThinkingBlock } from '../ThinkingBlock'; @@ -100,6 +102,8 @@ interface MessageBlockProps { allow: boolean, scope: PermissionScope ) => void; + /** Opens Settings deep-linked to a provider's Agentic Tools tab (Connect-AI CTA). */ + onOpenAgenticToolSettings?: (tool: AgenticToolName) => void; } /** Get short description for a tool call (file path, pattern, command, etc.) */ @@ -330,6 +334,7 @@ const MessageBlockInner: React.FC = ({ onPermissionDecision, teammateEmoji, client = null, + onOpenAgenticToolSettings, }) => { const { token } = theme.useToken(); @@ -492,6 +497,18 @@ const MessageBlockInner: React.FC = ({ ); } + // Task failed for lack of a resolved credential — render the actionable + // Connect-AI panel instead of the raw provider error. + if (isSystem && message.metadata?.error_kind === 'missing_credential' && message.metadata?.tool) { + return ( + + ); + } + // Daemon restart / crash notice — injected by startup reconciliation. // Intentionally low-frequency and user-meaningful; contrast with PR #1116 // which filtered high-frequency SDK lifecycle noise. diff --git a/apps/agor-ui/src/components/MissingCredentialPanel/MissingCredentialPanel.tsx b/apps/agor-ui/src/components/MissingCredentialPanel/MissingCredentialPanel.tsx new file mode 100644 index 000000000..b7481bcf0 --- /dev/null +++ b/apps/agor-ui/src/components/MissingCredentialPanel/MissingCredentialPanel.tsx @@ -0,0 +1,160 @@ +/** + * MissingCredentialPanel - Connect-AI empty state. + * + * Replaces the raw provider error ("Not logged in · Please run /login") with + * an actionable panel when a task failed for lack of a resolved credential. + * + * The stored classification only records that *some* user's run had no + * credential, so two viewers of the same session can differ (one connected, + * one not). The panel re-checks auth live for the current viewer via the + * `check-auth` service rather than trusting the task-run-time snapshot. + */ + +import type { AgenticToolName, AgorClient, AuthCheckResult } from '@agor-live/client'; +import { AGENTIC_TOOL_DISPLAY_NAMES, AGENTIC_TOOL_KEY_CREATION_URL } from '@agor-live/client'; +import { CheckCircleOutlined } from '@ant-design/icons'; +import { Button, Space, Spin, Typography, theme } from 'antd'; +import { useEffect, useState } from 'react'; +import { SystemMessage } from '../SystemMessage'; + +const { Text, Link } = Typography; + +/** Tools where an existing subscription/CLI login is a real alternative to an API key. */ +const NATIVE_AUTH_HINT: Partial> = { + 'claude-code': 'Already on a Claude Pro or Max plan? You can connect that instead of an API key.', + 'claude-code-cli': + 'Already on a Claude Pro or Max plan? You can connect that instead of an API key.', + codex: + 'Already signed in with ChatGPT on this machine? Codex can use that instead of an API key.', +}; + +export interface MissingCredentialPanelProps { + /** The provider this session is configured to use and needs a credential for. */ + tool: AgenticToolName; + client?: AgorClient | null; + /** Opens Settings deep-linked to this tool's Agentic Tools tab (primary CTA). */ + onOpenAgenticToolSettings?: (tool: AgenticToolName) => void; +} + +export const MissingCredentialPanel: React.FC = ({ + tool, + client, + onOpenAgenticToolSettings, +}) => { + const { token } = theme.useToken(); + const [checking, setChecking] = useState(true); + const [authResult, setAuthResult] = useState(null); + const [dismissed, setDismissed] = useState(false); + + useEffect(() => { + let cancelled = false; + if (!client) { + setChecking(false); + return; + } + setChecking(true); + client + .service('check-auth') + .create({ tool }) + .then((result) => { + if (!cancelled) setAuthResult(result as AuthCheckResult); + }) + .catch(() => { + if (!cancelled) setAuthResult(null); + }) + .finally(() => { + if (!cancelled) setChecking(false); + }); + return () => { + cancelled = true; + }; + // Re-check when the viewer or tool changes; `client` identity is stable + // per authenticated session. + }, [client, tool]); + + const displayName = AGENTIC_TOOL_DISPLAY_NAMES[tool] ?? tool; + const keyCreationUrl = AGENTIC_TOOL_KEY_CREATION_URL[tool]; + const nativeAuthHint = NATIVE_AUTH_HINT[tool]; + + if (dismissed) return null; + + if (checking) { + return ( + + + + Checking your {displayName} connection… + + + } + /> + ); + } + + // The current viewer already has a working credential (a different user ran + // the failed prompt), so no CTA is needed. + if (authResult?.authenticated) { + return ( + + + + This run needed a connected {displayName} account. Your account is already connected — + sending a new message should work. + + + } + /> + ); + } + + return ( + + + Agor doesn't have its own AI model — it drives {displayName} on your behalf, so it needs + a way to reach your account before this session can respond. + + +
+ +
+ + + {keyCreationUrl && ( + + Don't have a key yet?{' '} + + Get one from {displayName}'s console → + + + )} + {nativeAuthHint && ( + + {nativeAuthHint} + + )} + + + + } + /> + ); +}; diff --git a/apps/agor-ui/src/components/MissingCredentialPanel/index.ts b/apps/agor-ui/src/components/MissingCredentialPanel/index.ts new file mode 100644 index 000000000..db6e699fb --- /dev/null +++ b/apps/agor-ui/src/components/MissingCredentialPanel/index.ts @@ -0,0 +1,2 @@ +export type { MissingCredentialPanelProps } from './MissingCredentialPanel'; +export { MissingCredentialPanel } from './MissingCredentialPanel'; diff --git a/apps/agor-ui/src/components/OnboardingWizard/OnboardingWizard.tsx b/apps/agor-ui/src/components/OnboardingWizard/OnboardingWizard.tsx index d087dd3d5..8c1dc4717 100644 --- a/apps/agor-ui/src/components/OnboardingWizard/OnboardingWizard.tsx +++ b/apps/agor-ui/src/components/OnboardingWizard/OnboardingWizard.tsx @@ -26,6 +26,7 @@ import type { UserPreferences, } from '@agor-live/client'; import { + AGENTIC_TOOL_DISPLAY_NAMES, getDefaultPermissionMode, mapToCodexPermissionConfig, shortId, @@ -161,16 +162,6 @@ function apiKeyPlaceholder(agent: AgenticToolName, authMethod: AuthMethod = 'api return 'sk-ant-...'; } -const AGENT_LABELS: Record = { - 'claude-code': 'Claude Code', - 'claude-code-cli': 'Claude Code CLI', - codex: 'Codex', - gemini: 'Gemini', - opencode: 'OpenCode', - copilot: 'GitHub Copilot', - cursor: 'Cursor SDK', -}; - const RECOMMENDED_AGENT_OPTIONS: Array<{ value: AgenticToolName; title: string; eyebrow: string }> = [ { value: 'claude-code', title: 'Claude Code', eyebrow: 'Recommended' }, @@ -1067,8 +1058,8 @@ export function OnboardingWizard({ } - title={`${AGENT_LABELS[selectedAgent]} is configured`} - subTitle={`You're all set to use ${AGENT_LABELS[selectedAgent]}.`} + title={`${AGENTIC_TOOL_DISPLAY_NAMES[selectedAgent]} is configured`} + subTitle={`You're all set to use ${AGENTIC_TOOL_DISPLAY_NAMES[selectedAgent]}.`} />