Skip to content

feat(ui): replace raw "/login" error with a Connect-AI empty state#1754

Draft
kasiazjc wants to merge 5 commits into
mainfrom
feature-connect-ai-empty-state
Draft

feat(ui): replace raw "/login" error with a Connect-AI empty state#1754
kasiazjc wants to merge 5 commits into
mainfrom
feature-connect-ai-empty-state

Conversation

@kasiazjc

@kasiazjc kasiazjc commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Summary

When no credential resolves for a session's configured provider (Claude, Codex, Gemini, etc.), users used to see a raw, unexplained string leaked straight from the provider CLI/SDK's stderr: "Not logged in · Please run /login." /login isn't an Agor command, and the message gave no indication of what to do next.

This replaces that raw passthrough with a friendly, actionable "Connect AI" empty state — without touching how credentials are stored or resolved.

  • Detection is structural, not text-based. A new classifyMissingCredentialFailure before-create hook on the messages service (apps/agor-daemon/src/hooks/classify-missing-credential.ts) re-checks resolveApiKey — the same resolution chain (per-user key → config.yaml → env var → native CLI/OAuth) that check-auth.ts already uses for the onboarding wizard's "Test Connection" button — whenever a task fails. It only touches messages the executor tags with metadata.is_task_failure (a new marker added in base-executor.ts), so rate limits, network errors, and other failures keep their existing handling untouched.
  • Per-viewing-user, live. The daemon only classifies that a credential was missing at run time; the new MissingCredentialPanel component re-checks auth for whoever is currently viewing the message via the same check-auth service, scoped to their own identity. Two users looking at the same session — one connected, one not — each see the panel state that's actually true for them.
  • Multi-provider, data-driven. Provider display name, key-creation URL, and Settings deep-link are all sourced from new shared maps (AGENTIC_TOOL_DISPLAY_NAMES, AGENTIC_TOOL_KEY_CREATION_URL in packages/core/src/types/agentic-tool.ts), not hardcoded to Claude.
  • Real deep-link. UserSettingsModal gains an initialTab prop so the panel's primary CTA ("Connect Claude", "Connect Codex", …) lands directly on that provider's Agentic Tools tab instead of the generic Settings screen — wired through a new onOpenAgenticToolSettings action on AppActionsContext.
  • Copy: plain language throughout (no "/login", "stderr", "CLI", "SDK", "OAuth" anywhere user-facing), one primary CTA, subordinate secondary options (get an API key, use an existing subscription where applicable), and a low-friction "Not right now" dismiss that only hides the panel locally — the underlying task/message is left untouched (non-destructive; user can retry by sending a new prompt).
  • Built from existing Ant Design conventions only (SystemMessage/Alert/Empty-style patterns already used elsewhere in the app) — no new UI dependencies.

Test plan

  • classify-missing-credential.test.ts (new, 8 tests): no credential anywhere → classified; workspace/env credential present → not classified; per-user credential present → not classified; unrelated (non-task-failure) messages → untouched, resolveApiKey never called; unmapped tools (opencode) → falls through; missing task/session → falls through; resolveApiKey throwing → fails closed, message left untouched
  • Typecheck clean: @agor/core, @agor/daemon, @agor/executor, agor-ui
  • Lint clean (biome) on all changed files
  • No regressions: register-hooks.test.ts (43), base-executor.test.ts (3), UserSettingsModal.test.tsx (3), ConversationView.test.tsx (11), SessionPanel*.test.tsx (15), session.test.ts (17) — all passing
  • Grepped all changed/new files for "/login", "stderr", "CLI", "SDK", "OAuth" in rendered copy — none found (only in code comments/tool-id literals)
  • Manual click-through in a running dev instance (not exercised in this session — no dev server available in this environment)

kasiazjc added 5 commits July 1, 2026 16:14
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).
…lFailure

Matches the existing sessionsRepository injection pattern instead of
constructing TaskRepository internally, so the hook is unit-testable
without a real database.
…rios

Covers: no credential anywhere, workspace/env credential present,
per-user credential present, unrelated (non-task-failure) messages,
unmapped tools (opencode), missing task/session, and resolveApiKey
throwing — the hook must fail closed and leave the message untouched.
Live testing against a fresh no-credential container surfaced a second
pathway the original detection missed entirely: the claude CLI can
return a *successful* SDK result (subtype 'success', task completes
normally, nothing throws) whose result text is actually the CLI's own
auth-failure message, with zero real assistant turns and zero tokens
consumed. base-executor.ts's catch block — and the is_task_failure
marker classifyMissingCredentialFailure keyed off of — only fires when
the SDK call throws, so this success-shaped failure sailed straight
through as a normal assistant message containing the raw "Not logged
in - Please run /login" text.

Extend classifyMissingCredentialFailure to also trigger on assistant
messages with metadata.tokens.input === 0 && output === 0: a real
successful Claude turn always consumes tokens (the system prompt alone
costs hundreds), so zero-token assistant output is a reliable
structural signal that no real model call happened. That signal is not
unique to auth failures — local slash-command output (e.g. /cost) is
zero-token too — so it's only ever used to decide whether to *run* the
existing resolveApiKey check, never to decide the outcome; an
authenticated user's /cost output still has a credential resolve, so
it's left untouched. When classified, both pathways are normalized
onto system/SYSTEM (the zero-token message was synthesized as if it
were a real reply, but never actually was) so the UI keeps a single
render branch regardless of which SDK pathway produced it.
…Source 'none'

Found while live-verifying the Connect-AI empty state fix against a
genuinely credential-less container: MissingCredentialPanel's
per-viewer check-auth call reported authenticated:true even though
the admin user had zero configured credentials anywhere (no API key,
no config.yaml default, no env var, no ~/.claude/ file) and the task
that had just run demonstrably failed to reach the model.

Root cause: the Claude Agent SDK's accountInfo() signals "no source"
with the literal string 'none' rather than omitting the field
(confirmed via daemon logs: `tokenSource=none`). check-auth.ts's
hasAuthSignal check did a plain truthy check on that raw string, so
the sentinel itself counted as a signal.

Extracted the fix into a pure isRealAuthSource() helper in a new
check-auth-helpers.ts module (rather than inline) so it's unit
testable without pulling in check-auth.ts's full import graph -
importing it directly for a test hits an unrelated pre-existing
OpenTelemetry/ESM resolution break in the Claude Agent SDK's
dependency chain.
@kasiazjc

kasiazjc commented Jul 7, 2026

Copy link
Copy Markdown
Contributor Author

The panel-redesign commits that were briefly on this branch have been split out into a separate PR: #1830 (#1830).

This PR is back to just the missing-credential detection logic (daemon-side).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant