feat(desktop): connector cards that wait for the sign-in, and a guided first launch that holds together (#108292)
* feat(desktop): give Button a loading prop that swaps label for spinner without layout shift The label stays in the box, invisible, and the spinner is absolutely centred over it, so a Connect or Approve button keeps its width while it works instead of collapsing to a spinner. The approval bar had the same thrash and moves onto it. * refactor(desktop): one consent card for connectors and MCP setup McpSetupTool rendered its own copy of the connector card's markup. It now renders ConnectorCard for the pending question and ConnectorSummary once settled, and the card gains what MCP needed: keyboard accelerators, a source line, a question heading. The card also gets an avatar variant (40px mark in the left gutter, text and buttons on one column) and a collapseWhenSettled switch so a connector can stay a full card with a green Connected pill in the action slot while MCP keeps its one-line summary. Brand marks for Gmail, Calendar, Drive, Discord, Telegram and Spotify; Slack via Tabler because simple-icons dropped the mark. * feat(desktop): connector card drives the agent through manage_connections wait The offer used to end in a Continue in chat button, and the agent, seeing an unconnected status, would improvise around the app. Now the card does what the TUI does. Clicking Connect opens the browser and sends one hidden line telling the agent to park in manage_connections action=wait for that slug and to never call connect again (a second link cancels the one being signed into). Not now sends its own line. A hidden request that lands while the turn is busy steers it, or queues if the turn just ended. Which call owns the live card changes too: consecutive calls naming the same apps are one exchange (connect, the wait, the status that follows), and the first of the last exchange is the card, so the agent's wait no longer demotes the card mid-authorization and mints a fresh one below it. A targeted ask renders one or two bare cards; only a real catalog gets the header, search and refresh. * feat(desktop): onboarding connects apps in chat and keeps tasks finishable without them The welcome chat knew connectors only as preferences to pick and wire up later, so asked to connect Gmail it invented a Settings page that does not exist. Both scripts now carry one rule set: status once, one batched connect for every app named, the card is the ask so write a line and end the turn, never route around a declined app with another client or credential. The build handoff checks real connection status instead of asserting none are connected, and the first task must be finishable, not free of, the apps they picked. The connectors card explains what connecting means and reports the count on its Continue button. * fix(tools): resolve the Nous identity for share_auth profiles in the connector gate A profile created with share_auth has no auth.json of its own and signs in through the root store. Every other credential reader falls back to the global root; the connector gate read HERMES_HOME/auth.json directly, saw nothing, and stripped manage_connections from the profile's tool list, so the welcome chat's agent truthfully reported the tool missing. The gate now goes through get_provider_auth_state. * fix(agent): name a provider retry backoff on the live status line The retry status is buffered and replays only when every retry fails, so during a 60s backoff after a 5xx the user saw a bare spinner. Right after a connector sign-in landed this read as the agent going silent. The backoff now also rewrites the live wait notice, which the desktop already renders in the thread status row; it is transient and clears on recovery. * test(desktop): connector rehearsal launcher and flagged connector spec connector-rehearsal.mjs starts the real desktop and backend under a fresh HERMES_HOME with no copied credentials, a fixed Vite port and CDP on 9344, so the onboarding connector flow can be driven end to end by hand or from outside. The Playwright spec covers the flagged connector step. * fix(desktop): send the agent back into wait when the user keeps waiting after a timeout The card's Keep waiting re-entered the poll but the agent's own wait had timed out too and nothing told it to go back in, so it would start talking mid-authorization. keepWaiting now fires onWaiting like connect does. Tests also pin that an expired or revoked grant asks the gateway for reconnect, not connect. * style(desktop): blank lines in connector-flow test per lint * feat(desktop): HERMES_SKIP_INTRO=1 / --skip-intro skips the first-run film The intro is a one-time reveal, so anyone rehearsing the guided chat behind it sits through it on every fresh HERMES_HOME. The flag rides the existing launch-flags path (main → preload → renderer) next to guestOnboarding and only gates isIntroRevealEnabled; the backend never sees it. The rehearsal launcher sets it. * fix(desktop): onboarding card Continue stays Done after the transcript rebuilds The card kept its Done flag in component state. The hidden submit and the turn-end hydrate both rebuild the message list, so the card remounted with the flag false and Continue came back live, letting a step be answered twice. The committed steps now live with the other onboarding answers, keyed by step, and the first-build chip pick rides the same store. remember_onboarding projects by key, so the new field never reaches USER.md. * fix(desktop): no provider picker or free-tier chip over the guided first launch Two sign-in surfaces leaked into the guide. A credential probe on the setup profile (a free-tier token mid refresh, a session before its runtime settled) hit requestDesktopOnboarding and dropped the provider picker over the chat the user was in; and the statusbar free-tier chip sat there offering a second sign-in the whole time. Both now yield while the gate phase is cinematic, guided or handoff. The free tier is the provider for those phases, and the guide offers sign-in on its own ready screen. * fix(desktop): onboarding connector picks are real catalog slugs The picker offered Spotify, GitHub and Stripe, none of which the deployed connector catalog carries, and spelled Calendar and Drive with hyphens the gateway does not use. A pick the build chat could not honour ended as "Spotify isn't in the connector list" after the user had been told to expect it. The list is now twelve slugs from the live status catalog, spelled as the gateway spells them; GitHub is out (the terminal has git and gh), chat channels stay on Messaging. Marks for the new entries; the Google marks answer both spellings. The build runbook offers the picked connections in its first turn rather than after the work is underway. * fix(desktop): the free-tier ready screen never interrupts the guided chat A readiness round fires when the layout pick assembles the window, and it raised the free-tier ready screen over the conversation: the user was dropped into the main app, dismissed it, and came back to a card they had already answered. The guide is the introduction. The ready screen now yields while the gate is cinematic, guided or handoff, and the notice is acked the moment the guided chat takes the screen, not only when the film does, so a skipped film no longer leaves it pending. * feat(desktop): tour options that lead to building, and a fork that follows the tour "Just the basics" and "Show me around" read as a click-through with no exit; "I'll figure it out" read as declining help. Now Quick tour, Show me everything, and Skip, let's build something. The script also folds the fork into the same turn as the tour, so when the user closes the overlay the next ask is already waiting instead of a transcript that ends on the tour call. * feat(desktop): the onboarding connector picker reads the live catalog A hardcoded list, however carefully copied from today's catalog, is the next drift. The picker now asks connectors.list through the same session-owned RPC the connector cards use and offers exactly what the gateway carries: a curated lead order puts the everyday apps first, chat channels stay on Messaging, everything else is reachable by search. The picks are gateway slugs, handed straight to manage_connections. No catalog (toolset off, gateway unreachable) ends the step honestly with Skip instead of inventing apps. * test(desktop): the guided first launch never forces a sign-in The acceptance criterion the guided onboarding was built to, as a test: while the gate is cinematic, guided or handoff, the provider picker does not open and a credential warning is dropped rather than deferred to the next send. Outside the guide the picker opens as before. Red against the tree before the guards landed (6 of 9). * fix(desktop): a relaunch mid-guide resumes the guide, in the guide's shape Closing the app during the guided first launch and reopening it booted the normal shell around the persisted solo layout: the connecting splash, the stock composer and model picker, a small window whose sidebars would not open, while the gate still read guided. The gate now queues a kickoff for the guided phase too (the kickoff adopts the existing guide chat by title), takes the solo shape before the gateway opens rather than after, and the connecting overlay yields to the guide's own opening. A typed reply in the composer now closes an ask card and the first-build chips the same way a click does; the layout card's Continue comes back Done. * style(desktop): one answeredAfter helper for the ask card and first-build chips * fix(desktop): the guide takes its shape on the tick the film ends, not after the window shows Between the film and the greeting the full-size shell painted for a beat: finishIntroReveal showed the main window, then the kickoff shrank it once the setup profile answered. The listener on the intro's hidden edge now takes the guide's shape (solo layout + small centred window) synchronously, so the window is already the guide when it is shown. One takeGuideShape owns the pair; kickoff and the boot gate call it idempotently. * style(desktop): the 'nothing connects yet' line reads first on the connectors card
This commit is contained in:
@@ -1080,6 +1080,14 @@ def compute_error_backoff(
|
||||
agent._emit_status(_retry_status)
|
||||
else:
|
||||
agent._buffer_status(_retry_status)
|
||||
# The buffered line only replays if every retry fails; the live status
|
||||
# line is the one thing the user sees meanwhile. Name the wait there so a
|
||||
# 60s backoff after a 5xx is not an anonymous spinner — this is transient
|
||||
# (rewritten by the next frame, cleared on recovery), so it does not add
|
||||
# the transcript chatter the buffer exists to avoid.
|
||||
agent._emit_wait_notice(
|
||||
f"⏳ waiting on provider — retrying in {wait_time:.0f}s (attempt {retry_count}/{max_retries})"
|
||||
)
|
||||
logger.warning(
|
||||
"Retrying API call in %ss (attempt %s/%s) %s policy=%s error=%s",
|
||||
wait_time, retry_count, max_retries, agent._client_log_context(),
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { expect, test } from './test'
|
||||
import { createSandbox, buildAppEnv, launchDesktop } from './fixtures'
|
||||
|
||||
const GMAIL = 'Gmail'
|
||||
const CALENDAR = 'Google Calendar'
|
||||
|
||||
test.describe('flagged connector onboarding', () => {
|
||||
test('shows the optional connector controls in a fresh flagged session', async () => {
|
||||
const sandbox = createSandbox('connectors-ui')
|
||||
const { app, page } = await launchDesktop(buildAppEnv(sandbox, {
|
||||
HERMES_GUEST_ONBOARDING: '1'
|
||||
}))
|
||||
|
||||
try {
|
||||
await expect(page.getByText('Connect your apps')).toBeVisible({ timeout: 90_000 })
|
||||
await expect(page.getByText(/Connecting is optional/i)).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Connect', exact: true }).first()).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Not now', exact: true }).first()).toBeVisible()
|
||||
await expect(page.getByText(GMAIL)).toBeVisible()
|
||||
await expect(page.getByText(CALENDAR)).toBeVisible()
|
||||
} finally {
|
||||
await app.close().catch(() => undefined)
|
||||
sandbox.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
test('keeps the default surface free of connector authorization controls', async () => {
|
||||
const sandbox = createSandbox('connectors-off')
|
||||
const { app, page } = await launchDesktop(buildAppEnv(sandbox))
|
||||
|
||||
try {
|
||||
await expect(page.locator('[data-connector-offer]')).toHaveCount(0)
|
||||
} finally {
|
||||
await app.close().catch(() => undefined)
|
||||
sandbox.cleanup()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -2,9 +2,17 @@ import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { desktopBackendSpawnEnv, guestOnboardingEnabled } from './guest-onboarding'
|
||||
import { desktopBackendSpawnEnv, guestOnboardingEnabled, skipIntroEnabled } from './guest-onboarding'
|
||||
import { buildSpawnCommand } from './remote-lifecycle'
|
||||
|
||||
test('skipIntroEnabled: exactly "1" in env or --skip-intro on argv skips the first-run film', () => {
|
||||
assert.equal(skipIntroEnabled([], { HERMES_SKIP_INTRO: '1' }), true)
|
||||
assert.equal(skipIntroEnabled(['electron', '.', '--skip-intro'], {}), true)
|
||||
|
||||
assert.equal(skipIntroEnabled([], {}), false)
|
||||
assert.equal(skipIntroEnabled([], { HERMES_SKIP_INTRO: 'true' }), false)
|
||||
})
|
||||
|
||||
test('guestOnboardingEnabled: exactly "1" in env or --guest-onboarding on argv turns the free tier on', () => {
|
||||
assert.equal(guestOnboardingEnabled([], { HERMES_GUEST_ONBOARDING: '1' }), true)
|
||||
assert.equal(guestOnboardingEnabled(['electron', '.', '--guest-onboarding'], {}), true)
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
|
||||
export const GUEST_ONBOARDING_ENV = 'HERMES_GUEST_ONBOARDING'
|
||||
export const GUEST_ONBOARDING_FLAG = '--guest-onboarding'
|
||||
// Skip the first-run film. A rehearsal aid: the intro is a one-time reveal,
|
||||
// so anyone iterating on the guided chat behind it otherwise sits through it
|
||||
// on every fresh HERMES_HOME. Renderer-only; the backend never sees it.
|
||||
export const SKIP_INTRO_ENV = 'HERMES_SKIP_INTRO'
|
||||
export const SKIP_INTRO_FLAG = '--skip-intro'
|
||||
|
||||
export function guestOnboardingEnabled(
|
||||
argv: readonly string[] = process.argv,
|
||||
@@ -14,6 +19,10 @@ export function guestOnboardingEnabled(
|
||||
return env[GUEST_ONBOARDING_ENV] === '1' || argv.includes(GUEST_ONBOARDING_FLAG)
|
||||
}
|
||||
|
||||
export function skipIntroEnabled(argv: readonly string[] = process.argv, env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
return env[SKIP_INTRO_ENV] === '1' || argv.includes(SKIP_INTRO_FLAG)
|
||||
}
|
||||
|
||||
// Outermost wrapper for a backend spawn env: the flag is written LAST so no
|
||||
// earlier spread (process.env, backend.env) can resurrect a stray value, and
|
||||
// "off" is an explicit '0' rather than an absent key so a '1' inherited from
|
||||
|
||||
@@ -209,7 +209,7 @@ import {
|
||||
import { startGatewaysAfterUpdateAbort, stopGatewayBeforeUpdate } from './gateway-stop-before-update'
|
||||
import { probeGatewayWebSocket } from './gateway-ws-probe'
|
||||
import { registerGitIpc } from './git-ipc'
|
||||
import { desktopBackendSpawnEnv, guestOnboardingEnabled } from './guest-onboarding'
|
||||
import { desktopBackendSpawnEnv, guestOnboardingEnabled, skipIntroEnabled } from './guest-onboarding'
|
||||
import { readAndConsumeHandoffResult } from './handoff-result'
|
||||
import {
|
||||
ATTACHMENT_UPLOAD_DEFAULT_MAX_BYTES,
|
||||
@@ -911,6 +911,7 @@ const SKIP_QUIT_CONFIRM = process.env.HERMES_DESKTOP_SKIP_QUIT_CONFIRM === '1'
|
||||
// Nous free tier gate, decided ONCE here and stamped onto every backend spawn
|
||||
// (desktopBackendSpawnEnv) and the renderer (hermes:launch-flags).
|
||||
const GUEST_ONBOARDING = guestOnboardingEnabled()
|
||||
const SKIP_INTRO = skipIntroEnabled()
|
||||
|
||||
const BOOT_FAKE_STEP_MS = (() => {
|
||||
const raw = Number.parseInt(String(process.env.HERMES_DESKTOP_BOOT_FAKE_STEP_MS || ''), 10)
|
||||
@@ -17158,7 +17159,8 @@ ipcMain.on('hermes:translucency:support', event => {
|
||||
ipcMain.on('hermes:launch-flags', event => {
|
||||
event.returnValue = {
|
||||
localModels: process.argv.includes('--local') || process.platform === 'win32' || process.platform === 'darwin',
|
||||
guestOnboarding: GUEST_ONBOARDING
|
||||
guestOnboarding: GUEST_ONBOARDING,
|
||||
skipIntro: SKIP_INTRO
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -22,6 +22,9 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
// (HERMES_GUEST_ONBOARDING=1 or --guest-onboarding). Read-only; the same
|
||||
// decision is stamped onto every backend the app spawns.
|
||||
guestOnboardingEnabled: launchFlags?.guestOnboarding === true,
|
||||
// Launch-flag fact: skip the first-run film (HERMES_SKIP_INTRO=1 or
|
||||
// --skip-intro). Rehearsal aid for the guided chat behind it.
|
||||
skipIntro: launchFlags?.skipIntro === true,
|
||||
getConnection: (profile, opts) => ipcRenderer.invoke('hermes:connection', profile, opts),
|
||||
// Registry-scoped backend resolution: { connectionId, profile } → descriptor.
|
||||
getConnectionFor: payload => ipcRenderer.invoke('hermes:connection:for', payload),
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env node
|
||||
// Real desktop + real backend. Isolated identity; no copied credentials.
|
||||
// Adds a fixed Vite port and CDP (9344) so the run can be driven and read
|
||||
// from outside. When you only want to click through it yourself:
|
||||
// D=$(mktemp -d) && env -u HERMES_SHARED_AUTH_DIR HERMES_GUEST_ONBOARDING=1 HERMES_SKIP_INTRO=1 \
|
||||
// HERMES_HOME="$D/home" HERMES_DESKTOP_USER_DATA_DIR="$D/userdata" hermes desktop
|
||||
import { spawn } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import net from 'node:net'
|
||||
|
||||
const desktop = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const root = path.resolve(desktop, '../..')
|
||||
const sandbox = process.argv[2] ? path.resolve(process.argv[2]) : fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-connectors-'))
|
||||
const port = 5194
|
||||
const url = `http://127.0.0.1:${port}`
|
||||
const python = process.env.HERMES_DESKTOP_PYTHON
|
||||
if (!python || !fs.existsSync(python)) throw new Error('Set HERMES_DESKTOP_PYTHON to your installed Hermes Python executable.')
|
||||
if (!fs.existsSync(path.join(desktop, 'dist/electron-main.mjs'))) throw new Error('Run node scripts/bundle-electron-main.mjs --dev first.')
|
||||
await new Promise((resolve, reject) => {
|
||||
const probe = net.createServer()
|
||||
probe.once('error', () => reject(new Error(`Port ${port} is occupied; refusing to use another worktree server.`)))
|
||||
probe.listen(port, '127.0.0.1', () => probe.close(resolve))
|
||||
})
|
||||
for (const dir of ['.hermes/shared', 'electron', 'work']) fs.mkdirSync(path.join(sandbox, dir), { recursive: true })
|
||||
const env = Object.fromEntries(['PATH', 'TMPDIR', 'LANG', 'SHELL'].filter(key => process.env[key]).map(key => [key, process.env[key]]))
|
||||
Object.assign(env, {
|
||||
HOME: sandbox, HERMES_HOME: path.join(sandbox, '.hermes'), HERMES_SHARED_AUTH_DIR: path.join(sandbox, '.hermes/shared'),
|
||||
HERMES_DESKTOP_USER_DATA_DIR: path.join(sandbox, 'electron'), HERMES_DESKTOP_CWD: path.join(sandbox, 'work'),
|
||||
HERMES_DESKTOP_HERMES_ROOT: root, HERMES_DESKTOP_PYTHON: python,
|
||||
HERMES_DESKTOP_IGNORE_EXISTING: '1', HERMES_DESKTOP_APP_NAME: 'Hermes Connector Rehearsal',
|
||||
HERMES_GUEST_ONBOARDING: '1', HERMES_SKIP_INTRO: '1', HERMES_DESKTOP_DEV_SERVER: url, HERMES_DESKTOP_CDP_PORT: '9344'
|
||||
})
|
||||
const entry = path.join(desktop, 'dist/connector-rehearsal.mjs')
|
||||
fs.writeFileSync(entry, `import { app } from 'electron'\napp.setAsDefaultProtocolClient = () => false\napp.setAppPath(${JSON.stringify(desktop)})\nawait import('./electron-main.mjs')\n`)
|
||||
const vite = spawn(path.join(root, 'node_modules/.bin/vite'), ['--host', '127.0.0.1', '--port', String(port), '--strictPort'], { cwd: desktop, env, stdio: 'inherit' })
|
||||
let electron
|
||||
let stopping = false
|
||||
const stop = () => {
|
||||
if (stopping) return
|
||||
stopping = true
|
||||
electron?.kill('SIGTERM')
|
||||
vite.kill('SIGTERM')
|
||||
}
|
||||
process.on('SIGINT', stop)
|
||||
process.on('SIGTERM', stop)
|
||||
vite.on('exit', stop)
|
||||
try {
|
||||
const deadline = Date.now() + 30000
|
||||
while (true) {
|
||||
if (vite.exitCode !== null) throw new Error('Renderer server exited')
|
||||
try { if ((await fetch(url)).ok) break } catch {}
|
||||
if (Date.now() > deadline) throw new Error('Renderer did not become ready')
|
||||
await new Promise(resolve => setTimeout(resolve, 250))
|
||||
}
|
||||
const binary = path.join(desktop, 'node_modules/electron/dist', process.platform === 'darwin' ? 'Electron.app/Contents/MacOS/Electron' : process.platform === 'win32' ? 'electron.exe' : 'electron')
|
||||
electron = spawn(binary, [entry], { cwd: desktop, env, stdio: 'inherit' })
|
||||
console.log(`Connector rehearsal: ${sandbox}\nRenderer: ${url}\nReal service; OAuth requires your approval. Reuse the sandbox path to reopen this identity.`)
|
||||
electron.on('exit', stop)
|
||||
} catch (error) { stop(); throw error }
|
||||
@@ -109,8 +109,30 @@ export function useComposerSubmit({
|
||||
// button) route through the same send path. Match both the composer target
|
||||
// and the exact visible surface captured at click time — every tile stays
|
||||
// mounted, and a session can be rendered in more than one pane.
|
||||
//
|
||||
// Busy: a request from a card the user just clicked must not be dropped
|
||||
// because the agent is mid-sentence — that gap is exactly when they click.
|
||||
// Steer the live turn (the same stop-and-correct a typed message gets), and
|
||||
// if the turn has already ended, queue it so it runs next.
|
||||
const dispatchSubmitRef = useRef(dispatchSubmit)
|
||||
dispatchSubmitRef.current = dispatchSubmit
|
||||
const steerOrQueueRef = useRef((_text: string) => {})
|
||||
|
||||
steerOrQueueRef.current = (text: string) => {
|
||||
const queue = () => enqueueQueuedPrompt(activeQueueSessionKeyRef.current, { text, attachments: [] })
|
||||
|
||||
if (!onSteer) {
|
||||
queue()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
void Promise.resolve(onSteer(text)).then(accepted => {
|
||||
if (!accepted) {
|
||||
queue()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
useLayoutEffect(
|
||||
() =>
|
||||
@@ -122,10 +144,14 @@ export function useComposerSubmit({
|
||||
paneVisible &&
|
||||
!inputDisabled
|
||||
) {
|
||||
dispatchSubmitRef.current(text, undefined, displayKind)
|
||||
if (busy && displayKind === 'hidden') {
|
||||
steerOrQueueRef.current(text)
|
||||
} else {
|
||||
dispatchSubmitRef.current(text, undefined, displayKind)
|
||||
}
|
||||
}
|
||||
}),
|
||||
[inputDisabled, paneVisible, scope.target, surfaceId]
|
||||
[busy, inputDisabled, paneVisible, scope.target, surfaceId]
|
||||
)
|
||||
|
||||
const submitDraft = () => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
$chatOnboardingThreadIds,
|
||||
endChatOnboardingSolo,
|
||||
pickOnboardingGreeting,
|
||||
startChatOnboardingSolo
|
||||
takeGuideShape
|
||||
} from '@/components/onboarding-chat/assembly'
|
||||
import {
|
||||
$setupSession,
|
||||
@@ -114,8 +114,9 @@ export function useOnboardingKickoff({
|
||||
$newChatProfile.set(SETUP_PROFILE)
|
||||
await ensureGatewayProfile(SETUP_PROFILE)
|
||||
|
||||
startChatOnboardingSolo()
|
||||
window.hermesDesktop?.chatOnboarding?.soloBoot?.()
|
||||
// Idempotent: the gate already took the shape on the tick the guide was
|
||||
// owed, so no full-size shell painted during the profile round trips.
|
||||
takeGuideShape()
|
||||
await loadMachineProfile()
|
||||
|
||||
const seedMessages = buildChatOnboardingSeedMessages(pickOnboardingGreeting(), record.free_tier !== true)
|
||||
|
||||
@@ -37,6 +37,7 @@ import { copyFilePath, revealFile } from '@/store/file-actions'
|
||||
import { $freeTierStatus, FREE_TIER_MODEL } from '@/store/free-tier'
|
||||
import { openFreeTierSignIn } from '@/store/free-tier-sign-in'
|
||||
import { revealFileInTree } from '@/store/layout'
|
||||
import { $onboardingGate, guidedOnboardingActive } from '@/store/onboarding-gate'
|
||||
import { $activeGatewayProfile } from '@/store/profile'
|
||||
import { $projectTree, projectNameForCwd } from '@/store/projects'
|
||||
import {
|
||||
@@ -139,6 +140,12 @@ export function useStatusbarItems({
|
||||
// Backend truth for the free-tier chip. Refreshed on the ambient status
|
||||
// cadence (use-status-snapshot), never polled from here.
|
||||
const freeTier = useStore($freeTierStatus)
|
||||
// The chip is a standing invitation to sign in. During the guided first
|
||||
// launch that invitation lives on the guide's own ready screen; a second
|
||||
// one in the statusbar is a distraction from the chat they are in. The
|
||||
// subscription is what makes the check reactive.
|
||||
useStore($onboardingGate)
|
||||
const guideOwnsSignIn = guidedOnboardingActive()
|
||||
const updateStatus = useStore($updateStatus)
|
||||
const updateApply = useStore($updateApply)
|
||||
const backendUpdateStatus = useStore($backendUpdateStatus)
|
||||
@@ -478,7 +485,7 @@ export function useStatusbarItems({
|
||||
// Shown while a free-tier identity exists and the tier is on: it names the
|
||||
// identity that carries the connectors (and inference when nothing else
|
||||
// does), and it is the persistent way in to the sign-in.
|
||||
hidden: !freeTier?.available,
|
||||
hidden: !freeTier?.available || guideOwnsSignIn,
|
||||
icon: <Codicon name="account" size="0.75rem" />,
|
||||
id: 'free-tier',
|
||||
label: freeTierCopy.providerName,
|
||||
@@ -576,6 +583,7 @@ export function useStatusbarItems({
|
||||
fileMenu.revealInSidebar,
|
||||
freeTier?.available,
|
||||
freeTier?.model,
|
||||
guideOwnsSignIn,
|
||||
gatewayMenuContent,
|
||||
gatewayClassName,
|
||||
gatewayDetail,
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useState } from 'react'
|
||||
|
||||
import { requestComposerSubmit } from '@/app/chat/composer/focus'
|
||||
import { useSessionView } from '@/app/chat/session-view'
|
||||
import { answeredAfter } from '@/lib/chat-messages/parts'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Picked questions, module-scoped: transcript virtualization remounts
|
||||
@@ -50,12 +51,20 @@ export function AskDirective({ attrs, streaming }: { attrs: Record<string, strin
|
||||
const wantsInput = attrs.input === 'true' || attrs.input === 'yes'
|
||||
const [picked, setPicked] = useState<null | string>(() => (settled.has(identity) ? '' : null))
|
||||
|
||||
// A typed reply answers the question too. The card only knew about its own
|
||||
// buttons, so someone who answered in the composer came back to six live
|
||||
// chips under a question they had already dealt with. Any user message
|
||||
// after this one closes the ask.
|
||||
const answeredInComposer = answeredAfter(useStore(view.$messages), messageId)
|
||||
|
||||
const closed = picked !== null || answeredInComposer
|
||||
|
||||
if (!question || (options.length === 0 && !wantsInput)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const submit = (value: string) => {
|
||||
if (picked !== null || streaming || !value.trim()) {
|
||||
if (closed || streaming || !value.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -79,11 +88,11 @@ export function AskDirective({ attrs, streaming }: { attrs: Record<string, strin
|
||||
'max-w-full shrink-0 rounded-full border px-3 py-1.5 text-left text-[12px] whitespace-normal wrap-anywhere transition-colors',
|
||||
picked === option
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: picked !== null
|
||||
: closed
|
||||
? 'border-border/60 text-muted-foreground/50'
|
||||
: 'border-border bg-card hover:border-primary/50 hover:bg-primary/10'
|
||||
)}
|
||||
disabled={picked !== null || streaming}
|
||||
disabled={closed || streaming}
|
||||
key={option}
|
||||
onClick={() => submit(option)}
|
||||
type="button"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ToolCallMessagePartProps } from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { requestComposerSubmit } from '@/app/chat/composer/focus'
|
||||
import { useSessionView } from '@/app/chat/session-view'
|
||||
@@ -12,6 +12,7 @@ import { Loader } from '@/components/ui/loader'
|
||||
import { SearchField } from '@/components/ui/search-field'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { connectionRows, connectorCalls, connectorTitle, connectorToolName, recordOf } from '@/lib/connector-tools'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { createConnectorFlow } from '@/store/connector-flow'
|
||||
import { requestGatewayForAgent } from '@/store/gateway'
|
||||
import { $activeGatewayProfile } from '@/store/profile'
|
||||
@@ -22,19 +23,67 @@ export function ConnectorTool(props: ToolCallMessagePartProps) {
|
||||
const view = useSessionView()
|
||||
const runtimeId = useStore(view.$runtimeId)
|
||||
const storedId = useStore(view.$storedId)
|
||||
const busy = useStore(view.$busy)
|
||||
const messages = useStore(view.$messages)
|
||||
|
||||
const latest = messages
|
||||
// One live card per offer. Every manage_connections call renders through
|
||||
// here, but only ONE is the card the user acts on; the rest are settled
|
||||
// tool rows. Which one: consecutive calls naming the same apps are one
|
||||
// exchange — connect, the wait the agent parks in while the user signs in,
|
||||
// the status it runs when the connection lands — and the FIRST of the last
|
||||
// exchange is the card. The newest would demote the card mid-authorization
|
||||
// into a row and mint a fresh one below it. A catalog listing (status with
|
||||
// nothing named) after a targeted ask never starts an exchange: it is a
|
||||
// read, not an offer.
|
||||
const offers = messages
|
||||
.flatMap(message => message.parts)
|
||||
.filter(
|
||||
part =>
|
||||
part.type === 'tool-call' &&
|
||||
(part.toolName === 'manage_connections' || connectorCalls(part.toolName, part.args).length > 0)
|
||||
)
|
||||
.at(-1)
|
||||
|
||||
const historical = latest?.type === 'tool-call' && latest.toolCallId !== props.toolCallId
|
||||
const keyOf = (part: (typeof offers)[number]) =>
|
||||
part.type === 'tool-call'
|
||||
? connectionRows(part.args, part.result)
|
||||
.map(row => row.connector)
|
||||
.sort()
|
||||
.join('|')
|
||||
: ''
|
||||
|
||||
const targeted = (part: (typeof offers)[number]) => {
|
||||
if (part.type !== 'tool-call') {
|
||||
return false
|
||||
}
|
||||
|
||||
const asked = recordOf(part.args).connectors
|
||||
|
||||
return Array.isArray(asked) && asked.length > 0
|
||||
}
|
||||
|
||||
let liveId: string | undefined
|
||||
let liveKey: string | null = null
|
||||
let sawTargeted = false
|
||||
|
||||
for (const part of offers) {
|
||||
if (part.type !== 'tool-call') {
|
||||
continue
|
||||
}
|
||||
|
||||
const key = keyOf(part)
|
||||
|
||||
if (sawTargeted && !targeted(part)) {
|
||||
continue
|
||||
}
|
||||
|
||||
sawTargeted ||= targeted(part)
|
||||
|
||||
if (key !== liveKey) {
|
||||
liveKey = key
|
||||
liveId = part.toolCallId
|
||||
}
|
||||
}
|
||||
|
||||
const historical = liveId !== props.toolCallId
|
||||
|
||||
const [owner, setOwner] = useState<{
|
||||
storedId: string
|
||||
@@ -75,6 +124,20 @@ export function ConnectorTool(props: ToolCallMessagePartProps) {
|
||||
}, [storedId, runtimeId, historical])
|
||||
const rows = connectionRows(props.args, props.result)
|
||||
const signature = rows.map(row => row.connector).join('|')
|
||||
const target = view.kind === 'tile' ? `tile:${storedId}` : 'main'
|
||||
|
||||
// The TUI shape, stolen: the agent parks inside manage_connections
|
||||
// action="wait", which blocks the turn and polls the gateway, instead of
|
||||
// deciding what "not connected" means and building around the app. Each
|
||||
// card action sends one hidden line so the agent takes the right next call.
|
||||
// Read through a ref so the flow (memoised on identity) always nudges the
|
||||
// live composer target, never the one it was built with. Busy is the
|
||||
// composer's problem: a hidden request mid-turn steers or queues there.
|
||||
const nudgeRef = useRef((_text: string) => {})
|
||||
|
||||
nudgeRef.current = (text: string) => {
|
||||
requestComposerSubmit(`[connectors] ${text}`, { displayKind: 'hidden', target })
|
||||
}
|
||||
|
||||
const flow = useMemo(() => {
|
||||
if (historical || !runtimeId || !owner || owner.storedId !== storedId || owner.runtimeId !== runtimeId) {
|
||||
@@ -91,7 +154,11 @@ export function ConnectorTool(props: ToolCallMessagePartProps) {
|
||||
}
|
||||
|
||||
await window.hermesDesktop.openExternal(url)
|
||||
}
|
||||
},
|
||||
onWaiting: slug =>
|
||||
nudgeRef.current(
|
||||
`The user clicked Connect for ${connectorTitle(slug)} and the sign-in is open in their browser. Call manage_connections action="wait" connectors=["${slug}"] now and hold there until it reports connected. Do NOT call connect again — a second link cancels the one they are signing in with. Say nothing until wait returns.`
|
||||
)
|
||||
})
|
||||
}, [runtimeId, owner, storedId, signature, historical])
|
||||
|
||||
@@ -121,40 +188,29 @@ export function ConnectorTool(props: ToolCallMessagePartProps) {
|
||||
|
||||
return (
|
||||
<ConnectorOffer
|
||||
busy={busy}
|
||||
flow={flow}
|
||||
key={`${runtimeId}:${signature}`}
|
||||
onContinue={async text => {
|
||||
if (!owner || !runtimeId) {
|
||||
throw new Error('Session unavailable')
|
||||
}
|
||||
|
||||
const target = view.kind === 'tile' ? `tile:${storedId}` : 'main'
|
||||
|
||||
if (!requestComposerSubmit(text, { target })) {
|
||||
throw new Error('Composer unavailable')
|
||||
}
|
||||
}}
|
||||
onSkipped={slug =>
|
||||
nudgeRef.current(
|
||||
`The user chose Not now for ${connectorTitle(slug)}. Do not connect it, do not route around it with another client, credential or CLI for the same app. Continue the task without it, or ask what they want to do.`
|
||||
)
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface ConnectorOfferProps {
|
||||
flow: ReturnType<typeof createConnectorFlow>
|
||||
busy: boolean
|
||||
onContinue: (text: string) => Promise<void>
|
||||
/** The user waved the app off. */
|
||||
onSkipped: (slug: string) => void
|
||||
}
|
||||
|
||||
export function ConnectorOffer({ flow, busy, onContinue }: ConnectorOfferProps) {
|
||||
export function ConnectorOffer({ flow, onSkipped }: ConnectorOfferProps) {
|
||||
const state = useStore(flow.state)
|
||||
const { t } = useI18n()
|
||||
const copy = t.connectors
|
||||
const [query, setQuery] = useState('')
|
||||
const [continuing, setContinuing] = useState(false)
|
||||
const [continued, setContinued] = useState(false)
|
||||
const [continueError, setContinueError] = useState(false)
|
||||
const active = state.rows.some(row => row.phase === 'opening' || row.phase === 'waiting')
|
||||
const decided = state.rows.some(row => row.phase === 'connected' || row.phase === 'skipped')
|
||||
|
||||
const cardCopy: ConnectorCardCopy = {
|
||||
connectAction: copy.connect,
|
||||
@@ -179,41 +235,64 @@ export function ConnectorOffer({ flow, busy, onContinue }: ConnectorOfferProps)
|
||||
}
|
||||
|
||||
const rows = state.rows.filter(row => connectorTitle(row.connector).toLowerCase().includes(query.toLowerCase()))
|
||||
// A targeted ask ("connect Gmail") is one or two cards, each already a
|
||||
// complete question. A heading, a disclaimer and a refresh control over
|
||||
// them is a settings panel dropped into the chat. Only a real catalog — the
|
||||
// model asked for status with nothing named — earns the chrome.
|
||||
const catalog = state.rows.length > 4
|
||||
|
||||
return (
|
||||
<div className="my-2 grid min-w-0 max-w-lg gap-3" data-connector-offer>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium">{copy.title}</span>
|
||||
<Button onClick={() => void flow.refresh()} size="xs" variant="text">
|
||||
{copy.refresh}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{copy.disclaimer}</p>
|
||||
<div className="my-2 grid min-w-0 max-w-lg gap-1" data-connector-offer>
|
||||
{catalog ? (
|
||||
<div className="grid gap-0.5 px-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium">{copy.title}</span>
|
||||
<Button onClick={() => void flow.refresh()} size="xs" variant="text">
|
||||
{copy.refresh}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{copy.disclaimer}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{state.error ? (
|
||||
<p className="text-xs text-destructive" role="alert">
|
||||
<p className="flex flex-wrap items-center gap-2 px-1 text-xs text-destructive" role="alert">
|
||||
{copy.statusError}
|
||||
<Button onClick={() => void flow.refresh()} size="xs" variant="text">
|
||||
{copy.retry}
|
||||
</Button>
|
||||
</p>
|
||||
) : null}
|
||||
{!state.available && !state.error ? <p className="text-xs text-muted-foreground">{copy.unavailable}</p> : null}
|
||||
{state.rows.length > 6 ? <SearchField onChange={setQuery} placeholder={copy.search} value={query} /> : null}
|
||||
<div className="grid max-h-96 min-w-0 gap-3 overflow-y-auto">
|
||||
{!state.available && !state.error ? <p className="px-1 text-xs text-muted-foreground">{copy.unavailable}</p> : null}
|
||||
{catalog ? <SearchField onChange={setQuery} placeholder={copy.search} value={query} /> : null}
|
||||
<div className={cn('grid min-w-0', catalog && 'max-h-96 overflow-y-auto')}>
|
||||
{rows.map(row => (
|
||||
<div className="grid gap-1" key={row.connector}>
|
||||
<div className="grid" key={row.connector}>
|
||||
<ConnectorCard
|
||||
actionDisabled={!state.available || row.enabled === false || !!state.error}
|
||||
collapseWhenSettled={false}
|
||||
connector={{
|
||||
name: row.connector,
|
||||
title: row.name || connectorTitle(row.connector),
|
||||
description: row.description
|
||||
description: row.description || copy.describe(row.name || connectorTitle(row.connector))
|
||||
}}
|
||||
copy={{
|
||||
...cardCopy,
|
||||
connectTitle: copy.connectTitle,
|
||||
decline: row.phase === 'opening' || row.phase === 'waiting' ? copy.cancel : copy.skip,
|
||||
connectAction: ['expired', 'revoked'].includes(row.connectionStatus ?? '') ? copy.grant : copy.connect
|
||||
}}
|
||||
dismissed={row.phase === 'skipped'}
|
||||
onConnect={() => void flow.connect(row.connector)}
|
||||
onDismiss={() => flow.skip(row.connector)}
|
||||
onDismiss={() => {
|
||||
const wasPending = ['opening', 'waiting'].includes(row.phase)
|
||||
flow.skip(row.connector)
|
||||
|
||||
// A cancel mid-authorization is not a skip: the agent may be
|
||||
// parked in wait and will hear the timeout itself.
|
||||
if (!wasPending) {
|
||||
onSkipped(row.connector)
|
||||
}
|
||||
}}
|
||||
otherBusy={active && !['opening', 'waiting'].includes(row.phase)}
|
||||
outcome={
|
||||
row.phase === 'connected'
|
||||
@@ -238,9 +317,10 @@ export function ConnectorOffer({ flow, busy, onContinue }: ConnectorOfferProps)
|
||||
? 'needs_auth'
|
||||
: 'not_configured'
|
||||
}
|
||||
variant="avatar"
|
||||
/>
|
||||
{row.phase === 'timeout' ? (
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<div className="flex flex-wrap items-center gap-2 px-3.5 text-xs text-muted-foreground">
|
||||
<span>{copy.timeout}</span>
|
||||
<Button onClick={() => void flow.keepWaiting(row.connector)} size="xs" variant="textStrong">
|
||||
{copy.keepWaiting}
|
||||
@@ -249,42 +329,8 @@ export function ConnectorOffer({ flow, busy, onContinue }: ConnectorOfferProps)
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
{!rows.length && state.available ? <p className="text-xs text-muted-foreground">{copy.empty}</p> : null}
|
||||
{!rows.length && state.available ? <p className="px-1 text-xs text-muted-foreground">{copy.empty}</p> : null}
|
||||
</div>
|
||||
{decided && !continued ? (
|
||||
<div>
|
||||
<Button
|
||||
disabled={busy || active || continuing}
|
||||
onClick={() => {
|
||||
setContinuing(true)
|
||||
setContinueError(false)
|
||||
|
||||
const connected = state.rows
|
||||
.filter(row => row.phase === 'connected')
|
||||
.map(row => connectorTitle(row.connector))
|
||||
|
||||
const skipped = state.rows
|
||||
.filter(row => row.phase === 'skipped')
|
||||
.map(row => connectorTitle(row.connector))
|
||||
|
||||
void onContinue(
|
||||
`Continue the task. Connected apps: ${connected.join(', ') || 'none'}. Continue without: ${skipped.join(', ') || 'none'}. Use current connector status before accessing anything.`
|
||||
)
|
||||
.then(() => setContinued(true))
|
||||
.catch(() => setContinueError(true))
|
||||
.finally(() => setContinuing(false))
|
||||
}}
|
||||
size="sm"
|
||||
>
|
||||
{busy ? copy.continueBusy : copy.continue}
|
||||
</Button>
|
||||
{continueError ? (
|
||||
<p className="text-xs text-destructive" role="alert">
|
||||
{copy.continueFailed}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,15 +2,13 @@
|
||||
|
||||
import { type ToolCallMessagePartProps, useAuiState } from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { capabilityScoped } from '@/api/client'
|
||||
import { useSessionView } from '@/app/chat/session-view'
|
||||
import { ToolFallback } from '@/components/assistant-ui/tool/fallback'
|
||||
import { WIDGET_SHELL_CLASS } from '@/components/chat/widget-shell'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ConnectorCard, type ConnectorCardCopy, ConnectorSummary } from '@/components/ui/connector-card'
|
||||
import {
|
||||
addMcpServer,
|
||||
getActionStatus,
|
||||
@@ -22,8 +20,7 @@ import {
|
||||
} from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { AlertCircle, CheckCircle2, Loader2 } from '@/lib/icons'
|
||||
import { brandFor, brandGlyphStyle } from '@/lib/mcp-brands'
|
||||
import { Loader2 } from '@/lib/icons'
|
||||
import { completeMcpDesktopOAuth, McpOAuthCancelled } from '@/lib/mcp-dashboard-oauth'
|
||||
import { directoryEntry } from '@/lib/mcp-directory'
|
||||
import { prettyName } from '@/lib/text'
|
||||
@@ -74,18 +71,29 @@ function readSetupResult(result: unknown): SettledResult {
|
||||
|
||||
const SHELL_CLASS = `${WIDGET_SHELL_CLASS} text-[length:var(--conversation-text-font-size)] text-(--ui-text-primary)`
|
||||
|
||||
// Same platform sniff the approval bar uses for its accelerator hint.
|
||||
const isMac = typeof navigator !== 'undefined' && /Mac|iP(hone|ad|od)/.test(navigator.platform)
|
||||
|
||||
const ICON_CLASS = 'mt-px size-4 shrink-0 text-(--ui-text-tertiary)'
|
||||
|
||||
function SetupLine({ children, trailing }: { children: ReactNode; trailing?: ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="min-w-0 flex-1">{children}</div>
|
||||
{trailing}
|
||||
</div>
|
||||
)
|
||||
/** The card's strings, from this tool's own copy. The verb changes with the
|
||||
* action (Install / Enable / Authorize); the rest is the shared consent
|
||||
* vocabulary every connector card speaks. */
|
||||
function cardCopy(copy: ReturnType<typeof useI18n>['t']['assistant']['mcpSetup'], action: SetupAction): ConnectorCardCopy {
|
||||
return {
|
||||
connectAction: action === 'enable' ? copy.enableAction : action === 'authorize' ? copy.authorizeAction : copy.installAction,
|
||||
connectTitle:
|
||||
action === 'enable' ? copy.enableTitle : action === 'authorize' ? copy.authorizeTitle : copy.installTitle,
|
||||
decline: copy.decline,
|
||||
envRequired: copy.envRequired,
|
||||
grantAction: copy.authorizeAction,
|
||||
retryAction: copy.installAction,
|
||||
stateConnected: '',
|
||||
stateDeclined: copy.declined,
|
||||
stateDisabled: '',
|
||||
stateFailed: '',
|
||||
stateNeedsAuth: '',
|
||||
toolCount: copy.toolCount,
|
||||
trustCommunity: '',
|
||||
trustCommunityTip: () => '',
|
||||
trustVerified: () => '',
|
||||
trustVerifiedTip: () => ''
|
||||
}
|
||||
}
|
||||
|
||||
export const McpSetupTool = (props: ToolCallMessagePartProps) => {
|
||||
@@ -134,30 +142,21 @@ function McpSetupSettled({ args, result }: ToolCallMessagePartProps) {
|
||||
const ok = status === 'installed' || status === 'enabled' || status === 'authorized'
|
||||
const neutral = status === 'declined' || status === 'unanswered'
|
||||
const toolCount = Array.isArray(fromResult.tools) ? fromResult.tools.length : 0
|
||||
const brand = brandFor(server)
|
||||
|
||||
// Settled is scaffolding, the same line a spent connector offer collapses
|
||||
// to: the name, then the verdict as meta. A failure keeps its reason.
|
||||
return (
|
||||
<div className={cn(SHELL_CLASS, 'my-1.5 grid gap-1.5')} data-slot="mcp-setup-inline">
|
||||
<SetupLine
|
||||
trailing={
|
||||
ok ? (
|
||||
<CheckCircle2 aria-hidden className={cn(ICON_CLASS, 'text-emerald-400')} />
|
||||
) : neutral && brand ? (
|
||||
<brand.Icon aria-hidden className="mt-px size-4 shrink-0 opacity-60" style={brandGlyphStyle(brand)} />
|
||||
) : neutral ? (
|
||||
<Codicon className={ICON_CLASS} name="plug" size="1rem" />
|
||||
) : (
|
||||
<AlertCircle aria-hidden className={cn(ICON_CLASS, 'text-destructive')} />
|
||||
)
|
||||
}
|
||||
>
|
||||
<span className={cn('font-medium', neutral && 'italic text-(--ui-text-tertiary)')}>{line}</span>
|
||||
{ok && toolCount > 0 && <span className="ml-2 text-(--ui-text-tertiary)">{copy.toolCount(toolCount)}</span>}
|
||||
{!ok && !neutral && fromResult.detail ? (
|
||||
<p className="mt-0.5 text-(--ui-text-secondary)">{fromResult.detail}</p>
|
||||
) : null}
|
||||
</SetupLine>
|
||||
</div>
|
||||
<ConnectorSummary
|
||||
connector={{ name: server, title: displayName }}
|
||||
meta={
|
||||
ok && toolCount > 0
|
||||
? `${line} · ${copy.toolCount(toolCount)}`
|
||||
: !ok && !neutral && fromResult.detail
|
||||
? `${line} — ${fromResult.detail}`
|
||||
: line
|
||||
}
|
||||
tone={ok ? 'ok' : neutral ? undefined : 'error'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -382,29 +381,14 @@ function McpSetupPending({ args }: ToolCallMessagePartProps) {
|
||||
}
|
||||
}, [action, copy, entry, envDraft, respond, server])
|
||||
|
||||
const title =
|
||||
action === 'enable'
|
||||
? copy.enableTitle(prettyName(server))
|
||||
: action === 'authorize'
|
||||
? copy.authorizeTitle(prettyName(server))
|
||||
: copy.installTitle(prettyName(server))
|
||||
|
||||
const actionLabel =
|
||||
action === 'enable' ? copy.enableAction : action === 'authorize' ? copy.authorizeAction : copy.installAction
|
||||
const displayName = prettyName(server)
|
||||
const card = cardCopy(copy, action)
|
||||
|
||||
// What connecting actually means — the endpoint that will be contacted.
|
||||
// VS Code's trust dialog links the config it's about to trust; same idea.
|
||||
// Catalog entries carry their transport URL in the API response; the
|
||||
// static directory remains a fallback rung for older backends.
|
||||
const known = directoryEntry(server)
|
||||
const sourceLine = action === 'install' ? (entry?.url ?? known?.url ?? copy.catalogSource) : null
|
||||
const brand = brandFor(server)
|
||||
|
||||
const trailingIcon = brand ? (
|
||||
<brand.Icon aria-hidden className="mt-px size-4 shrink-0" style={brandGlyphStyle(brand)} />
|
||||
) : (
|
||||
<Codicon className={ICON_CLASS} name="plug" size="1rem" />
|
||||
)
|
||||
|
||||
// ⌘/Ctrl+Enter → approve, Esc → decline/cancel. Same accelerators, same
|
||||
// guard shape as the approval bar (tool/approval.tsx). Unlike approve, Esc
|
||||
@@ -449,67 +433,35 @@ function McpSetupPending({ args }: ToolCallMessagePartProps) {
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className={cn(SHELL_CLASS, 'my-1.5 flex items-center gap-2')} data-slot="mcp-setup-inline">
|
||||
<div className={cn(SHELL_CLASS, 'my-1.5 flex items-center gap-2')} data-slot="connector-card">
|
||||
<Loader2 aria-hidden className="size-4 animate-spin text-(--ui-text-tertiary)" />
|
||||
<span className="text-(--ui-text-tertiary)">{title}</span>
|
||||
<span className="text-(--ui-text-tertiary)">{card.connectTitle?.(displayName)}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// The same consent card the connector offer renders: one shape for every
|
||||
// "connect this?" in the transcript. `phase` is what flips the card into
|
||||
// its working state (spinner on the action, decline becomes cancel).
|
||||
return (
|
||||
<div className={cn(SHELL_CLASS, 'my-1.5 grid gap-1.5')} data-slot="mcp-setup-inline">
|
||||
<SetupLine trailing={trailingIcon}>
|
||||
<span className="font-medium leading-(--conversation-line-height)">{title}</span>
|
||||
{reason ? <p className="mt-0.5 text-(--ui-text-secondary)">{reason}</p> : null}
|
||||
{sourceLine && <p className="mt-0.5 truncate text-[0.6875rem] text-(--ui-text-tertiary)">{sourceLine}</p>}
|
||||
</SetupLine>
|
||||
{envOpen && entry && entry.required_env.length > 0 && (
|
||||
<div className="grid gap-2" data-slot="mcp-setup-env">
|
||||
<p className="text-[0.6875rem] text-(--ui-text-tertiary)">{copy.envRequired}</p>
|
||||
{entry.required_env.map(env => (
|
||||
<label className="grid gap-1" key={env.name}>
|
||||
<span className="text-[0.6875rem] text-(--ui-text-secondary)">
|
||||
{env.prompt || env.name}
|
||||
{env.required ? ' *' : ''}
|
||||
</span>
|
||||
<Input
|
||||
className="h-7 text-xs"
|
||||
onChange={event => setEnvDraft(prev => ({ ...prev, [env.name]: event.currentTarget.value }))}
|
||||
type="password"
|
||||
value={envDraft[env.name] ?? ''}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Same strip as the tool approval bar (tool/approval.tsx): a bordered
|
||||
primary-tinted action plus a quiet ghost decline, with the matching
|
||||
keyboard hints. One consent vocabulary across the transcript. */}
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="inline-flex h-6 items-stretch overflow-hidden rounded-md border border-primary/25 bg-primary/10 text-primary">
|
||||
<Button
|
||||
className="h-full gap-1 rounded-none px-2 text-xs font-medium text-primary hover:bg-primary/15 hover:text-primary"
|
||||
disabled={working}
|
||||
onClick={() => void approve()}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
>
|
||||
{working ? <Loader2 className="size-3 animate-spin" /> : actionLabel}
|
||||
{!working && <span className="text-[0.625rem] text-primary/60">{isMac ? '⌘⏎' : 'Ctrl⏎'}</span>}
|
||||
</Button>
|
||||
</div>
|
||||
{/* Never disabled: while a flow is in flight this is the cancel —
|
||||
a stuck OAuth tab or hung install must always have a way out. */}
|
||||
<Button
|
||||
className="h-6 gap-1.5 rounded-md px-1.5 text-xs font-normal text-(--ui-text-tertiary) hover:text-foreground"
|
||||
onClick={decline}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
>
|
||||
{working ? t.common.cancel : copy.decline}
|
||||
<span className="text-[0.625rem] opacity-55">Esc</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<ConnectorCard
|
||||
accelerators
|
||||
connector={{
|
||||
description: reason || undefined,
|
||||
name: server,
|
||||
requiredEnv: entry?.required_env,
|
||||
title: displayName
|
||||
}}
|
||||
copy={{ ...card, decline: working ? t.common.cancel : card.decline }}
|
||||
envDraft={envDraft}
|
||||
envOpen={envOpen && !!entry && entry.required_env.length > 0}
|
||||
onConnect={() => void approve()}
|
||||
onDismiss={decline}
|
||||
onEnvChange={(key, value) => setEnvDraft(prev => ({ ...prev, [key]: value }))}
|
||||
phase={working ? '' : undefined}
|
||||
source={sourceLine ? { text: sourceLine } : undefined}
|
||||
state="not_configured"
|
||||
variant="avatar"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { AlertCircle, ChevronDown, Loader2 } from '@/lib/icons'
|
||||
import { AlertCircle, ChevronDown } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
@@ -204,12 +204,13 @@ const ApprovalBar: FC<{ request: ApprovalRequest; surface: 'floating' | 'inline'
|
||||
<Button
|
||||
className="h-full gap-1 rounded-none px-2 text-xs font-medium text-primary hover:bg-primary/15 hover:text-primary"
|
||||
disabled={busy}
|
||||
loading={submitting === 'once'}
|
||||
onClick={() => void respond('once')}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
>
|
||||
{submitting === 'once' ? <Loader2 className="size-3 animate-spin" /> : copy.run}
|
||||
{submitting !== 'once' && <span className="text-[0.625rem] text-primary/60">{isMac ? '⌘⏎' : 'Ctrl⏎'}</span>}
|
||||
{copy.run}
|
||||
<span className="text-[0.625rem] text-primary/60">{isMac ? '⌘⏎' : 'Ctrl⏎'}</span>
|
||||
</Button>
|
||||
{hasMoreOptions && <span aria-hidden className="w-px self-stretch bg-primary/20" />}
|
||||
{hasMoreOptions && (
|
||||
@@ -252,12 +253,13 @@ const ApprovalBar: FC<{ request: ApprovalRequest; surface: 'floating' | 'inline'
|
||||
<Button
|
||||
className="h-6 gap-1.5 rounded-md px-1.5 text-xs font-normal text-(--ui-text-tertiary) hover:text-foreground"
|
||||
disabled={busy}
|
||||
loading={submitting === 'deny'}
|
||||
onClick={() => void respond('deny')}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
>
|
||||
{submitting === 'deny' ? <Loader2 className="size-3 animate-spin" /> : copy.reject}
|
||||
{submitting !== 'deny' && <span className="text-[0.625rem] opacity-55">Esc</span>}
|
||||
{copy.reject}
|
||||
<span className="text-[0.625rem] opacity-55">Esc</span>
|
||||
</Button>
|
||||
|
||||
{hasCommand && (
|
||||
|
||||
@@ -6,6 +6,7 @@ import { prefersReducedMotion } from '@/hooks/use-media-query'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $desktopBoot } from '@/store/boot'
|
||||
import { $gatewaySwitching } from '@/store/gateway-switch'
|
||||
import { guidedOnboardingActive } from '@/store/onboarding-gate'
|
||||
import { $gatewayState } from '@/store/session'
|
||||
|
||||
// Decode mechanics live in the shared <DecodeText> primitive
|
||||
@@ -132,6 +133,15 @@ export function GatewayConnectingOverlay() {
|
||||
return null
|
||||
}
|
||||
|
||||
// The guided first launch has its own opening (the film, then the typed
|
||||
// greeting in a small window). "Connecting…" over it, then "Connected to
|
||||
// localhost", is the app's boot narrating itself in the middle of the
|
||||
// guide's; the guide's surface stays, this one yields. Boot progress still
|
||||
// gates the transcript underneath — nothing paints early.
|
||||
if (!previewing && guidedOnboardingActive()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const leaving = phase !== 'live'
|
||||
const overlayHidden = phase === 'overlay-out' || phase === 'gone'
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { takeGuideShape } from '@/components/onboarding-chat/assembly'
|
||||
import {
|
||||
$introReveal,
|
||||
finishIntroReveal,
|
||||
@@ -36,10 +37,12 @@ export function IntroRevealGate({ enabled }: IntroRevealGateProps) {
|
||||
}
|
||||
|
||||
// Observe the store edge directly: a failed native open can finish before
|
||||
// React renders the playing phase.
|
||||
// React renders the playing phase. Take the guide's shape on the same
|
||||
// tick — finishIntroReveal shows the main window right after this fires.
|
||||
return $introReveal.listen((state, previous) => {
|
||||
if (state.phase === 'hidden' && previous?.phase !== 'hidden') {
|
||||
queueGuideAfterIntro()
|
||||
takeGuideShape()
|
||||
}
|
||||
})
|
||||
}, [enabled])
|
||||
|
||||
@@ -65,6 +65,22 @@ export const $chatLayoutPicked = atom(false)
|
||||
|
||||
let previousLayout: { id: string; tree: LayoutNode | null } | null = null
|
||||
|
||||
/** The guide's shape, all at once: the solo layout and the small centred
|
||||
* window. Called on the tick the guide is owed (film ended, or a boot that
|
||||
* finds the guide queued) so no full-size frame paints in between. */
|
||||
export function takeGuideShape(): void {
|
||||
if ($chatOnboardingSolo.get()) {
|
||||
return
|
||||
}
|
||||
|
||||
startChatOnboardingSolo()
|
||||
|
||||
// startChatOnboardingSolo declines when the guide is off; shrink only when it took.
|
||||
if ($chatOnboardingSolo.get()) {
|
||||
window.hermesDesktop?.chatOnboarding?.soloBoot?.()
|
||||
}
|
||||
}
|
||||
|
||||
export function startChatOnboardingSolo(): void {
|
||||
if (!isOnboardingEnabled() || $chatOnboardingSolo.get()) {
|
||||
return
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { useAuiState } from '@assistant-ui/react'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
|
||||
import { requestComposerSubmit } from '@/app/chat/composer/focus'
|
||||
import { useSessionView } from '@/app/chat/session-view'
|
||||
@@ -27,8 +27,10 @@ import {
|
||||
SETUP_PROFILE
|
||||
} from '@/components/onboarding-chat/setup-profile'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { answeredAfter } from '@/lib/chat-messages/parts'
|
||||
import { segmentTranscriptDirectives } from '@/lib/transcript-directives'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $onboardingAnswers, markStepCommitted } from '@/store/onboarding-answers'
|
||||
import { assertSessionOwnerResolved } from '@/store/session-owner-resolution'
|
||||
import { isSessionOwnerRoute } from '@/store/session-request-router'
|
||||
|
||||
@@ -46,7 +48,16 @@ export function FirstBuildCard({ attrs, locked }: CardProps) {
|
||||
const view = useSessionView()
|
||||
const storedId = useStore(view.$storedId)
|
||||
const target = view.kind === 'tile' ? `tile:${storedId}` : 'main'
|
||||
const [picked, setPicked] = useState<null | string>(null)
|
||||
// The pick lives with the other answers, not in component state: the
|
||||
// visible submit rebuilds the transcript and a local flag came back null,
|
||||
// leaving every chip clickable after one had already been sent. A typed
|
||||
// reply in the composer closes the card the same way a chip does.
|
||||
const messageId = useAuiState(state => state.message.id)
|
||||
|
||||
const answeredInComposer = answeredAfter(useStore(view.$messages), messageId)
|
||||
|
||||
const committed = useStore($onboardingAnswers).committed.find(step => step.startsWith('first:'))?.slice(6) ?? null
|
||||
const picked = committed ?? (answeredInComposer ? '' : null)
|
||||
|
||||
// Parse + validate the model's options: up to 4, each short enough to sit on
|
||||
// a chip, deduped case-insensitively (models repeat themselves). Garbage in
|
||||
@@ -73,12 +84,12 @@ export function FirstBuildCard({ attrs, locked }: CardProps) {
|
||||
const options = parsed.length < 2 ? [FALLBACK_OPTION] : parsed
|
||||
|
||||
const pick = (option: string) => {
|
||||
if (picked || locked) {
|
||||
if (picked !== null || locked) {
|
||||
return
|
||||
}
|
||||
|
||||
if (requestComposerSubmit(option, { target })) {
|
||||
setPicked(option)
|
||||
markStepCommitted(`first:${option}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
*/
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { requestComposerSubmit } from '@/app/chat/composer/focus'
|
||||
import { useSessionView } from '@/app/chat/session-view'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $onboardingAnswers, markStepCommitted } from '@/store/onboarding-answers'
|
||||
|
||||
export interface CardProps {
|
||||
/** The directive's raw attrs — the model-written payload. */
|
||||
@@ -19,17 +19,20 @@ export interface CardProps {
|
||||
locked: boolean
|
||||
}
|
||||
|
||||
export function useCardCommit() {
|
||||
/** `step` names the card so Done survives the card remounting: the hidden
|
||||
* submit and the turn-end hydrate both rebuild the transcript, and a flag in
|
||||
* component state came back false each time. */
|
||||
export function useCardCommit(step: string) {
|
||||
const view = useSessionView()
|
||||
const storedId = useStore(view.$storedId)
|
||||
const target = view.kind === 'tile' ? `tile:${storedId}` : 'main'
|
||||
const [done, setDone] = useState(false)
|
||||
const done = useStore($onboardingAnswers).committed.includes(step)
|
||||
|
||||
const commit = (summary: string): boolean => {
|
||||
const sent = requestComposerSubmit(`[setup] ${summary}`, { displayKind: 'hidden', target })
|
||||
|
||||
if (sent) {
|
||||
setDone(true)
|
||||
markStepCommitted(step)
|
||||
}
|
||||
|
||||
return sent
|
||||
@@ -43,12 +46,16 @@ export function useCardCommit() {
|
||||
* read as a form. */
|
||||
export function CardFrame({
|
||||
children,
|
||||
continueLabel = 'Continue',
|
||||
disabled = false,
|
||||
done,
|
||||
locked = false,
|
||||
onContinue
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
/** The action, named for what it does when the default reads as a shrug —
|
||||
* "Continue with 2" tells them the picks registered. */
|
||||
continueLabel?: string
|
||||
disabled?: boolean
|
||||
done: boolean
|
||||
locked?: boolean
|
||||
@@ -71,7 +78,7 @@ export function CardFrame({
|
||||
onClick={onContinue}
|
||||
size="sm"
|
||||
>
|
||||
{done ? '✓ Done' : 'Continue'}
|
||||
{done ? '✓ Done' : continueLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,28 +6,45 @@
|
||||
*/
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { useSessionView } from '@/app/chat/session-view'
|
||||
import { $chatLayoutPicked, assembleChatOnboarding } from '@/components/onboarding-chat/assembly'
|
||||
import { CardFrame, type CardProps, useCardCommit } from '@/components/onboarding-chat/cards/frame'
|
||||
import { Chip } from '@/components/onboarding-chat/chip'
|
||||
import {
|
||||
accentsFor,
|
||||
AccentSwatch,
|
||||
CONNECTORS,
|
||||
LayoutPreviewCard,
|
||||
LAYOUTS,
|
||||
NOUS_ACCENT
|
||||
NOUS_ACCENT,
|
||||
orderConnectorPicks
|
||||
} from '@/components/onboarding-chat/options'
|
||||
import type { LayoutNode } from '@/components/pane-shell/tree/model'
|
||||
import { ConnectorLogo } from '@/components/ui/connector-logo'
|
||||
import { SearchField } from '@/components/ui/search-field'
|
||||
import { registry } from '@/contrib/registry'
|
||||
import { connectorTitle } from '@/lib/connector-tools'
|
||||
import { useConnectorCatalog } from '@/store/connector-catalog'
|
||||
import { $onboardingAnswers, setOnboardingAnswers } from '@/store/onboarding-answers'
|
||||
import { useTheme } from '@/themes'
|
||||
import { setAccentOverride } from '@/themes/accent-override'
|
||||
|
||||
export function ConnectorsCard({ locked }: CardProps) {
|
||||
const view = useSessionView()
|
||||
const storedId = useStore(view.$storedId)
|
||||
const runtimeId = useStore(view.$runtimeId)
|
||||
const answers = useStore($onboardingAnswers)
|
||||
const { commit, done } = useCardCommit()
|
||||
const { commit, done } = useCardCommit('connectors')
|
||||
const catalog = useConnectorCatalog(storedId, runtimeId)
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
// Only what the gateway actually carries. A pick is a slug the build chat
|
||||
// can hand straight to manage_connections; a name with nothing behind it
|
||||
// is a promise it has to walk back.
|
||||
const rows = useMemo(() => (catalog.status === 'ready' ? orderConnectorPicks(catalog.rows) : []), [catalog])
|
||||
const shown = rows.filter(row => connectorTitle(row.connector).toLowerCase().includes(query.toLowerCase()))
|
||||
const picked = rows.filter(row => answers.connectors.includes(row.connector))
|
||||
|
||||
const toggle = (id: string) =>
|
||||
setOnboardingAnswers({
|
||||
@@ -36,34 +53,62 @@ export function ConnectorsCard({ locked }: CardProps) {
|
||||
: [...answers.connectors, id]
|
||||
})
|
||||
|
||||
// Nothing to pick from: the toolset is off or the gateway is unreachable.
|
||||
// The step still has to end, so it ends honestly.
|
||||
if (catalog.status === 'unavailable' || (catalog.status === 'ready' && rows.length === 0)) {
|
||||
return (
|
||||
<CardFrame continueLabel="Skip this" done={done} locked={locked} onContinue={() => commit('apps I use: none for now')}>
|
||||
<p className="text-sm text-muted-foreground">Connections aren’t available right now — this can be set up later.</p>
|
||||
</CardFrame>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<CardFrame
|
||||
continueLabel={picked.length > 0 ? `Continue with ${picked.length}` : 'None of these'}
|
||||
disabled={catalog.status === 'loading'}
|
||||
done={done}
|
||||
locked={locked}
|
||||
onContinue={() => {
|
||||
const picked = CONNECTORS.filter(connector => answers.connectors.includes(connector.id))
|
||||
|
||||
commit(
|
||||
`apps I use, not connected yet: ${picked.length > 0 ? picked.map(c => c.name).join(', ') : 'none for now'}`
|
||||
`apps I use, not connected yet: ${picked.length > 0 ? picked.map(row => row.connector).join(', ') : 'none for now'}`
|
||||
)
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{CONNECTORS.map(connector => (
|
||||
<Chip
|
||||
icon={
|
||||
<ConnectorLogo
|
||||
className="size-7 rounded-full text-sm"
|
||||
connector={{ homepage: connector.homepage, name: connector.id, title: connector.name }}
|
||||
{catalog.status === 'loading' ? (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{Array.from({ length: 9 }, (_, index) => (
|
||||
<div className="h-10 animate-pulse rounded-lg bg-muted/40" key={index} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{rows.length > 12 ? <SearchField onChange={setQuery} placeholder="Find an app" value={query} /> : null}
|
||||
<div className="grid max-h-72 grid-cols-3 gap-2 overflow-y-auto">
|
||||
{shown.map(row => (
|
||||
<Chip
|
||||
icon={
|
||||
<ConnectorLogo
|
||||
className="size-7 rounded-full text-sm"
|
||||
connector={{ name: row.connector, title: row.name || connectorTitle(row.connector) }}
|
||||
/>
|
||||
}
|
||||
key={row.connector}
|
||||
label={row.name || connectorTitle(row.connector)}
|
||||
on={answers.connectors.includes(row.connector)}
|
||||
onToggle={() => toggle(row.connector)}
|
||||
/>
|
||||
}
|
||||
key={connector.id}
|
||||
label={connector.name}
|
||||
on={answers.connectors.includes(connector.id)}
|
||||
onToggle={() => toggle(connector.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{/* Picking is a preference, not an authorization: nothing *** signed into
|
||||
here. Saying so is what keeps the Connect cards later from reading as
|
||||
a second ask for the same thing. */}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<strong className="font-medium text-foreground">Nothing connects yet.</strong> Hermes will offer to link these
|
||||
when a task needs them, and asks before reading anything.
|
||||
</p>
|
||||
</CardFrame>
|
||||
)
|
||||
}
|
||||
@@ -71,7 +116,7 @@ export function ConnectorsCard({ locked }: CardProps) {
|
||||
export function LookCard({ locked }: CardProps) {
|
||||
const answers = useStore($onboardingAnswers)
|
||||
const { renderedMode } = useTheme()
|
||||
const { commit, done } = useCardCommit()
|
||||
const { commit, done } = useCardCommit('look')
|
||||
const accents = accentsFor(renderedMode === 'dark')
|
||||
const accent = answers.accent ?? NOUS_ACCENT
|
||||
const picked = accents.find(swatch => swatch.hex === accent.toLowerCase())
|
||||
@@ -102,7 +147,7 @@ export function LookCard({ locked }: CardProps) {
|
||||
|
||||
export function LayoutCard({ locked }: CardProps) {
|
||||
const answers = useStore($onboardingAnswers)
|
||||
const { commit, done } = useCardCommit()
|
||||
const { commit, done } = useCardCommit('layout')
|
||||
// The stored answer defaults to 'basic', but the CHOICE is the point of this
|
||||
// step — nothing renders selected (and Continue stays off) until they click.
|
||||
// Store-backed: the pick's own layout apply remounts this card (the pane
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { takeGuideShape } from '@/components/onboarding-chat/assembly'
|
||||
import { isOnboardingEnabled } from '@/lib/onboarding-enabled'
|
||||
import { ackFreeTierNotice, type FreeTierRequester } from '@/store/free-tier'
|
||||
import { $introReveal } from '@/store/intro-reveal'
|
||||
@@ -17,22 +18,53 @@ export function OnboardingChatGate({ enabled, onKickoff, requestGateway }: Onboa
|
||||
const gate = useStore($onboardingGate)
|
||||
const intro = useStore($introReveal)
|
||||
|
||||
// A guide is owed the moment the renderer knows it (cinematic with the film
|
||||
// seen, or a relaunch mid-guide). Take the solo shape NOW, before the
|
||||
// gateway opens — otherwise the normal shell paints at full size for the
|
||||
// seconds the backend takes to come up, and then snaps down to the guide.
|
||||
useEffect(() => {
|
||||
if (gate.guideQueued && intro.phase === 'hidden') {
|
||||
takeGuideShape()
|
||||
}
|
||||
// Once, on mount: the queued flag is a boot fact, not a live signal.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !isOnboardingEnabled()) {
|
||||
return
|
||||
}
|
||||
|
||||
// The guide is the free tier's introduction, whichever way it opens: the
|
||||
// film, or the guided chat directly when the film is skipped. Ack the
|
||||
// one-time notice as soon as either takes the screen, or a readiness
|
||||
// round mid-guide raises the ready screen over the conversation.
|
||||
const ack = () => {
|
||||
clearFreeTierIntro()
|
||||
void ackFreeTierNotice(requestGateway).then(acked => {
|
||||
if (acked) {
|
||||
clearFreeTierIntro()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// subscribe also sees an intro started by the preceding sibling's effect.
|
||||
return $introReveal.subscribe(state => {
|
||||
const offIntro = $introReveal.subscribe(state => {
|
||||
if (state.phase === 'playing') {
|
||||
clearFreeTierIntro()
|
||||
void ackFreeTierNotice(requestGateway).then(acked => {
|
||||
if (acked) {
|
||||
clearFreeTierIntro()
|
||||
}
|
||||
})
|
||||
ack()
|
||||
}
|
||||
})
|
||||
|
||||
const offGate = $onboardingGate.subscribe(state => {
|
||||
if (state.phase === 'guided') {
|
||||
ack()
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
offIntro()
|
||||
offGate()
|
||||
}
|
||||
}, [enabled, requestGateway])
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -3,26 +3,46 @@ import { Tip } from '@/components/ui/tooltip'
|
||||
import { IS_MAC } from '@/lib/keybinds/combo'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Preferences for the first build’s optional connector offer. The live catalog,
|
||||
// not this display list, decides which apps are available to connect. Marks
|
||||
// resolve through the shared ConnectorLogo ladder: curated brand glyph first,
|
||||
// the product's own favicon where simple-icons has no mark (Slack's left over
|
||||
// trademark), monogram last.
|
||||
export const CONNECTORS: Array<{ homepage?: string; id: string; name: string }> = [
|
||||
{ id: 'gmail', name: 'Gmail' },
|
||||
{ id: 'google-calendar', name: 'Calendar' },
|
||||
{ id: 'google-drive', name: 'Drive' },
|
||||
{ homepage: 'https://slack.com', id: 'slack', name: 'Slack' },
|
||||
{ id: 'github', name: 'GitHub' },
|
||||
{ id: 'notion', name: 'Notion' },
|
||||
{ id: 'linear', name: 'Linear' },
|
||||
{ id: 'figma', name: 'Figma' },
|
||||
{ id: 'discord', name: 'Discord' },
|
||||
{ id: 'telegram', name: 'Telegram' },
|
||||
{ id: 'spotify', name: 'Spotify' },
|
||||
{ id: 'stripe', name: 'Stripe' }
|
||||
// Which live-catalog slugs to lead with, and in what order. The catalog is the
|
||||
// source of truth for WHAT can be connected — this is only a sort key for the
|
||||
// picker, so the apps most people use land in the first rows and the rest
|
||||
// stay reachable by search. A slug the catalog no longer carries is simply
|
||||
// not shown; a new one it gains is shown after these.
|
||||
export const CONNECTOR_LEAD_ORDER = [
|
||||
'gmail',
|
||||
'googlecalendar',
|
||||
'googledrive',
|
||||
'googledocs',
|
||||
'googlesheets',
|
||||
'outlook',
|
||||
'slack',
|
||||
'notion',
|
||||
'linear',
|
||||
'jira',
|
||||
'figma',
|
||||
'todoist'
|
||||
]
|
||||
|
||||
// Connectors are the apps Hermes reads and acts on FOR the user. Chat channels
|
||||
// (Discord, Telegram, WhatsApp) are how a user talks TO Hermes — those live on
|
||||
// the Messaging page, and offering them here as if they were data sources
|
||||
// taught users the wrong thing about what "connect" does. The catalog
|
||||
// carries them for the agent's sake; the first-run picker leaves them out.
|
||||
export const CONNECTOR_PICKER_HIDDEN = new Set(['discord', 'discordbot', 'microsoft_teams'])
|
||||
|
||||
export function orderConnectorPicks<T extends { connector: string }>(rows: T[]): T[] {
|
||||
const rank = new Map(CONNECTOR_LEAD_ORDER.map((slug, index) => [slug, index]))
|
||||
|
||||
return rows
|
||||
.filter(row => !CONNECTOR_PICKER_HIDDEN.has(row.connector))
|
||||
.sort((a, b) => {
|
||||
const ra = rank.get(a.connector) ?? Number.POSITIVE_INFINITY
|
||||
const rb = rank.get(b.connector) ?? Number.POSITIVE_INFINITY
|
||||
|
||||
return ra - rb || a.connector.localeCompare(b.connector)
|
||||
})
|
||||
}
|
||||
|
||||
// Big accent swatches, Dia-style. Each seeds `retintTheme` through the accent
|
||||
// override, so a click repaints the surface live. Nous blue is the default =
|
||||
// no override. Mono seeds the current mode's pole — black in light, white in
|
||||
|
||||
@@ -187,7 +187,7 @@ export function buildFirstTaskRunbook(
|
||||
? `They already said what they are working on: ${context}. Let it shape your choices without re-asking.`
|
||||
: '',
|
||||
tools.length
|
||||
? `Tools they use day to day: ${tools.join(', ')} — none are connected yet; never require one for this first build.`
|
||||
? `Apps they said they use: ${tools.join(', ')}. Some may already be connected from onboarding; check with manage_connections action="status" before assuming either way, and never require an unconnected one for this first build.`
|
||||
: '',
|
||||
'Their next message is the go signal: really begin the work — plan briefly, then build (scaffold, research, first artifact).',
|
||||
"As you start, tell them in one short sentence: you'll ask for permissions as you go, and they can say no to anything or redirect you.",
|
||||
@@ -202,7 +202,7 @@ export function buildFirstTaskRunbook(
|
||||
}
|
||||
|
||||
const NO_AUTH_RULE =
|
||||
'CRITICAL: this first build must need NO external account or OAuth (no Gmail, no Slack, no Google sign-in) — connectors are optional and get wired only with their consent. Everything else is fair game and the more visible the better: web research with the browser shown to the user as you work, scripts, computer use, a small app, a file-based tracker, a scheduled reminder, a generated page. If the idea needs an account, build the no-auth core first and say the connection is a later step.'
|
||||
'CRITICAL: this first build must be finishable with NO external account or OAuth (no Gmail, no Slack, no Google sign-in) — connectors get wired only with their consent, and an app that is already connected may be used, one that is not may be offered. Everything else is fair game and the more visible the better: web research with the browser shown to the user as you work, scripts, computer use, a small app, a file-based tracker, a scheduled reminder, a generated page. If the idea needs an account that is not connected, build the no-auth core first and offer the connection as the next step. NEVER route around a connector: an unconnected Gmail is not a cue to install an IMAP client, ask for an app password, or find another way into the same account. The connector IS the way in; if they decline it, the app is out of this build.'
|
||||
|
||||
/** The picks invite an optional connection, not a claim that an account is already linked. */
|
||||
function connectorRunbook(picks: string[]): string[] {
|
||||
@@ -211,9 +211,9 @@ function connectorRunbook(picks: string[]): string[] {
|
||||
}
|
||||
|
||||
return [
|
||||
`The user said they use these apps: ${picks.join(', ')}. Offer to connect the ones useful for this task, but keep the no-auth core moving and never require sign-in to finish it.`,
|
||||
'When they want a connection, use manage_connections action="status" first. Match against the returned catalog; never invent a connector slug or claim an unavailable app is supported. Ask for consent before reading private data. For apps they agree to connect, make one batched action="connect" request and show its real authorization links labelled with each app’s name.',
|
||||
'After the user has seen and approved those links, use manage_connections action="wait" for the same slugs; only a confirmed connected result permits tool use. A timeout, declined consent or gateway outage means not connected, never an empty inbox. Say which apps remain unavailable and offer to continue without them. Never describe a gateway error as proof they need another Nous login.',
|
||||
`The user said they use these apps: ${picks.join(', ')}. These are real connector slugs. Offer to connect the ones useful for this task BEFORE the build starts, in your first turn, so the work can use them from the beginning — but keep the no-auth core moving and never require sign-in to finish it. If none of them help this task, say so in one line and move on; do not describe the catalog.`,
|
||||
'Before you mention connecting anything, use manage_connections action="status" once. Anything already connected is yours to use for the task, with consent before reading private data. Match the rest against the returned catalog; never invent a connector slug or claim an unavailable app is supported. For the apps they agree to connect, make ONE action="connect" call carrying every slug at once (connectors=["gmail","googlecalendar"]) — the app renders one Connect card per app, side by side, and the user works through them; one call per app strands them clicking through a queue. Do not paste the authorization links into prose. Never call connect a second time for an app that already has a card: a new link cancels the one they are signing in with.',
|
||||
'THE CARD IS THE ASK. After a status that shows an app unconnected, or after a connect, write ONE short line and END YOUR TURN — the user answers with the card’s buttons, not with text. Do not start work, do not call other tools, do not decide for them. Their click arrives as a hidden [connectors] message telling you exactly which manage_connections call to make next; follow it. When it says to wait, call action="wait" for that slug and hold: wait blocks until the authorization lands, so you never guess whether they are done. A timeout, declined consent or gateway outage means not connected, never an empty inbox. Say which apps remain unavailable and offer to continue without them. Never describe a gateway error as proof they need another Nous login.',
|
||||
'Discover the connected app’s relevant tools with tool_search and use real results for the requested task. Never fabricate sample account data as if it came from a connector. Reading is separate from sending, deleting or scheduling: ask before those actions. No automatic daily brief or recurring job unless that is what the user asked for.'
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { Slot } from 'radix-ui'
|
||||
import * as React from 'react'
|
||||
|
||||
import { Loader2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// Text+icon actions underline the label on hover, not the glyph.
|
||||
@@ -73,21 +74,47 @@ function Button({
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
asChild = false,
|
||||
loading = false,
|
||||
children,
|
||||
disabled,
|
||||
...props
|
||||
}: React.ComponentProps<'button'> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
/** Working. The label stays in flow but goes invisible and a spinner
|
||||
* sits over it, so the button keeps its exact width and height — a
|
||||
* label swapped for a glyph reflows every sibling on the row. */
|
||||
loading?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : 'button'
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size }), className)}
|
||||
aria-busy={loading || undefined}
|
||||
className={cn(buttonVariants({ variant, size }), loading && 'relative', className)}
|
||||
data-size={size}
|
||||
data-slot="button"
|
||||
data-variant={variant}
|
||||
disabled={disabled || loading}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
{/* Same flex + gap as the button itself, so the ghost label measures
|
||||
exactly what the live one did. The spinner sits in a wrapper so
|
||||
it is never a direct-child svg — the size variants' `has-[>svg]`
|
||||
would otherwise switch to icon padding and shave the width. */}
|
||||
<span aria-hidden className="invisible inline-flex items-center gap-[inherit]">
|
||||
{children}
|
||||
</span>
|
||||
<span aria-hidden className="absolute inset-0 grid place-items-center">
|
||||
<Loader2 className="animate-spin" />
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</Comp>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ConnectorLogo, type ConnectorLogoSubject } from '@/components/ui/connec
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { MarkdownLinkText } from '@/lib/external-link'
|
||||
import { Loader2 } from '@/lib/icons'
|
||||
import { CheckCircle2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
@@ -63,6 +63,9 @@ export interface ConnectorCardOutcome {
|
||||
* card stays a leaf that anything can render, tests included. */
|
||||
export interface ConnectorCardCopy {
|
||||
connectAction: string
|
||||
/** The live offer's heading as a question — "Connect Gmail?" — the same
|
||||
* shape the MCP setup card asks in. Absent, the card leads with the name. */
|
||||
connectTitle?: (title: string) => string
|
||||
decline: string
|
||||
envRequired: string
|
||||
grantAction: string
|
||||
@@ -79,8 +82,18 @@ export interface ConnectorCardCopy {
|
||||
trustVerifiedTip: (publisher: string) => string
|
||||
}
|
||||
|
||||
/** What connecting actually means — the endpoint that will be contacted,
|
||||
* or the catalog it came from. VS Code's trust dialog links the config it
|
||||
* is about to trust; same idea. Shown under the description in tertiary. */
|
||||
export interface ConnectorCardSource {
|
||||
text: string
|
||||
}
|
||||
|
||||
const SHELL_CLASS = `${WIDGET_SHELL_CLASS} text-[length:var(--conversation-text-font-size)] text-(--ui-text-primary)`
|
||||
|
||||
// Same platform sniff the approval bar uses for its accelerator hint.
|
||||
const isMac = typeof navigator !== 'undefined' && /Mac|iP(hone|ad|od)/.test(navigator.platform)
|
||||
|
||||
const hostOf = (url: null | string | undefined): string => {
|
||||
if (!url) {
|
||||
return ''
|
||||
@@ -124,7 +137,9 @@ export function ConnectorSummary({
|
||||
}: {
|
||||
connector: ConnectorLogoSubject
|
||||
meta?: string
|
||||
tone?: 'error'
|
||||
/** `ok` is the settled tool row's emerald; `error` its destructive. Absent
|
||||
* is the neutral grey a skip or a no-answer reads in. */
|
||||
tone?: 'error' | 'ok'
|
||||
}) {
|
||||
// The scaffold mark goes on the row, never on a container holding several:
|
||||
// opacity opens a stacking context and would pin every sibling to one level.
|
||||
@@ -135,7 +150,17 @@ export function ConnectorSummary({
|
||||
<span className="truncate text-[length:var(--conversation-tool-font-size)] text-(--ui-text-primary)">
|
||||
{connector.title || connector.name}
|
||||
</span>
|
||||
{meta ? <span className={cn(SCAFFOLD_META_CLASS, tone === 'error' && 'text-destructive')}>{meta}</span> : null}
|
||||
{meta ? (
|
||||
<span
|
||||
className={cn(
|
||||
SCAFFOLD_META_CLASS,
|
||||
tone === 'error' && 'text-destructive',
|
||||
tone === 'ok' && 'text-emerald-600/85 dark:text-emerald-400/85'
|
||||
)}
|
||||
>
|
||||
{meta}
|
||||
</span>
|
||||
) : null}
|
||||
</ScaffoldRow>
|
||||
</div>
|
||||
)
|
||||
@@ -186,6 +211,15 @@ function TrustBadge({ connector, copy }: { connector: ConnectorCardSubject; copy
|
||||
}
|
||||
|
||||
export interface ConnectorCardProps {
|
||||
/** Show ⌘⏎ / Esc beside the actions. Only the card that also LISTENS for
|
||||
* those keys should claim them; a hint on a card that ignores the key is
|
||||
* a lie the user finds out about by pressing it. */
|
||||
accelerators?: boolean
|
||||
/** Settled cards fold to a scaffold line (the MCP setup default). The
|
||||
* connector offer keeps the card standing with a green Connected in the
|
||||
* action slot: a row of identical cards where one collapses reads as a
|
||||
* row where one broke. */
|
||||
collapseWhenSettled?: boolean
|
||||
connector: ConnectorCardSubject
|
||||
copy: ConnectorCardCopy
|
||||
/** Waved off by the user. Collapses to the same settled line as success. */
|
||||
@@ -204,7 +238,12 @@ export interface ConnectorCardProps {
|
||||
outcome?: ConnectorCardOutcome
|
||||
/** Present only while working; replaces the resting state label. */
|
||||
phase?: string
|
||||
source?: ConnectorCardSource
|
||||
state: ConnectorCardState
|
||||
/** `avatar` leads with the mark at identity scale in a left gutter, the way
|
||||
* the MCP and Messaging headers introduce a service. `compact` (default)
|
||||
* trails a small mark on the right like the setup card's tool row. */
|
||||
variant?: 'avatar' | 'compact'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -216,6 +255,8 @@ export interface ConnectorCardProps {
|
||||
* spent and the space belongs to the ones still asking.
|
||||
*/
|
||||
export function ConnectorCard({
|
||||
accelerators = false,
|
||||
collapseWhenSettled = true,
|
||||
connector,
|
||||
copy,
|
||||
dismissed = false,
|
||||
@@ -228,7 +269,9 @@ export function ConnectorCard({
|
||||
actionDisabled = false,
|
||||
outcome,
|
||||
phase,
|
||||
state
|
||||
source,
|
||||
state,
|
||||
variant = 'compact'
|
||||
}: ConnectorCardProps) {
|
||||
const working = phase !== undefined
|
||||
const connected = outcome?.status === 'connected'
|
||||
@@ -236,10 +279,18 @@ export function ConnectorCard({
|
||||
|
||||
// Answered: the offer is spent, so the card collapses to a scaffold line and
|
||||
// gives the space back to whatever is still asking.
|
||||
if (connected || dismissed) {
|
||||
return <ConnectorSummary connector={connector} meta={outcomeMeta(outcome ?? { status: 'declined' }, copy)} />
|
||||
if ((connected || dismissed) && collapseWhenSettled) {
|
||||
return (
|
||||
<ConnectorSummary
|
||||
connector={connector}
|
||||
meta={outcomeMeta(outcome ?? { status: 'declined' }, copy)}
|
||||
tone={connected ? 'ok' : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const settled = connected || dismissed
|
||||
|
||||
const stateLabel = state === 'disabled' ? copy.stateDisabled : state === 'needs_auth' ? copy.stateNeedsAuth : null
|
||||
|
||||
// Before the first connect, and again after a failed one. Something that
|
||||
@@ -252,98 +303,131 @@ export function ConnectorCard({
|
||||
const envFields = fixable ? (connector.requiredEnv ?? []) : []
|
||||
const steps = fixable ? (connector.setup ?? []) : []
|
||||
|
||||
// Logo owns the left rail; everything the card says and every control it
|
||||
// offers shares the one text column, so the buttons sit on the copy's grid
|
||||
// line instead of hanging off the card's edge under the mark.
|
||||
// Same shape as the MCP setup card, which is the consent widget users have
|
||||
// already met: the ask as a heading, one line of what it means, and the
|
||||
// action strip on the shell's own left edge. The mark either trails small
|
||||
// on the right (compact) or leads at identity scale in a left gutter
|
||||
// (avatar) — the MCP tab and Messaging headers' `items-start gap-3` row,
|
||||
// sized up so a first-time user sees WHOSE sign-in is about to open.
|
||||
const avatar = variant === 'avatar'
|
||||
|
||||
return (
|
||||
<div className={cn(SHELL_CLASS, 'flex items-start gap-3')} data-slot="connector-card">
|
||||
<ConnectorLogo connector={connector} />
|
||||
|
||||
<div className="grid min-w-0 flex-1 gap-0.5">
|
||||
<div className="flex flex-wrap items-baseline gap-x-1.5">
|
||||
<span className="font-medium">{connector.title}</span>
|
||||
{/* While the card is working its phase replaces the resting state —
|
||||
"Signing in…" is the one the user needs, because the browser tab
|
||||
that just took focus is otherwise unexplained. */}
|
||||
{working ? (
|
||||
<span className="text-[0.6875rem] text-(--ui-text-tertiary)">{phase}</span>
|
||||
) : (
|
||||
stateLabel && <span className="text-[0.6875rem] text-(--ui-text-tertiary)">{stateLabel}</span>
|
||||
)}
|
||||
<TrustBadge connector={connector} copy={copy} />
|
||||
</div>
|
||||
|
||||
{connector.description ? <p className="text-(--ui-text-secondary)">{connector.description}</p> : null}
|
||||
|
||||
{failed && outcome.detail ? <p className="text-[0.6875rem] text-destructive">{outcome.detail}</p> : null}
|
||||
|
||||
{/* The part we cannot do. Numbered because order matters, linked
|
||||
because the whole cost of these steps is finding the page. */}
|
||||
{steps.length > 0 && (
|
||||
<ol className="mt-1.5 grid gap-1" data-slot="connector-card-steps">
|
||||
{steps.map((step, index) => (
|
||||
<li className="flex gap-1.5 text-[0.6875rem] text-(--ui-text-secondary)" key={step}>
|
||||
<span className="tabular-nums text-(--ui-text-tertiary)">{index + 1}.</span>
|
||||
<MarkdownLinkText text={step} />
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
|
||||
{envOpen && envFields.length > 0 && (
|
||||
<div className="mt-1 grid gap-2" data-slot="connector-card-env">
|
||||
<p className="text-[0.6875rem] text-(--ui-text-tertiary)">{copy.envRequired}</p>
|
||||
{envFields.map(env => (
|
||||
<label className="grid gap-1" key={env.name}>
|
||||
<span className="text-[0.6875rem] text-(--ui-text-secondary)">
|
||||
{env.prompt || env.name}
|
||||
{env.required ? ' *' : ''}
|
||||
</span>
|
||||
<Input
|
||||
className="h-7 text-xs"
|
||||
onChange={event => onEnvChange?.(env.name, event.currentTarget.value)}
|
||||
type="password"
|
||||
value={envDraft[env.name] ?? ''}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
<div className={cn(SHELL_CLASS, 'my-1.5 grid gap-1.5')} data-slot="connector-card">
|
||||
<div className={cn('flex items-start', avatar ? 'gap-3' : 'gap-2')}>
|
||||
{avatar ? <ConnectorLogo className="size-10 rounded-xl text-base" connector={connector} /> : null}
|
||||
<div className="grid min-w-0 flex-1 gap-0.5">
|
||||
<div className="flex flex-wrap items-baseline gap-x-1.5">
|
||||
<span className="font-medium leading-(--conversation-line-height)">
|
||||
{copy.connectTitle ? copy.connectTitle(connector.title) : connector.title}
|
||||
</span>
|
||||
{/* While the card is working its phase replaces the resting state —
|
||||
"Signing in…" is the one the user needs, because the browser tab
|
||||
that just took focus is otherwise unexplained. */}
|
||||
{working ? (
|
||||
<span className="text-[0.6875rem] text-(--ui-text-tertiary)">{phase}</span>
|
||||
) : (
|
||||
stateLabel && <span className="text-[0.6875rem] text-(--ui-text-tertiary)">{stateLabel}</span>
|
||||
)}
|
||||
<TrustBadge connector={connector} copy={copy} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Same strip as the tool approval bar (tool/approval.tsx), down to its
|
||||
`mt-2` stand-off: a bordered primary-tinted action plus a quiet
|
||||
ghost decline. One consent vocabulary across the transcript. */}
|
||||
<div className="mt-2 flex items-center gap-2.5">
|
||||
<div className="inline-flex h-6 items-stretch overflow-hidden rounded-md border border-primary/25 bg-primary/10 text-primary">
|
||||
{connector.description ? <p className="text-(--ui-text-secondary)">{connector.description}</p> : null}
|
||||
|
||||
{source ? <p className="truncate text-[0.6875rem] text-(--ui-text-tertiary)">{source.text}</p> : null}
|
||||
|
||||
{failed && outcome.detail ? <p className="text-[0.6875rem] text-destructive">{outcome.detail}</p> : null}
|
||||
|
||||
{/* The part we cannot do. Numbered because order matters, linked
|
||||
because the whole cost of these steps is finding the page. */}
|
||||
{steps.length > 0 && (
|
||||
<ol className="mt-1.5 grid gap-1" data-slot="connector-card-steps">
|
||||
{steps.map((step, index) => (
|
||||
<li className="flex gap-1.5 text-[0.6875rem] text-(--ui-text-secondary)" key={step}>
|
||||
<span className="tabular-nums text-(--ui-text-tertiary)">{index + 1}.</span>
|
||||
<MarkdownLinkText text={step} />
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
|
||||
{envOpen && envFields.length > 0 && (
|
||||
<div className="mt-1 grid gap-2" data-slot="connector-card-env">
|
||||
<p className="text-[0.6875rem] text-(--ui-text-tertiary)">{copy.envRequired}</p>
|
||||
{envFields.map(env => (
|
||||
<label className="grid gap-1" key={env.name}>
|
||||
<span className="text-[0.6875rem] text-(--ui-text-secondary)">
|
||||
{env.prompt || env.name}
|
||||
{env.required ? ' *' : ''}
|
||||
</span>
|
||||
<Input
|
||||
className="h-7 text-xs"
|
||||
onChange={event => onEnvChange?.(env.name, event.currentTarget.value)}
|
||||
type="password"
|
||||
value={envDraft[env.name] ?? ''}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{avatar ? null : (
|
||||
<ConnectorLogo className="mt-px size-5 rounded-[0.3rem] text-[0.6875rem]" connector={connector} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Same strip as the tool approval bar (tool/approval.tsx), down to its
|
||||
stand-off: a bordered primary-tinted action plus a quiet ghost
|
||||
decline. One consent vocabulary across the transcript. In the avatar
|
||||
layout the strip sits on the text column, so the gutter stays the
|
||||
mark's alone. Settled (and kept standing), the action slot holds the
|
||||
verdict in the same box — green Connected, grey Skipped — so the row
|
||||
of cards keeps its rhythm and nothing jumps. */}
|
||||
<div className={cn('flex items-center gap-2.5', avatar && 'pl-13')}>
|
||||
{settled ? (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex h-6 items-stretch overflow-hidden rounded-md border',
|
||||
connected
|
||||
? 'border-emerald-600/25 bg-emerald-600/10 text-emerald-700 dark:border-emerald-400/25 dark:bg-emerald-400/10 dark:text-emerald-300'
|
||||
: 'border-(--ui-stroke-tertiary) bg-(--ui-bg-quaternary) text-(--ui-text-tertiary)'
|
||||
)}
|
||||
data-slot="connector-card-verdict"
|
||||
>
|
||||
<span className="inline-flex h-full items-center gap-1 px-2 text-xs font-medium">
|
||||
{connected ? <CheckCircle2 aria-hidden className="size-3" /> : null}
|
||||
{outcomeMeta(outcome ?? { status: 'declined' }, copy)}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="inline-flex h-6 items-stretch overflow-hidden rounded-md border border-primary/25 bg-primary/10 text-primary">
|
||||
<Button
|
||||
className="h-full gap-1 rounded-none px-2 text-xs font-medium text-primary hover:bg-primary/15 hover:text-primary"
|
||||
disabled={otherBusy || actionDisabled}
|
||||
loading={working}
|
||||
onClick={onConnect}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
>
|
||||
{outcome?.needsAuth ? copy.grantAction : failed ? copy.retryAction : copy.connectAction}
|
||||
{accelerators ? (
|
||||
<span className="text-[0.625rem] text-primary/60">{isMac ? '⌘⏎' : 'Ctrl⏎'}</span>
|
||||
) : null}
|
||||
</Button>
|
||||
</div>
|
||||
{/* Never disabled: while a connect is in flight this is the way out
|
||||
of a stuck sign-in tab or a hung install. */}
|
||||
<Button
|
||||
className="h-full gap-1 rounded-none px-2 text-xs font-medium text-primary hover:bg-primary/15 hover:text-primary"
|
||||
disabled={working || otherBusy || actionDisabled}
|
||||
onClick={onConnect}
|
||||
className="h-6 gap-1.5 rounded-md px-1.5 text-xs font-normal text-(--ui-text-tertiary) hover:text-foreground"
|
||||
onClick={onDismiss}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
>
|
||||
{working ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : outcome?.needsAuth ? (
|
||||
copy.grantAction
|
||||
) : failed ? (
|
||||
copy.retryAction
|
||||
) : (
|
||||
copy.connectAction
|
||||
)}
|
||||
{copy.decline}
|
||||
{accelerators ? <span className="text-[0.625rem] opacity-55">Esc</span> : null}
|
||||
</Button>
|
||||
</div>
|
||||
{/* Never disabled: while a connect is in flight this is the way out
|
||||
of a stuck sign-in tab or a hung install. */}
|
||||
<Button
|
||||
className="h-6 gap-1.5 rounded-md px-1.5 text-xs font-normal text-(--ui-text-tertiary) hover:text-foreground"
|
||||
onClick={onDismiss}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
>
|
||||
{copy.decline}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
Vendored
+3
@@ -356,6 +356,9 @@ declare global {
|
||||
* (HERMES_GUEST_ONBOARDING=1 or --guest-onboarding). Read-only fact the
|
||||
* main process also stamps onto every backend it spawns. */
|
||||
guestOnboardingEnabled?: boolean
|
||||
/** Launch flag: skip the first-run film (HERMES_SKIP_INTRO=1 or
|
||||
* --skip-intro) so a fresh HERMES_HOME lands on the guided chat. */
|
||||
skipIntro?: boolean
|
||||
setTranslucency?: (payload: TranslucencyState) => void
|
||||
setKeepAwake?: (on: boolean) => void
|
||||
setDisableF12?: (blocked: boolean) => void
|
||||
|
||||
@@ -26,10 +26,9 @@ export const en: Translations = {
|
||||
ownerMissing: 'Reopen this conversation to manage its connections.',
|
||||
search: 'Find an app',
|
||||
empty: 'No matching apps',
|
||||
continue: 'Continue in chat',
|
||||
continueBusy: 'Waiting for this turn to finish',
|
||||
continueFailed: 'Could not continue. Try again.',
|
||||
disclaimer: 'Connecting is optional. Only authorize the apps you want Hermes to use.',
|
||||
connectTitle: app => `Connect ${app}?`,
|
||||
describe: app => `Hermes signs in to ${app} in your browser and asks before reading anything there.`,
|
||||
execution: 'Connector tools'
|
||||
},
|
||||
|
||||
|
||||
@@ -74,10 +74,9 @@ export interface Translations {
|
||||
ownerMissing: string
|
||||
search: string
|
||||
empty: string
|
||||
continue: string
|
||||
continueBusy: string
|
||||
continueFailed: string
|
||||
disclaimer: string
|
||||
connectTitle: (app: string) => string
|
||||
describe: (app: string) => string
|
||||
execution: string
|
||||
}
|
||||
sessionImport: {
|
||||
|
||||
@@ -26,10 +26,9 @@ export const zh: Translations = {
|
||||
ownerMissing: '请重新打开此对话以管理连接。',
|
||||
search: '查找应用',
|
||||
empty: '没有匹配的应用',
|
||||
continue: '在对话中继续',
|
||||
continueBusy: '等待当前操作完成',
|
||||
continueFailed: '无法继续,请重试。',
|
||||
disclaimer: '连接为可选操作。请仅授权你希望 Hermes 使用的应用。',
|
||||
connectTitle: app => `连接 ${app}?`,
|
||||
describe: app => `Hermes 会在浏览器中登录 ${app},读取任何内容前都会先询问。`,
|
||||
execution: '连接器工具'
|
||||
},
|
||||
|
||||
|
||||
@@ -302,3 +302,11 @@ export function appendAssistantTextPart(
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
/** True when a visible user message follows `messageId` — the reader has moved
|
||||
* on, so a question card at `messageId` counts as answered. */
|
||||
export function answeredAfter(messages: ChatMessage[], messageId: string): boolean {
|
||||
const at = messages.findIndex(message => message.id === messageId)
|
||||
|
||||
return at !== -1 && messages.slice(at + 1).some(message => message.role === 'user' && !message.hidden)
|
||||
}
|
||||
|
||||
@@ -14,11 +14,17 @@ import {
|
||||
SiAsana,
|
||||
SiAtlassian,
|
||||
SiDatadog,
|
||||
SiDiscord,
|
||||
SiFigma,
|
||||
SiGithub,
|
||||
SiGitlab,
|
||||
SiGmail,
|
||||
SiGooglecalendar,
|
||||
SiGoogledrive,
|
||||
SiGooglesheets,
|
||||
SiHuggingface,
|
||||
SiIntercom,
|
||||
SiJira,
|
||||
SiLinear,
|
||||
SiN8n,
|
||||
SiNetlify,
|
||||
@@ -26,14 +32,19 @@ import {
|
||||
SiPaypal,
|
||||
SiPostgresql,
|
||||
SiSentry,
|
||||
SiSpotify,
|
||||
SiSquare,
|
||||
SiStripe,
|
||||
SiSupabase,
|
||||
SiTelegram,
|
||||
SiTodoist,
|
||||
SiUnrealengine,
|
||||
SiVercel,
|
||||
SiWebflow,
|
||||
SiYoutube,
|
||||
SiZapier
|
||||
} from '@icons-pack/react-simple-icons'
|
||||
import { IconBrandSlack } from '@tabler/icons-react'
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
|
||||
export interface McpBrand {
|
||||
@@ -50,12 +61,22 @@ export const MCP_BRAND_ICONS: Record<string, McpBrand> = {
|
||||
asana: { Icon: SiAsana, color: '#F06A6A' },
|
||||
atlassian: { Icon: SiAtlassian, color: '#0052CC' },
|
||||
datadog: { Icon: SiDatadog, color: '#632CA6' },
|
||||
discord: { Icon: SiDiscord, color: '#5865F2' },
|
||||
figma: { Icon: SiFigma, color: '#F24E1E' },
|
||||
github: { Icon: SiGithub, color: '#181717', monochrome: true },
|
||||
gitlab: { Icon: SiGitlab, color: '#FC6D26' },
|
||||
gmail: { Icon: SiGmail, color: '#EA4335' },
|
||||
// Gateway spelling (connector slugs), plus the hyphenated form MCP server
|
||||
// names tend to use.
|
||||
googlecalendar: { Icon: SiGooglecalendar, color: '#4285F4' },
|
||||
'google-calendar': { Icon: SiGooglecalendar, color: '#4285F4' },
|
||||
googledrive: { Icon: SiGoogledrive, color: '#4285F4' },
|
||||
'google-drive': { Icon: SiGoogledrive, color: '#4285F4' },
|
||||
googlesheets: { Icon: SiGooglesheets, color: '#34A853' },
|
||||
hugging_face: { Icon: SiHuggingface, color: '#FFD21E' },
|
||||
huggingface: { Icon: SiHuggingface, color: '#FFD21E' },
|
||||
intercom: { Icon: SiIntercom, color: '#6AFDEF' },
|
||||
jira: { Icon: SiJira, color: '#0052CC' },
|
||||
linear: { Icon: SiLinear, color: '#5E6AD2' },
|
||||
n8n: { Icon: SiN8n, color: '#EA4B71' },
|
||||
netlify: { Icon: SiNetlify, color: '#00C7B7' },
|
||||
@@ -64,12 +85,20 @@ export const MCP_BRAND_ICONS: Record<string, McpBrand> = {
|
||||
postgres: { Icon: SiPostgresql, color: '#4169E1' },
|
||||
postgresql: { Icon: SiPostgresql, color: '#4169E1' },
|
||||
sentry: { Icon: SiSentry, color: '#362D59' },
|
||||
// simple-icons dropped Slack's mark on a trademark request and the site's
|
||||
// favicon is a flat purple square that reads as a blank disc at chip size.
|
||||
// Tabler's outline pinwheel is the one recognisable Slack we can ship.
|
||||
slack: { Icon: IconBrandSlack as ComponentType<SVGProps<SVGSVGElement>>, color: '#4A154B' },
|
||||
spotify: { Icon: SiSpotify, color: '#1DB954' },
|
||||
square: { Icon: SiSquare, color: '#3E4348', monochrome: true },
|
||||
stripe: { Icon: SiStripe, color: '#635BFF' },
|
||||
supabase: { Icon: SiSupabase, color: '#3FCF8E' },
|
||||
telegram: { Icon: SiTelegram, color: '#26A5E4' },
|
||||
todoist: { Icon: SiTodoist, color: '#E44332' },
|
||||
'unreal-engine': { Icon: SiUnrealengine, color: '#0E1128', monochrome: true },
|
||||
vercel: { Icon: SiVercel, color: '#000000', monochrome: true },
|
||||
webflow: { Icon: SiWebflow, color: '#146EF5' },
|
||||
youtube: { Icon: SiYoutube, color: '#FF0000' },
|
||||
zapier: { Icon: SiZapier, color: '#FF4A00' }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* The live connector catalog for a chat session, read once per mount.
|
||||
*
|
||||
* The onboarding picker used to be a hardcoded list, and it drifted from the
|
||||
* deployed catalog: it offered apps the gateway does not carry and spelled
|
||||
* others with hyphens the gateway does not use. The pick was then a promise
|
||||
* the build chat had to walk back. This hook asks the gateway what is
|
||||
* actually there, through the same session-owned RPC the connector cards
|
||||
* use, so the picker can only ever offer what can be connected.
|
||||
*
|
||||
* `available: false` (toolset off, signed out) and a failed request both
|
||||
* resolve to `null` rows — the caller decides what to show; there is no
|
||||
* fallback list here, because a fallback is how the drift started.
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { resolveSessionOwner } from '@/app/session/hooks/use-session-actions/utils'
|
||||
import type { ConnectorRow } from '@/lib/connector-tools'
|
||||
import { requestGatewayForAgent } from '@/store/gateway'
|
||||
import { $activeGatewayProfile } from '@/store/profile'
|
||||
import { assertSessionOwnerResolved } from '@/store/session-owner-resolution'
|
||||
import { isSessionOwnerRoute } from '@/store/session-request-router'
|
||||
|
||||
export type ConnectorCatalog = { status: 'loading' } | { status: 'ready'; rows: ConnectorRow[] } | { status: 'unavailable' }
|
||||
|
||||
export function useConnectorCatalog(storedId: null | string, runtimeId: null | string): ConnectorCatalog {
|
||||
const [catalog, setCatalog] = useState<ConnectorCatalog>({ status: 'loading' })
|
||||
|
||||
useEffect(() => {
|
||||
if (!storedId || !runtimeId) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
const ambientProfile = $activeGatewayProfile.get()
|
||||
|
||||
void resolveSessionOwner(storedId)
|
||||
.then(scope => {
|
||||
assertSessionOwnerResolved(scope, { method: 'connectors.list', sessionId: storedId })
|
||||
|
||||
const connectionId = isSessionOwnerRoute(scope) ? scope.connectionId : null
|
||||
const profile = isSessionOwnerRoute(scope) ? scope.profile : scope || ambientProfile
|
||||
|
||||
return requestGatewayForAgent<{ available: boolean; connectors: ConnectorRow[] }>(
|
||||
connectionId,
|
||||
profile,
|
||||
'connectors.list',
|
||||
{ session_id: runtimeId }
|
||||
)
|
||||
})
|
||||
.then(response => {
|
||||
if (!cancelled) {
|
||||
setCatalog(response.available ? { rows: response.connectors, status: 'ready' } : { status: 'unavailable' })
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setCatalog({ status: 'unavailable' })
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [storedId, runtimeId])
|
||||
|
||||
return catalog
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createConnectorFlow } from './connector-flow'
|
||||
|
||||
const listed = (connected: boolean) => ({
|
||||
available: true,
|
||||
connectors: [{ connector: 'gmail', connected, enabled: true }]
|
||||
})
|
||||
|
||||
function flowWith(responses: { list: () => unknown; connect?: () => unknown }, onWaiting = vi.fn()) {
|
||||
const open = vi.fn(async () => {})
|
||||
|
||||
const request = vi.fn(async (method: string) => {
|
||||
if (method === 'connectors.list') {
|
||||
return responses.list()
|
||||
}
|
||||
|
||||
return responses.connect?.() ?? { results: [] }
|
||||
})
|
||||
|
||||
const flow = createConnectorFlow('session', [{ connector: 'gmail' }], {
|
||||
request: request as never,
|
||||
open,
|
||||
onWaiting,
|
||||
delay: async () => {},
|
||||
now: () => 0
|
||||
})
|
||||
|
||||
return { flow, open, onWaiting, request }
|
||||
}
|
||||
|
||||
describe('the moment the browser has the sign-in', () => {
|
||||
it('reports waiting once the link is open, before the poll settles', async () => {
|
||||
let connected = false
|
||||
const order: string[] = []
|
||||
|
||||
const { flow, onWaiting, open } = flowWith(
|
||||
{
|
||||
list: () => listed(connected),
|
||||
connect: () => ({ results: [{ connector: 'gmail', status: 'initiated', connect_url: 'https://auth.test/x' }] })
|
||||
},
|
||||
vi.fn(() => {
|
||||
order.push('waiting')
|
||||
// The user authorizes in the browser; the next poll sees it.
|
||||
connected = true
|
||||
})
|
||||
)
|
||||
|
||||
open.mockImplementation(async () => {
|
||||
order.push('open')
|
||||
})
|
||||
|
||||
await flow.refresh()
|
||||
await flow.connect('gmail')
|
||||
|
||||
expect(order).toEqual(['open', 'waiting'])
|
||||
expect(onWaiting).toHaveBeenCalledWith('gmail')
|
||||
expect(flow.state.get().rows[0].phase).toBe('connected')
|
||||
})
|
||||
|
||||
it('says nothing when the mint fails, so the agent is not sent to wait on nothing', async () => {
|
||||
const { flow, onWaiting } = flowWith({
|
||||
list: () => listed(false),
|
||||
connect: () => ({ results: [{ connector: 'gmail', status: 'error' }] })
|
||||
})
|
||||
|
||||
await flow.refresh()
|
||||
await flow.connect('gmail')
|
||||
|
||||
expect(onWaiting).not.toHaveBeenCalled()
|
||||
expect(flow.state.get().rows[0].phase).toBe('error')
|
||||
})
|
||||
|
||||
it('says nothing when the app was already active — there is no browser step', async () => {
|
||||
const { flow, onWaiting } = flowWith({
|
||||
list: () => listed(true),
|
||||
connect: () => ({ results: [{ connector: 'gmail', status: 'active' }] })
|
||||
})
|
||||
|
||||
await flow.refresh()
|
||||
await flow.connect('gmail')
|
||||
|
||||
expect(onWaiting).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sends the agent back in when the user keeps waiting after a timeout', async () => {
|
||||
let clock = 0
|
||||
const open = vi.fn(async () => {})
|
||||
const onWaiting = vi.fn()
|
||||
|
||||
const request = vi.fn(async (method: string) =>
|
||||
method === 'connectors.list'
|
||||
? listed(false)
|
||||
: { results: [{ connector: 'gmail', status: 'initiated', connect_url: 'https://auth.test/x' }] }
|
||||
)
|
||||
|
||||
const flow = createConnectorFlow('session', [{ connector: 'gmail' }], {
|
||||
request: request as never,
|
||||
open,
|
||||
onWaiting,
|
||||
// Each poll tick burns the whole window so the first wait times out.
|
||||
delay: async () => {
|
||||
clock += 120001
|
||||
},
|
||||
now: () => clock
|
||||
})
|
||||
|
||||
await flow.refresh()
|
||||
await flow.connect('gmail')
|
||||
|
||||
expect(flow.state.get().rows[0].phase).toBe('timeout')
|
||||
expect(onWaiting).toHaveBeenCalledTimes(1)
|
||||
|
||||
await flow.keepWaiting('gmail')
|
||||
|
||||
expect(onWaiting).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('asks the gateway to reconnect, not connect, when the grant is expired or revoked', async () => {
|
||||
const { flow, request } = flowWith({
|
||||
list: () => ({
|
||||
available: true,
|
||||
connectors: [{ connector: 'gmail', connected: false, enabled: true, connectionStatus: 'expired' }]
|
||||
}),
|
||||
connect: () => ({ results: [{ connector: 'gmail', status: 'active' }] })
|
||||
})
|
||||
|
||||
await flow.refresh()
|
||||
await flow.connect('gmail')
|
||||
|
||||
expect(request).toHaveBeenCalledWith('connectors.connect', expect.objectContaining({ reconnect: true }))
|
||||
})
|
||||
})
|
||||
@@ -16,6 +16,9 @@ export interface ConnectorFlowState {
|
||||
export interface ConnectorFlowDeps {
|
||||
request: <T>(method: string, params: { session_id: string; connectors?: string[]; reconnect?: boolean }) => Promise<T>
|
||||
open: (url: string) => Promise<void>
|
||||
/** The browser has the sign-in and the card is now waiting on the user.
|
||||
* Also fired when the user asks to keep waiting after a timeout. */
|
||||
onWaiting?: (slug: string) => void
|
||||
delay?: () => Promise<void>
|
||||
now?: () => number
|
||||
}
|
||||
@@ -207,6 +210,7 @@ export function createConnectorFlow(sessionId: string, seeds: ConnectorRow[], de
|
||||
}
|
||||
|
||||
update(slug, { phase: 'waiting' })
|
||||
deps.onWaiting?.(slug)
|
||||
await wait(slug, token)
|
||||
} catch {
|
||||
if (valid(slug, token)) {
|
||||
@@ -235,6 +239,8 @@ export function createConnectorFlow(sessionId: string, seeds: ConnectorRow[], de
|
||||
const token = (attempts.get(slug) ?? 0) + 1
|
||||
attempts.set(slug, token)
|
||||
update(slug, { phase: 'waiting', error: undefined })
|
||||
// The agent's own wait timed out alongside ours; send it back in.
|
||||
deps.onWaiting?.(slug)
|
||||
await wait(slug, token)
|
||||
},
|
||||
skip: (slug: string) => {
|
||||
|
||||
@@ -32,7 +32,7 @@ export function hasSeenIntroReveal(): boolean {
|
||||
}
|
||||
|
||||
export function isIntroRevealEnabled(): boolean {
|
||||
return isOnboardingEnabled()
|
||||
return isOnboardingEnabled() && window.hermesDesktop?.skipIntro !== true
|
||||
}
|
||||
|
||||
export function shouldPlayFirstRunIntro(firstRunSkipped: boolean): boolean {
|
||||
@@ -62,6 +62,10 @@ export function finishIntroReveal(): void {
|
||||
|
||||
writeKey(SEEN_KEY, '1')
|
||||
$introReveal.set(INITIAL)
|
||||
// The gate's listener on that edge queues the guide and takes the solo
|
||||
// shape (small window, greeting layout) synchronously, so the main window
|
||||
// is already the guide when it is shown. Showing first and shrinking after
|
||||
// is what flashed the full app between the film and the greeting.
|
||||
void window.hermesDesktop?.introReveal?.close({ showMain: true }).catch(() => undefined)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,11 @@ import { readJson, writeJson } from '@/lib/storage'
|
||||
|
||||
export interface OnboardingAnswers {
|
||||
accent: null | string
|
||||
/** Cards the user has already pressed Continue on. The card's own React
|
||||
* state dies on every transcript reconcile (the hidden submit and the
|
||||
* turn-end hydrate both rebuild the message list), so a Done button that
|
||||
* lived there came back live and let the step be answered twice. */
|
||||
committed: string[]
|
||||
connectors: string[]
|
||||
context: string
|
||||
name: string
|
||||
@@ -15,6 +20,7 @@ export const ANSWERS_KEY = 'hermes-onboarding-wizard-answers-v1'
|
||||
|
||||
export const DEFAULT_ANSWERS: OnboardingAnswers = {
|
||||
accent: null,
|
||||
committed: [],
|
||||
connectors: [],
|
||||
context: '',
|
||||
name: '',
|
||||
@@ -28,6 +34,7 @@ export function loadAnswers(): OnboardingAnswers {
|
||||
// to personalization or written back on the next answer.
|
||||
return {
|
||||
accent: raw?.accent ?? DEFAULT_ANSWERS.accent,
|
||||
committed: raw?.committed ?? [...DEFAULT_ANSWERS.committed],
|
||||
connectors: raw?.connectors ?? [...DEFAULT_ANSWERS.connectors],
|
||||
context: raw?.context ?? DEFAULT_ANSWERS.context,
|
||||
name: raw?.name ?? DEFAULT_ANSWERS.name,
|
||||
@@ -43,3 +50,11 @@ export function setOnboardingAnswers(patch: Partial<OnboardingAnswers>): void {
|
||||
$onboardingAnswers.set(next)
|
||||
writeJson(ANSWERS_KEY, next)
|
||||
}
|
||||
|
||||
export function markStepCommitted(step: string): void {
|
||||
const { committed } = $onboardingAnswers.get()
|
||||
|
||||
if (!committed.includes(step)) {
|
||||
setOnboardingAnswers({ committed: [...committed, step] })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,13 @@ function loadGate(): OnboardingGateState {
|
||||
|
||||
const phase = isOnboardingEnabled() && isOnboardingPhase(saved) ? saved : 'idle'
|
||||
|
||||
return { phase, guideQueued: phase === 'cinematic' && hasSeenIntroReveal() }
|
||||
// Two phases owe a kickoff at boot. `cinematic` with the film already seen
|
||||
// is the film-to-guide seam. `guided` is a relaunch mid-guide: without a
|
||||
// kickoff the normal app boots around the persisted solo layout — the
|
||||
// connected splash, the stock composer and model picker, a small window
|
||||
// whose sidebars cannot open — while the gate still says the guide is on.
|
||||
// The kickoff adopts the existing guide chat by title, so nothing is lost.
|
||||
return { phase, guideQueued: (phase === 'cinematic' && hasSeenIntroReveal()) || phase === 'guided' }
|
||||
}
|
||||
|
||||
export const $onboardingGate = atom<OnboardingGateState>(loadGate())
|
||||
@@ -40,6 +46,16 @@ function setPhase(phase: OnboardingPhase): void {
|
||||
$onboardingGate.set({ phase, guideQueued: false })
|
||||
}
|
||||
|
||||
/** The guided first launch is on screen or mid-handoff. Ambient chrome that
|
||||
* would send the user elsewhere (the provider picker, the free-tier chip)
|
||||
* yields to it: the free tier IS the provider for those phases, and the
|
||||
* guide's ready screen is where sign-in is offered. */
|
||||
export function guidedOnboardingActive(): boolean {
|
||||
const { phase } = $onboardingGate.get()
|
||||
|
||||
return isOnboardingEnabled() && (phase === 'cinematic' || phase === 'guided' || phase === 'handoff')
|
||||
}
|
||||
|
||||
export function beginOnboardingFlow(): void {
|
||||
if (isOnboardingEnabled() && $onboardingGate.get().phase === 'idle' && !hasSeenIntroReveal()) {
|
||||
setPhase('cinematic')
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* The free tier is unmetered and the guided first launch never demands an
|
||||
* account. This is the acceptance criterion the guided onboarding was built
|
||||
* to, as a test rather than a memory: a user can work through the guide and
|
||||
* the first build, tool call after tool call, and the only way a sign-in
|
||||
* reaches them is the guide's own ready screen at the moment the guide picks.
|
||||
* Every surface that could push a sign-in over the guide — the provider
|
||||
* picker, the deferred credential warning, the free-tier ready screen — has
|
||||
* to yield while the gate is cinematic, guided or handoff.
|
||||
*/
|
||||
import { expect, it, vi } from 'vitest'
|
||||
|
||||
import type * as storageModule from '@/lib/storage'
|
||||
|
||||
const storage = vi.hoisted(() => new Map<string, string>())
|
||||
|
||||
vi.mock('@/lib/onboarding-enabled', () => ({ isOnboardingEnabled: () => true }))
|
||||
vi.mock('@/lib/storage', async importOriginal => ({
|
||||
...(await importOriginal<typeof storageModule>()),
|
||||
readKey: (key: string) => storage.get(key) ?? null,
|
||||
writeKey: (key: string, value: string | null) => {
|
||||
if (value === null) {
|
||||
storage.delete(key)
|
||||
} else {
|
||||
storage.set(key, value)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
async function load(phase: string) {
|
||||
storage.clear()
|
||||
storage.set('hermes-onboarding-phase-v1', phase)
|
||||
vi.resetModules()
|
||||
|
||||
const gate = await import('./onboarding-gate')
|
||||
const onboarding = await import('./onboarding')
|
||||
|
||||
return { gate, onboarding }
|
||||
}
|
||||
|
||||
it.each(['cinematic', 'guided', 'handoff'])('the provider picker never opens over the guide (%s)', async phase => {
|
||||
const { onboarding } = await load(phase)
|
||||
|
||||
onboarding.requestDesktopOnboarding('No inference provider is configured.')
|
||||
|
||||
expect(onboarding.$desktopOnboarding.get().requested).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['cinematic', 'guided', 'handoff'])('a credential warning during the guide is dropped, not deferred (%s)', async phase => {
|
||||
const { onboarding } = await load(phase)
|
||||
|
||||
onboarding.requestDesktopOnboardingForCredentialWarning("No API key configured for provider 'nous'. First message will fail.")
|
||||
|
||||
expect(onboarding.consumePendingCredentialWarning()).toBeNull()
|
||||
})
|
||||
|
||||
it.each(['idle', 'skipped', 'done'])('outside the guide the picker opens as before (%s)', async phase => {
|
||||
const { onboarding } = await load(phase)
|
||||
|
||||
onboarding.requestDesktopOnboarding('No inference provider is configured.')
|
||||
|
||||
expect(onboarding.$desktopOnboarding.get().requested).toBe(true)
|
||||
})
|
||||
@@ -74,9 +74,9 @@ const TOUR_QUESTION = 'Want a look around first?'
|
||||
* click-through, but three highlighted buttons with a line each beats three
|
||||
* lines of prose describing buttons the user then has to go find. */
|
||||
export const TOUR_OPTIONS = {
|
||||
basics: 'Just the basics',
|
||||
none: "I'll figure it out",
|
||||
tour: 'Show me around'
|
||||
basics: 'Quick tour',
|
||||
none: 'Skip, let’s build something',
|
||||
tour: 'Show me everything'
|
||||
} as const
|
||||
|
||||
/**
|
||||
@@ -171,7 +171,8 @@ export function buildChatOnboardingPrompt(suggestedName?: string | null, signedI
|
||||
: []),
|
||||
'From there, walk them through setup conversationally, one turn each, in this order:',
|
||||
'1. This turn is exactly four things and then you stop: a few warm words about their name, then ::onboarding{step="name" value="THEIR_NAME"} on a line of its own (THEIR_NAME being the name they actually gave; it renders as nothing and just saves it), then one short sentence about their colour, then ::onboarding{step="look"} on a line of its own. That is one turn, not two, and it is not a conflict with RULE 3: the name line is not a question, the look card is, and it is the last thing you write.',
|
||||
'2. Then the tools they already use, so Hermes can connect to them later: one short sentence, then ::onboarding{step="connectors"} on a line of its own.',
|
||||
'2. Then the apps they already use, so Hermes can connect to them later: one short sentence that makes clear what connecting means — you would read and act inside those apps for them (their inbox, their calendar, their repos), not message them there — then ::onboarding{step="connectors"} on a line of its own. Chat apps like Discord or Telegram are a different thing (how they reach you) and are not what this card is asking about; if they bring one up, say it lives in Messaging in the app’s settings and move on.',
|
||||
'CONNECTING, IF THEY ASK FOR IT HERE. The picks are preferences, not connections — but if at any point they ask you to connect an app, or say they want one wired up now, do it in this chat: call manage_connections action="status" once, then one action="connect" with EVERY app they named as a batch (connectors=["gmail","googlecalendar"], not one call per app). The app renders that as a Connect card per app — the card is the ask, so write one short line and END YOUR TURN; never paste the links, never describe a settings page. Their click arrives as a hidden [connectors] message telling you the exact next call; follow it, and when it says wait, call action="wait" and hold. Never call connect a second time for an app that already has a card: a new link cancels the one they are signing in with. If an app is not in the status catalog, say so plainly. There is no Connectors page in Settings; do not send them to one.',
|
||||
// The one place sign-in is named BEFORE it is needed. It goes here because
|
||||
// this beat already put the idea in their head — they just listed the
|
||||
// accounts they live in — so "you'll want an account for that" reads as an
|
||||
@@ -186,12 +187,12 @@ export function buildChatOnboardingPrompt(suggestedName?: string | null, signedI
|
||||
'In that same turn, once, mention in ONE short clause that wiring those up later will want a model provider — a free Nous account is there if they want it, free tier, no card, and they can bring their own provider instead — then move straight on. Do not sell it, do not list providers, do not ask them to do it now, and never bring it up again: they will be asked properly at the point it actually matters.'
|
||||
]),
|
||||
'3. Then their layout: one short sentence, then ::onboarding{step="layout"} on a line of its own.',
|
||||
`4. The app has just arranged itself around this chat, so offer them a look at it: one short sentence, then the line ::ask{question="${TOUR_QUESTION}" options="${TOUR_OPTIONS.basics}|${TOUR_OPTIONS.tour}|${TOUR_OPTIONS.none}"} alone as its own paragraph. Branch on the answer, then go straight to step 5 whichever they picked.`,
|
||||
`4. The app has just arranged itself around this chat, so offer them a look at it: one short sentence, then the line ::ask{question="${TOUR_QUESTION}" options="${TOUR_OPTIONS.basics}|${TOUR_OPTIONS.tour}|${TOUR_OPTIONS.none}"} alone as its own paragraph. Branch on the answer, then go straight to step 5 IN THE SAME TURN whichever they picked — the tour overlay has its own Done button and ending your turn on it strands them with nothing to click next.`,
|
||||
` - "${TOUR_OPTIONS.basics}": three steps, the essentials only — where their conversations live, where they ask for a job, and how to start a fresh one. Point at each and say one useful thing about it.`,
|
||||
` - "${TOUR_OPTIONS.tour}": 4 to 6 steps, a proper look around — the essentials plus whatever else the layout they just picked actually gives them.`,
|
||||
` Both of those run the tour tool the same way: call it with action="targets" FIRST and build only out of what it actually reports, preferring the targets marked stable — never invent a selector, and if a piece you wanted is not in the list, drop that step rather than guessing at it. Then ONE action="start" call, each step a few words of title and one plain sentence of body. One short line before the call and one after; the tour does the talking.`,
|
||||
` - "${TOUR_OPTIONS.none}": one short line, and move on.`,
|
||||
' Whichever they picked, the line you close that turn on tells them the tour is always on offer: they can ask you to show them any part of this, any time. Say it in your own words, once, and never bring it up again.',
|
||||
` Both of those run the tour tool the same way: call it with action="targets" FIRST and build only out of what it actually reports, preferring the targets marked stable — never invent a selector, and if a piece you wanted is not in the list, drop that step rather than guessing at it. Then ONE action="start" call, each step a few words of title and one plain sentence of body. One short line before the call; after it returns, the fork (step 5) follows in this same turn so the ask is waiting under the tour when they close it.`,
|
||||
` - "${TOUR_OPTIONS.none}": no line about the tour at all, straight to step 5.`,
|
||||
' Once, in your own words, somewhere in that turn: the tour is always on offer, they can ask you to show them any part of this any time. Never bring it up again.',
|
||||
`5. Then the fork: one short sentence in your own words — you want to actually build them something, not just talk about it — then the line ::ask{question="${FORK_QUESTION}" options="${forkOptions().join('|')}" input="true"} alone as its own paragraph.`,
|
||||
...(fallback.length
|
||||
? [
|
||||
@@ -201,7 +202,7 @@ export function buildChatOnboardingPrompt(suggestedName?: string | null, signedI
|
||||
'6. Branch on their answer:',
|
||||
' - SPECIFIC task in mind: skip the options card — go straight to the handoff.',
|
||||
` - "${machine}": the machine itself is the job. Ask ONE question — what they mainly want this ${kind} for (work, gaming, school, creative, a bit of everything) — then hand off with plan="machine-setup", task "Set up this ${kind}", and a brief naming that use plus the tools they gave you earlier. Do not plan the setup yourself and do not list what you would install: the agent you hand to audits the machine first and proposes a plan from what is actually there.`,
|
||||
` - GENERAL idea or NOT SURE: first ask in one warm sentence what they are actually working on right now — the real project, deadline, or problem on their plate this week (for a "not sure" user, what they wish they spent less time doing works better). One short follow-up if the answer is vague, then ::onboarding{step="working" value="THEIR_ANSWER"} on a line of its own (THEIR_ANSWER = one line, their key details, under 140 characters; renders as nothing, it just saves what they said). Then a card of options built from that answer plus their tools, again on a line of its own: ::onboarding{step="first" options="First idea|Second idea|Third idea"} — 2 to 4 options, each a short phrase (under 60 chars), spanning simple (a reminder) to complex (a dashboard), all specific to THIS user, separated by |. Their tap IS their reply — hand off from it.`,
|
||||
` - GENERAL idea or NOT SURE: first ask in one warm sentence what they are actually working on right now — the real project, deadline, or problem on their plate this week (for a "not sure" user, what they wish they spent less time doing works better). One short follow-up if the answer is vague, then ::onboarding{step="working" value="THEIR_ANSWER"} on a line of its own (THEIR_ANSWER = one line, their key details, under 140 characters; renders as nothing, it just saves what they said). Then a card of options built from that answer plus their apps, again on a line of its own: ::onboarding{step="first" options="First idea|Second idea|Third idea"} — 2 to 4 options, each a short phrase (under 60 chars), spanning simple (a reminder) to complex (a dashboard), all specific to THIS user, separated by |. THE APPS THEY PICKED DRIVE THESE OPTIONS: someone who picked Gmail and Calendar should see an inbox or schedule idea ("A morning brief of today's meetings and unread mail"), someone who picked GitHub and Linear should see a repo or ticket idea, and someone who picked nothing gets ideas that need no account at all. At least one option should stand on its own without any connection, so there is always a pick that runs today. Their tap IS their reply — hand off from it.`,
|
||||
// Plugins are the strongest first build we can offer — the result lands
|
||||
// inside the window they are already looking at, in seconds, and it is
|
||||
// theirs. But only for the answers that actually suit it: forcing one on
|
||||
@@ -210,7 +211,7 @@ export function buildChatOnboardingPrompt(suggestedName?: string | null, signedI
|
||||
' WHEN A PLUGIN FITS, MAKE IT ONE OF THOSE OPTIONS. Hermes can build pieces of its own interface — a small chip in the status bar, a button by the composer, a panel beside the chat — and the user watches it appear in this window as you write it. That is the best first build available whenever what they described is something they would want to SEE or REACH at a glance: a number they keep checking, a list they keep opening, a status they keep asking about, a thing they wish were one click instead of five. Phrase it as the outcome, never as the mechanism ("A panel with today\'s tickets", not "Write a plugin"). Roughly one option, not the whole card, and only alongside the other shapes — a task that is genuinely just a task (draft this, research that, rename these files) should not be bent into an interface.',
|
||||
' If they pick that one, hand off with plan="plugin" on the handoff line.',
|
||||
` - "${FORK_OPTIONS.skip}": say one short line that the app is theirs and this chat stays here if they ever want a hand, then stand down. No more questions, no handoff.`,
|
||||
' CRITICAL for every branch: the first task must need NO external account or OAuth (no Gmail, no Slack, no Google sign-in) — connecting the apps they picked is optional and happens only with their consent in the build chat. Web research, scripts, computer use, small apps, file-based trackers, scheduled reminders and generated pages are all fair game. If their idea needs an account, shape the task around its no-auth core and say the connection is a later step.',
|
||||
' CRITICAL for every branch: the first task must be FINISHABLE with no external account or OAuth (no Gmail, no Slack, no Google sign-in). If the option they picked leans on one of their apps, that is fine and expected — the build chat offers the connection as a Connect card and, with their consent, uses it; without it, the task still ships its no-auth core (a local brief, a file-based tracker, a scheduled reminder) and names the connection as the step that lights it up. Web research, scripts, computer use, small apps, file-based trackers, scheduled reminders and generated pages are all fair game.',
|
||||
'7. THE HANDOFF — you do not build the task in this conversation. Once the task is decided, reply with ONE short sentence framing it (you are giving the work its own chat so it has room, and this one stays open), then ::onboarding{step="handoff" task="short task name" brief="the build instruction, one sentence, written as the user\'s ask"} on a line of its own — task under 40 chars, brief under 200. Add plan="machine-setup" to that same line when the job is setting up their computer, or plan="plugin" when it is a piece of the Hermes interface. The app opens the session, moves the user into it, and starts the build from your brief.',
|
||||
'8. Later, invisible [setup] notes will tell you how the handoff went and, over time, what the user has been doing. When the handoff-complete note arrives, follow its instructions: one short line that you are around if they want a hand, then stop. If a handoff-failed note arrives instead, explain briefly that the first build did not start and point to Retry first build. Do not start another copy here or promise the build is running.',
|
||||
'Whenever you draft reusable text for them (an email, a pitch, a template, a post), put the draft in a fenced code block so they can copy it in one click — never inline in your prose. Your own commentary stays outside the block.',
|
||||
|
||||
@@ -17,6 +17,7 @@ import { evaluateRuntimeReadiness, type RuntimeReadinessResult } from '@/lib/run
|
||||
import { setMainModelAssignment } from '@/store/cron-model-impact'
|
||||
import { ackFreeTierNotice, freeTierReadyPending, refreshFreeTierStatus, setFreeTierRoute } from '@/store/free-tier'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { guidedOnboardingActive } from '@/store/onboarding-gate'
|
||||
import type { ModelOptionProvider, OAuthProvider, OAuthStartResponse } from '@/types/hermes'
|
||||
|
||||
type PkceStart = Extract<OAuthStartResponse, { flow: 'pkce' }>
|
||||
@@ -484,6 +485,15 @@ async function refreshProviders() {
|
||||
}
|
||||
|
||||
export function requestDesktopOnboarding(reason = DEFAULT_ONBOARDING_REASON) {
|
||||
// Not during the guided first launch. The free tier carries inference
|
||||
// there, and a credential probe that fires anyway (a free-tier token mid
|
||||
// refresh, a setup-profile session before its runtime settles) would drop
|
||||
// the provider picker over the guide the user is in the middle of. Sign-in
|
||||
// is offered where the guide chooses to, on its own ready screen.
|
||||
if (guidedOnboardingActive()) {
|
||||
return
|
||||
}
|
||||
|
||||
patch({ reason: reason.trim() || DEFAULT_ONBOARDING_REASON, requested: true })
|
||||
}
|
||||
|
||||
@@ -501,7 +511,7 @@ let pendingCredentialWarning: null | string = null
|
||||
export function requestDesktopOnboardingForCredentialWarning(reason: null | string | undefined) {
|
||||
const warning = reason?.trim()
|
||||
|
||||
if (!warning || !isProviderSetupErrorMessage(warning)) {
|
||||
if (!warning || !isProviderSetupErrorMessage(warning) || guidedOnboardingActive()) {
|
||||
pendingCredentialWarning = null
|
||||
|
||||
return
|
||||
@@ -721,6 +731,15 @@ async function applyFreeTierIntro(ctx: OnboardingContext, runtime: RuntimeReadin
|
||||
setFreeTierRoute(runtime.freeTier)
|
||||
const status = await refreshFreeTierStatus(ctx.requestGateway)
|
||||
|
||||
// The guided first launch IS the introduction. Raising the ready screen on
|
||||
// top of it (a readiness round fires when the layout pick assembles the
|
||||
// window) covered the guide mid-conversation, and dismissing it remounted
|
||||
// the card the user had just answered. The guide acks the notice itself
|
||||
// when it hands off.
|
||||
if (guidedOnboardingActive()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (freeTierReadyPending(status, runtime.freeTier ?? null)) {
|
||||
patch({ freeTierReady: true })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""A provider retry backoff names itself on the live status line.
|
||||
|
||||
The buffered retry status replays only if every retry fails, so during the
|
||||
backoff itself the user used to see an anonymous spinner — and right after a
|
||||
tool that just finished (a connector sign-in landing, say) it read as the
|
||||
agent going silent. The wait notice is transient (rewritten by the next
|
||||
frame, cleared on recovery) and rides the frame long provider waits already
|
||||
use, so it adds none of the transcript chatter the buffer exists to avoid."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from agent.turn_recovery import compute_error_backoff
|
||||
|
||||
|
||||
def test_retry_backoff_names_the_wait_on_the_live_status_line():
|
||||
agent = MagicMock()
|
||||
agent._client_log_context.return_value = ""
|
||||
|
||||
wait = compute_error_backoff(
|
||||
agent, RuntimeError("502"), retry_count=1, max_retries=3,
|
||||
is_rate_limited=False, is_zai_coding_overload=False,
|
||||
base_url="https://example.test/v1", model="test/model",
|
||||
)
|
||||
|
||||
assert wait > 0
|
||||
# Still buffered for the exhausted-retries replay …
|
||||
agent._buffer_status.assert_called_once()
|
||||
# … and named live while the backoff runs.
|
||||
agent._emit_wait_notice.assert_called_once()
|
||||
text = agent._emit_wait_notice.call_args.args[0]
|
||||
assert text.startswith("⏳ waiting on provider")
|
||||
assert "attempt 1/3" in text
|
||||
@@ -208,3 +208,32 @@ def test_default_bearer_gate_accepts_both_deployed_hosts_only():
|
||||
"http://tool-gateway.nousresearch.com/api/vendorx/generations",
|
||||
):
|
||||
assert not managed_gateway_auth.is_managed_nous_gateway_url(untrusted)
|
||||
|
||||
|
||||
def test_read_nous_provider_state_falls_back_to_global_root_for_share_auth_profiles(tmp_path, monkeypatch):
|
||||
# A profile created with ``share_auth`` has no auth.json of its own; it signs in with the
|
||||
# root identity. The connector gate must see that identity, or manage_connections vanishes
|
||||
# from the profile's tool list while every other credential reader still works.
|
||||
root = tmp_path / ".hermes"
|
||||
profile = root / "profiles" / "hermes-setup"
|
||||
profile.mkdir(parents=True)
|
||||
(root / "auth.json").write_text(json.dumps({
|
||||
"version": 1,
|
||||
"providers": {"nous": {"auth_method": "anonymous", "access_token": "tok"}},
|
||||
}))
|
||||
monkeypatch.setenv("HERMES_HOME", str(profile))
|
||||
monkeypatch.setenv("HERMES_GUEST_ONBOARDING", "1")
|
||||
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
|
||||
|
||||
import hermes_constants
|
||||
from hermes_cli import auth as auth_mod
|
||||
|
||||
monkeypatch.setattr(hermes_constants, "get_default_hermes_root", lambda: root)
|
||||
monkeypatch.setattr(auth_mod, "get_hermes_home", lambda: profile)
|
||||
monkeypatch.setattr(auth_mod, "_global_auth_store_cache", None)
|
||||
monkeypatch.setattr(auth_mod, "_auth_file_path", lambda: profile / "auth.json")
|
||||
|
||||
state = managed_tool_gateway._read_nous_provider_state()
|
||||
|
||||
assert state is not None
|
||||
assert state["auth_method"] == "anonymous"
|
||||
|
||||
@@ -40,13 +40,16 @@ def auth_json_path():
|
||||
def _read_nous_provider_state() -> Optional[dict]:
|
||||
"""The profile's Nous state, or None. A free-tier identity counts only while the free tier is on:
|
||||
with ``nous.guest: false`` it is invisible here, so no cached or refreshed token of it is ever
|
||||
attached to a request."""
|
||||
attached to a request.
|
||||
|
||||
Resolves through the same profile-then-global-root fallback every other credential reader
|
||||
uses: a profile created with ``share_auth`` has no ``auth.json`` of its own and signs in with
|
||||
the root identity. Reading only ``HERMES_HOME/auth.json`` made that profile look signed out to
|
||||
the connector gate alone, so ``manage_connections`` vanished from its tool list."""
|
||||
try:
|
||||
path = auth_json_path()
|
||||
if not path.is_file():
|
||||
return None
|
||||
providers = json.loads(path.read_text(encoding="utf-8-sig")).get("providers", {})
|
||||
nous_provider = providers.get("nous", {}) if isinstance(providers, dict) else None
|
||||
from hermes_cli.auth import get_provider_auth_state
|
||||
|
||||
nous_provider = get_provider_auth_state("nous")
|
||||
if not isinstance(nous_provider, dict):
|
||||
return None
|
||||
from hermes_cli.anon_auth import guest_enabled, is_guest_state
|
||||
|
||||
Reference in New Issue
Block a user