diff --git a/.changeset/ai-plan-approve-feedback.md b/.changeset/ai-plan-approve-feedback.md new file mode 100644 index 000000000..65edf2c81 --- /dev/null +++ b/.changeset/ai-plan-approve-feedback.md @@ -0,0 +1,12 @@ +--- +'@object-ui/plugin-chatbot': patch +'@object-ui/app-shell': patch +--- + +Plan-card approval gives immediate in-card feedback (#2627): clicking +"Build it" flips the clicked card to a spinning "Building…" badge right away +(the approval's chat-level effects land at the bottom of the thread, outside +the viewport, so the card looked untouched for ~10s and users double-clicked). +The durable Built state still derives from the message stream; an approval +that never left the client (rate limit / offline) rolls the badge back so the +button returns. New `planBuildingLabel` prop (AiChatPage passes zh). diff --git a/.changeset/login-config-race-sso-timeout.md b/.changeset/login-config-race-sso-timeout.md new file mode 100644 index 000000000..038e22666 --- /dev/null +++ b/.changeset/login-config-race-sso-timeout.md @@ -0,0 +1,21 @@ +--- +'@object-ui/auth': patch +--- + +Login-page auth-config hardening (#2625, #2626): + +- `createAuthClient.getConfig` now single-flights + caches the `/auth/config` + fetch (the login page's three consumers used to fire three requests) and + retries failures with backoff (500ms/1.5s/3.5s, 8s per-attempt abort) before + rejecting. A cold-starting environment kernel no longer strands the page + without its SSO buttons; a final failure clears the cache so later callers + retry. +- `LoginForm` holds a spinner instead of painting the password-form defaults + while config resolves — an SSO-only deployment must never flash a password + wall at JIT users who have no password. A failed config still falls back to + the password form (break-glass beats lock-out). +- `signInWithProvider` gains a 20s watchdog: a sign-in request that hangs now + rejects with a clear timeout error so the provider button recovers instead + of spinning forever. +- Removed LoginForm's duplicate "or" divider — SocialSignInButtons already + renders its own, and the stacked pair read as a rendering glitch. diff --git a/.changeset/register-double-divider.md b/.changeset/register-double-divider.md new file mode 100644 index 000000000..0c8342810 --- /dev/null +++ b/.changeset/register-double-divider.md @@ -0,0 +1,8 @@ +--- +'@object-ui/auth': patch +--- + +RegisterForm: drop the duplicate "or" divider (matching the LoginForm fix in +#2629). SocialSignInButtons already renders its own "or continue with email" +divider under the provider buttons; RegisterForm stacked a second "OR" line on +top, which read as a rendering glitch on the sign-up page. diff --git a/.changeset/welcome-cta-autocreate.md b/.changeset/welcome-cta-autocreate.md new file mode 100644 index 000000000..ed4cef79e --- /dev/null +++ b/.changeset/welcome-cta-autocreate.md @@ -0,0 +1,14 @@ +--- +'@object-ui/components': patch +'@object-ui/app-shell': patch +--- + +Welcome-page "Create your environment" deep-links straight into the create +dialog (#844): `action:button` gains a client-side `autoTrigger` flag (runs +the action once on mount — same execute path as a click, so param dialogs / +confirms / entitlement gates still apply), and the environments list consumes +`?runAction=create_environment` to mark its create action once entitlements +resolve (upgrade-locked orgs get the upgrade prompt instead; the param is +stripped after consumption so refresh/back don't re-open). Also localizes the +EnvironmentListToolbar's state-aware label overrides ({en,zh}) — they were +hard-coded English inside a zh console. diff --git a/packages/app-shell/src/console/ai/AiChatPage.tsx b/packages/app-shell/src/console/ai/AiChatPage.tsx index e18d0f97f..294cd6bf6 100644 --- a/packages/app-shell/src/console/ai/AiChatPage.tsx +++ b/packages/app-shell/src/console/ai/AiChatPage.tsx @@ -2158,6 +2158,9 @@ export function ChatPane({ planApproveLabel={t('console.ai.planApprove', { defaultValue: 'Build it' })} planAdjustLabel={t('console.ai.planAdjust', { defaultValue: 'Adjust' })} planBuiltLabel={t('console.ai.planBuilt', { defaultValue: 'Built' })} + planBuildingLabel={ + convZh ? '正在搭建…' : t('console.ai.planBuilding', { defaultValue: 'Building…' }) + } planReadyLabel={t('console.ai.planReady', { defaultValue: 'The plan is ready. Build it now, or tell me what to adjust.', })} diff --git a/packages/app-shell/src/console/home/CloudOnboardingNext.tsx b/packages/app-shell/src/console/home/CloudOnboardingNext.tsx index fd685052a..02797d663 100644 --- a/packages/app-shell/src/console/home/CloudOnboardingNext.tsx +++ b/packages/app-shell/src/console/home/CloudOnboardingNext.tsx @@ -161,7 +161,12 @@ export function CloudOnboardingNext({ properties }: CloudOnboardingNextProps) {
{showCreatePrimary ? ( - diff --git a/packages/app-shell/src/console/home/__tests__/CloudOnboardingNext.test.tsx b/packages/app-shell/src/console/home/__tests__/CloudOnboardingNext.test.tsx index 09848b401..fa4ba8628 100644 --- a/packages/app-shell/src/console/home/__tests__/CloudOnboardingNext.test.tsx +++ b/packages/app-shell/src/console/home/__tests__/CloudOnboardingNext.test.tsx @@ -53,7 +53,11 @@ describe('CloudOnboardingNext', () => { expect(screen.queryByText('Open Production')).toBeNull(); fireEvent.click(create); - expect(navigateMock).toHaveBeenCalledWith('/apps/cloud_control/sys_environment'); + // Deep-links into the create dialog (#844): the environments list consumes + // `runAction=create_environment` and auto-opens its create action. + expect(navigateMock).toHaveBeenCalledWith( + '/apps/cloud_control/sys_environment?runAction=create_environment', + ); }); it('shows "Open Production" once the org has a production env', async () => { diff --git a/packages/app-shell/src/environment/EnvironmentListToolbar.tsx b/packages/app-shell/src/environment/EnvironmentListToolbar.tsx index 96cc4e5f3..1cf27a1fb 100644 --- a/packages/app-shell/src/environment/EnvironmentListToolbar.tsx +++ b/packages/app-shell/src/environment/EnvironmentListToolbar.tsx @@ -21,6 +21,7 @@ * so only the label/variant changes — no duplicate POST logic here. */ +import { useEffect, useRef, useState } from 'react'; import { SchemaRenderer } from '@object-ui/react'; import { Button } from '@object-ui/components'; import { Plus } from 'lucide-react'; @@ -33,6 +34,51 @@ import { const CREATE_ACTION = 'create_environment'; +/** Resolve an inline {en,zh} label against the document locale. */ +function pick(label: { en: string; zh: string }): string { + const lang = + (typeof document !== 'undefined' && document.documentElement.getAttribute('lang')) || 'en'; + return lang.toLowerCase().startsWith('zh') ? label.zh : label.en; +} + +/** + * Deep-link support (#844): `?runAction=create_environment` on the + * environments route auto-opens the create dialog once entitlements have + * resolved — the welcome page's "Create your environment" CTA uses it so the + * user doesn't land on the list and have to find the create button again. + * The param is consumed exactly once (stripped from the URL on consumption) + * so refresh / back don't re-open the dialog. + */ +function useAutoRunCreate(ctaKind: string | null): boolean { + // Deliberately router-free (window.location + history.replaceState): the + // toolbar also renders in tests/hosts without a Router, and the param is a + // one-shot signal, not navigation state. + const [requested] = useState(() => { + try { + return new URLSearchParams(window.location.search).get('runAction') === CREATE_ACTION; + } catch { + return false; + } + }); + const consumed = useRef(false); + // Only meaningful once the entitlement state has resolved: `upgrade_...` + // routes to the upgrade dialog instead, and while loading we must not + // trigger an action whose meaning (prod vs dev create) isn't known yet. + const shouldRun = requested && !consumed.current && ctaKind != null; + useEffect(() => { + if (!shouldRun) return; + consumed.current = true; + try { + const url = new URL(window.location.href); + url.searchParams.delete('runAction'); + window.history.replaceState(window.history.state, '', url); + } catch { + /* URL cleanup is cosmetic — never fail the trigger over it */ + } + }, [shouldRun]); + return shouldRun; +} + interface Props { /** Toolbar actions already localized by the caller (ObjectView). */ actions: any[]; @@ -44,9 +90,20 @@ interface Props { export function EnvironmentListToolbar({ actions, entitlements, onUpgrade }: Props) { const toolbarActions = (actions || []).filter((a: any) => a?.locations?.includes('list_toolbar')); - if (toolbarActions.length === 0) return null; - const ctaKind = entitlements?.ready ? decideEnvironmentCta(entitlements) : null; + const autoRunCreate = useAutoRunCreate(toolbarActions.length > 0 ? ctaKind : null); + + // Deep-linked "create" while in the upgrade state opens the SAME upgrade + // prompt — the honest answer to "create" here. In an effect, not render: + // onUpgrade sets parent state. + useEffect(() => { + if (autoRunCreate && ctaKind === 'upgrade_for_development' && entitlements) { + onUpgrade(upgradeDialogSpec(entitlements)); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [autoRunCreate, ctaKind]); + + if (toolbarActions.length === 0) return null; // Upgrade state: a free-plan org clicking "create" must NOT POST-then-403. // Render a primary button that opens the upgrade prompt, plus any other @@ -67,7 +124,7 @@ export function EnvironmentListToolbar({ actions, entitlements, onUpgrade }: Pro data-testid="environment-add-upgrade" > - Add environment + {pick({ en: 'Add environment', zh: '新建环境' })} ); @@ -75,13 +132,25 @@ export function EnvironmentListToolbar({ actions, entitlements, onUpgrade }: Pro // setup_production / add_development / loading: render the bar, overriding only // the create action's label (and promoting production setup to a primary CTA). + // Labels are locale-aware — the metadata label is already localized by the + // caller, but these state-aware overrides used to be hard-coded English, + // which flashed an English button in a zh console (#844). const renderedActions = toolbarActions.map((a: any) => { if (a?.name !== CREATE_ACTION || ctaKind == null) return a; if (ctaKind === 'setup_production') { - return { ...a, label: 'Set up your production environment', variant: 'primary' }; + return { + ...a, + label: pick({ en: 'Set up your production environment', zh: '创建你的生产环境' }), + variant: 'primary', + ...(autoRunCreate ? { autoTrigger: true } : {}), + }; } // add_development - return { ...a, label: 'Add development environment' }; + return { + ...a, + label: pick({ en: 'Add development environment', zh: '新建开发环境' }), + ...(autoRunCreate ? { autoTrigger: true } : {}), + }; }); return ( diff --git a/packages/app-shell/src/environment/__tests__/EnvironmentListToolbar.test.tsx b/packages/app-shell/src/environment/__tests__/EnvironmentListToolbar.test.tsx index 8a78aec8c..e17d1c210 100644 --- a/packages/app-shell/src/environment/__tests__/EnvironmentListToolbar.test.tsx +++ b/packages/app-shell/src/environment/__tests__/EnvironmentListToolbar.test.tsx @@ -14,7 +14,7 @@ vi.mock('@object-ui/react', async (importActual) => ({ SchemaRenderer: ({ schema }: any) => (
{(schema.actions || []).map((a: any) => ( - + ))}
), @@ -62,3 +62,31 @@ describe('EnvironmentListToolbar', () => { expect(screen.queryByTestId('environment-add-upgrade')).toBeNull(); }); }); + +describe('EnvironmentListToolbar — ?runAction=create_environment deep link (#844)', () => { + const withRunActionParam = () => { + const url = new URL(window.location.href); + url.searchParams.set('runAction', 'create_environment'); + window.history.replaceState(null, '', url); + }; + + it('marks the create action autoTrigger once entitlements resolve, then strips the param', async () => { + withRunActionParam(); + render(); + const btn = screen.getByText('Set up your production environment'); + // The SchemaRenderer stub surfaces autoTrigger as data-autotrigger. + expect(btn.getAttribute('data-autotrigger')).toBe('true'); + // Param is consumed exactly once — stripped from the URL. + await vi.waitFor(() => { + expect(new URL(window.location.href).searchParams.get('runAction')).toBeNull(); + }); + }); + + it('upgrade state: deep link opens the upgrade prompt instead of a create POST', async () => { + withRunActionParam(); + const onUpgrade = vi.fn(); + render(); + await vi.waitFor(() => expect(onUpgrade).toHaveBeenCalledTimes(1)); + expect(onUpgrade.mock.calls[0][0]).toMatchObject({ code: 'DEV_ENV_PLAN_LOCKED' }); + }); +}); diff --git a/packages/auth/src/LoginForm.tsx b/packages/auth/src/LoginForm.tsx index bcd0fbb03..795192a7a 100644 --- a/packages/auth/src/LoginForm.tsx +++ b/packages/auth/src/LoginForm.tsx @@ -188,6 +188,13 @@ export function LoginForm({ // state because SSO routing is a raw fetch, not the shared `signIn` (whose // `isLoading` drives the password submit button). const [ssoSubmitting, setSsoSubmitting] = useState(false); + // Config not resolved yet: render a spinner INSTEAD of the password-form + // defaults. Painting the defaults first showed an SSO-only deployment a + // password wall (no SSO button, no enforced collapse) whenever the config + // fetch was slow/failed on first load — and platform-SSO JIT users have no + // password at all (#2625). A FAILED fetch (after the client's retries) + // still falls back to the password form: break-glass beats lock-out. + const [configPending, setConfigPending] = useState(true); useEffect(() => { let cancelled = false; @@ -206,6 +213,9 @@ export function LoginForm({ .catch(() => { // SSO is an enhancement, not required — leave the buttons/form hidden // or shown at their safe defaults. + }) + .finally(() => { + if (!cancelled) setConfigPending(false); }); return () => { cancelled = true; @@ -349,6 +359,14 @@ export function LoginForm({ />
+ {configPending ? ( + /* Auth config still resolving — hold the layout instead of painting + the password-form defaults that may be wrong for this server. */ +
+ +
+ ) : ( + <> setHasSocialProviders(hasProviders)} /> {passwordFormVisible ? ( @@ -357,8 +375,9 @@ export function LoginForm({ Server-side the number is guarded by a per-number cooldown + hourly cap — the resend button mirrors it with a countdown. */
- {hasSocialProviders && } - + {/* No divider here: SocialSignInButtons already renders its own + "or continue with email" divider under the provider buttons — + stacking a second "or" line read as a rendering glitch (#2625). */} {error && }
@@ -428,8 +447,9 @@ export function LoginForm({ ) : (
- {hasSocialProviders && } - + {/* No divider here: SocialSignInButtons already renders its own + "or continue with email" divider under the provider buttons — + stacking a second "or" line read as a rendering glitch (#2625). */} {error && }
@@ -539,6 +559,8 @@ export function LoginForm({
)} + + )}
{registerUrl && !ssoEnforced && ( diff --git a/packages/auth/src/RegisterForm.tsx b/packages/auth/src/RegisterForm.tsx index 4e4be716b..66f24577c 100644 --- a/packages/auth/src/RegisterForm.tsx +++ b/packages/auth/src/RegisterForm.tsx @@ -15,7 +15,6 @@ import { AUTH_INPUT_CLASS, AUTH_LINK_CLASS, AUTH_PRIMARY_BUTTON_CLASS, - AuthDivider, AuthErrorBanner, AuthFormHeader, AuthSpinner, @@ -127,7 +126,6 @@ export function RegisterForm({ const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); const [error, setError] = useState(null); - const [hasSocialProviders, setHasSocialProviders] = useState(false); const l = { nameLabel: labels.nameLabel ?? 'Name', @@ -185,11 +183,13 @@ export function RegisterForm({ />
- setHasSocialProviders(has)} /> + - {hasSocialProviders && } - + {/* No divider here: SocialSignInButtons already renders its own + "or continue with email" divider under the provider buttons — + stacking a second "or" line read as a rendering glitch (#2625, + matching the LoginForm fix). */} {error && }
diff --git a/packages/auth/src/__tests__/LoginForm.test.tsx b/packages/auth/src/__tests__/LoginForm.test.tsx index 47f9a8394..d141f7f32 100644 --- a/packages/auth/src/__tests__/LoginForm.test.tsx +++ b/packages/auth/src/__tests__/LoginForm.test.tsx @@ -287,3 +287,29 @@ describe('LoginForm — SSO button pending state (objectui#2458 item 1)', () => } }); }); + +describe('LoginForm — config-loading gate (#2625)', () => { + it('holds a loading state instead of painting the password form while config resolves', async () => { + let resolveConfig!: (c: AuthPublicConfig) => void; + const pending = new Promise((resolve) => { resolveConfig = resolve; }); + renderLogin(createMockClient({}, { getConfig: vi.fn().mockReturnValue(pending) })); + + // While pending: spinner, NO password form (an SSO-only server must never + // flash a password wall at users who have no password). + expect(screen.getByTestId('login-config-loading')).toBeTruthy(); + expect(screen.queryByLabelText('Email')).toBeNull(); + + resolveConfig({ features: { ssoEnforced: true } }); + await waitFor(() => expect(screen.queryByTestId('login-config-loading')).toBeNull()); + // Enforced mode honoured on FIRST paint after resolve: password form + // hidden, break-glass link offered. + expect(screen.queryByLabelText('Email')).toBeNull(); + expect(screen.getByRole('button', { name: 'Use a password instead' })).toBeTruthy(); + }); + + it('falls back to the password form when config ultimately fails (break-glass beats lock-out)', async () => { + renderLogin(createMockClient({}, { getConfig: vi.fn().mockRejectedValue(new Error('config down')) })); + await screen.findByLabelText('Email'); + expect(screen.queryByTestId('login-config-loading')).toBeNull(); + }); +}); diff --git a/packages/auth/src/__tests__/createAuthClient.test.ts b/packages/auth/src/__tests__/createAuthClient.test.ts index 3641d3b88..5c1843fb7 100644 --- a/packages/auth/src/__tests__/createAuthClient.test.ts +++ b/packages/auth/src/__tests__/createAuthClient.test.ts @@ -456,3 +456,91 @@ describe('createAuthClient — signInWithProvider redirect contract (objectui#24 ).rejects.toThrow('Provider not found'); }); }); + +describe('createAuthClient — getConfig single-flight / cache / retry (#2625)', () => { + it('single-flights concurrent callers and caches the success', async () => { + const { mockFn, calls } = createMockFetch({ + '/config': { body: { data: { features: { sso: true } } } }, + }); + const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn }); + const [a, b] = await Promise.all([client.getConfig(), client.getConfig()]); + const c = await client.getConfig(); + expect(a.features?.sso).toBe(true); + expect(b).toBe(a); + expect(c).toBe(a); + // Three consumers, ONE request — the login page used to fire three. + expect(calls.filter((x) => x.url.includes('/config'))).toHaveLength(1); + }); + + it('retries a failing config fetch with backoff before resolving', async () => { + vi.useFakeTimers(); + try { + let attempts = 0; + const mockFn = vi.fn(async () => { + attempts += 1; + if (attempts < 3) { + return new Response(JSON.stringify({ message: 'cold start' }), { status: 503 }); + } + return new Response(JSON.stringify({ data: { features: { ssoEnforced: true } } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }); + const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn }); + const pending = client.getConfig(); + // Backoff schedule is 500ms → 1500ms; advance past both. + await vi.advanceTimersByTimeAsync(500 + 1500 + 50); + const config = await pending; + expect(config.features?.ssoEnforced).toBe(true); + expect(attempts).toBe(3); + } finally { + vi.useRealTimers(); + } + }); + + it('a final failure rejects but does not poison the cache', async () => { + vi.useFakeTimers(); + try { + let attempts = 0; + const mockFn = vi.fn(async () => { + attempts += 1; + if (attempts <= 4) { + return new Response('down', { status: 503 }); + } + return new Response(JSON.stringify({ data: { features: {} } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }); + const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn }); + const first = client.getConfig(); + const firstAssertion = expect(first).rejects.toThrow(/Failed to load auth config/); + await vi.advanceTimersByTimeAsync(500 + 1500 + 3500 + 50); + await firstAssertion; + // Next caller starts a FRESH cycle (attempt 5 succeeds immediately). + const second = await client.getConfig(); + expect(second.features).toBeDefined(); + expect(attempts).toBe(5); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe('createAuthClient — signInWithProvider watchdog (#2626)', () => { + it('rejects with a timeout error when the sign-in request never returns', async () => { + vi.useFakeTimers(); + try { + // A fetch that hangs forever — the cold-start failure mode where the + // provider button used to spin until a page refresh. + const mockFn = vi.fn(() => new Promise(() => { /* never settles */ })); + const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn }); + const pending = client.signInWithProvider('objectstack-cloud', { type: 'oidc' }); + const assertion = expect(pending).rejects.toThrow(/timed out/); + await vi.advanceTimersByTimeAsync(20_000 + 50); + await assertion; + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/packages/auth/src/createAuthClient.ts b/packages/auth/src/createAuthClient.ts index e3dda7d30..63185166f 100644 --- a/packages/auth/src/createAuthClient.ts +++ b/packages/auth/src/createAuthClient.ts @@ -229,6 +229,64 @@ export function createAuthClient(config: AuthClientConfig): AuthClient { return payload ?? {}; } + /** + * Fetch `GET {authUrl}/config` once, with a per-attempt timeout so a + * request that HANGS (a freshly provisioned environment whose kernel is + * still cold-starting) converts into a retryable failure instead of + * stalling the login page forever. + */ + async function fetchConfigAttempt(timeoutMs: number): Promise { + const controller = typeof AbortController !== 'undefined' ? new AbortController() : undefined; + const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : undefined; + try { + const url = `${origin}${basePath}/config`; + const response = await bearerFetch(url, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + ...(controller ? { signal: controller.signal } : {}), + }); + if (!response.ok) { + throw new Error(`Failed to load auth config (status ${response.status})`); + } + const body = (await response.json()) as + | { success?: boolean; data?: AuthPublicConfig; error?: { message?: string } } + | AuthPublicConfig; + // Server wraps the payload as `{ success, data }`; tolerate both shapes. + if (body && typeof body === 'object' && 'data' in body && body.data) { + return body.data as AuthPublicConfig; + } + return body as AuthPublicConfig; + } finally { + if (timer !== undefined) clearTimeout(timer); + } + } + + // Auth config is static per server boot: single-flight + cache the success + // so the login page's multiple consumers (LoginForm, SocialSignInButtons, + // RegisterForm) share ONE request instead of three. Failures RETRY with a + // short backoff before rejecting — a first-load failure used to render the + // page without its SSO buttons, which strands SSO-only users on a password + // wall they have no password for (#2625). A final failure clears the cache + // so the next caller starts a fresh cycle. + const CONFIG_RETRY_DELAYS_MS = [500, 1500, 3500]; + const CONFIG_ATTEMPT_TIMEOUT_MS = 8_000; + let configPromise: Promise | null = null; + + async function loadConfigWithRetry(): Promise { + let lastError: unknown; + for (let attempt = 0; attempt <= CONFIG_RETRY_DELAYS_MS.length; attempt++) { + try { + return await fetchConfigAttempt(CONFIG_ATTEMPT_TIMEOUT_MS); + } catch (err) { + lastError = err; + if (attempt < CONFIG_RETRY_DELAYS_MS.length) { + await new Promise((resolve) => setTimeout(resolve, CONFIG_RETRY_DELAYS_MS[attempt])); + } + } + } + throw lastError instanceof Error ? lastError : new Error(String(lastError)); + } + return { async signIn(credentials: SignInCredentials) { const { data, error } = await betterAuth.signIn.email({ @@ -549,36 +607,44 @@ export function createAuthClient(config: AuthClientConfig): AuthClient { }, async getConfig(): Promise { - const url = `${origin}${basePath}/config`; - const response = await bearerFetch(url, { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - }); - if (!response.ok) { - throw new Error(`Failed to load auth config (status ${response.status})`); - } - const body = (await response.json()) as - | { success?: boolean; data?: AuthPublicConfig; error?: { message?: string } } - | AuthPublicConfig; - // Server wraps the payload as `{ success, data }`; tolerate both shapes. - if (body && typeof body === 'object' && 'data' in body && body.data) { - return body.data as AuthPublicConfig; + if (!configPromise) { + configPromise = loadConfigWithRetry().catch((err) => { + configPromise = null; + throw err; + }); } - return body as AuthPublicConfig; + return configPromise; }, async signInWithProvider(providerId: string, options: SignInWithProviderOptions = {}) { const { type = 'social', callbackURL, errorCallbackURL } = options; + // Watchdog (#2626): a sign-in request that HANGS (e.g. the environment + // kernel is cold-starting on first open) used to leave the provider + // button spinning forever — no error, no way to retry. Convert a + // no-response into a rejection so the button contract (#2458 pending + + // inline error) can recover it. The abandoned request may still land + // later; the user retrying just starts a fresh redirect, which is safe. + const SIGN_IN_TIMEOUT_MS = 20_000; + const withTimeout = (p: Promise): Promise => new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error('Sign-in timed out — the server did not respond. Please try again.')), + SIGN_IN_TIMEOUT_MS, + ); + p.then( + (v) => { clearTimeout(timer); resolve(v); }, + (e) => { clearTimeout(timer); reject(e); }, + ); + }); // We pass `disableDefaultFetchPlugins: true` to better-auth above, // which also disables better-auth's `redirectPlugin` (the one that // navigates the browser to `data.url` when the server responds with // `{ url, redirect: true }`). The server still returns that payload // for OAuth/OIDC providers — we just have to honour it ourselves. - const signInSocial = async () => await betterAuth.signIn.social({ + const signInSocial = async () => await withTimeout(betterAuth.signIn.social({ provider: providerId as Parameters[0]['provider'], callbackURL, errorCallbackURL, - }) as { data: { url?: string; redirect?: boolean } | null; error: { message?: string; status: number } | null }; + })) as { data: { url?: string; redirect?: boolean } | null; error: { message?: string; status: number } | null }; let result: Awaited>; if (type === 'oidc') { // better-auth ≥ 1.7 registers generic OAuth/OIDC providers through @@ -592,7 +658,9 @@ export function createAuthClient(config: AuthClientConfig): AuthClient { const oauth2 = (betterAuth as unknown as { signIn: { oauth2?: (args: Record) => Promise<{ data: { url?: string; redirect?: boolean } | null; error: { message?: string; status: number } | null }> }; }).signIn.oauth2; - const legacy = oauth2 ? await oauth2({ providerId, callbackURL, errorCallbackURL }) : null; + const legacy = oauth2 + ? await withTimeout(oauth2({ providerId, callbackURL, errorCallbackURL })).catch(() => null) + : null; if (legacy && !legacy.error) { result = legacy; } diff --git a/packages/components/src/renderers/action/action-button.tsx b/packages/components/src/renderers/action/action-button.tsx index 302b127e5..3e49281b3 100644 --- a/packages/components/src/renderers/action/action-button.tsx +++ b/packages/components/src/renderers/action/action-button.tsx @@ -17,7 +17,7 @@ * - Variant / size / className overrides from schema */ -import React, { forwardRef, useCallback, useState } from 'react'; +import React, { forwardRef, useCallback, useEffect, useRef, useState } from 'react'; import { ComponentRegistry } from '@object-ui/core'; import type { ActionSchema } from '@object-ui/types'; import { useAction } from '@object-ui/react'; @@ -129,6 +129,23 @@ const ActionButtonRenderer = forwardRef( } }, [schema, execute, loading, localContext]); + // Client-side auto-trigger (#844): a caller (e.g. a welcome-page CTA that + // deep-links into "create") can mark an action `autoTrigger: true` to run + // it once as soon as the button mounts — the exact same execute path as a + // click, so param dialogs / confirms / entitlement gates all still apply. + // NOT persisted metadata: the flag only exists on client-composed schemas. + // The ref guards re-fires across re-renders; the flag flipping true later + // (state-dependent toolbars) still triggers exactly once. + const autoTriggered = useRef(false); + const autoTrigger = (schema as any).autoTrigger === true; + useEffect(() => { + if (!autoTrigger || autoTriggered.current) return; + autoTriggered.current = true; + void handleClick(); + // handleClick identity changes with schema/context churn; the ref makes + // this once-only regardless, so it's safe to depend on it. + }, [autoTrigger, handleClick]); + if (schema.visible && !isVisible) return null; return ( diff --git a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx index ddb1ae312..9997cdb6e 100644 --- a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx +++ b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx @@ -666,6 +666,8 @@ export interface ChatbotEnhancedProps extends React.HTMLAttributes( planApproveLabel = 'Build it', planAdjustLabel = 'Adjust', planBuiltLabel = 'Built', + planBuildingLabel = 'Building…', planReadyLabel = 'The plan is ready. Build it now, or tell me what to adjust.', planApproveMessage = 'Looks good — build it as proposed.', planApproveDefaultsMessage = 'Build it with your best assumptions; use sensible defaults for the open questions.', @@ -1501,6 +1504,10 @@ const ChatbotEnhanced = React.forwardRef( // clears any prior send-failure restore guard. lastSubmittedRef.current = text; restoredErrorRef.current = null; + // A newer send supersedes any pending approve as "the last send" — an + // unsent failure of THIS message must not roll back a plan card whose + // approval already reached the server (#2627). + lastApprovedPlanIdRef.current = null; onSendMessage?.(text, files); }, [onSendMessage] @@ -1508,6 +1515,7 @@ const ChatbotEnhanced = React.forwardRef( const handleSuggestionClick = React.useCallback( (text: string) => { + lastApprovedPlanIdRef.current = null; onSendMessage?.(text); }, [onSendMessage] @@ -1519,8 +1527,28 @@ const ChatbotEnhanced = React.forwardRef( // approval explicitly authorizes sensible defaults so a click never silently // drops them. const promptInputWrapRef = React.useRef(null); + // Optimistic per-card feedback (#2627): the approval's visible effect (a + // new user bubble + a streaming turn) lands at the BOTTOM of the thread — + // outside the viewport when the plan card is scrolled into view — so the + // card itself looked untouched for the ~10s until apply_blueprint started + // and users clicked again. Flip the clicked card to a "Building…" badge + // immediately; the durable Built state still derives from the message + // stream (builtPlanIds). A send that never left the client (rate limit / + // offline) rolls the flip back so the buttons return. + const [approvedPlanIds, setApprovedPlanIds] = React.useState>( + () => new Set(), + ); + const lastApprovedPlanIdRef = React.useRef(null); const handlePlanApprove = React.useCallback( - (hasOpenQuestions: boolean) => { + (hasOpenQuestions: boolean, toolCallId?: string) => { + if (toolCallId) { + lastApprovedPlanIdRef.current = toolCallId; + setApprovedPlanIds((prev) => { + const next = new Set(prev); + next.add(toolCallId); + return next; + }); + } onSendMessage?.(hasOpenQuestions ? planApproveDefaultsMessage : planApproveMessage); }, [onSendMessage, planApproveMessage, planApproveDefaultsMessage] @@ -1544,6 +1572,18 @@ const ChatbotEnhanced = React.forwardRef( if (!error || !isUnsentSendError(error)) return; if (restoredErrorRef.current === error) return; restoredErrorRef.current = error; + // The approval message never left the client — roll the optimistic + // "Building…" badge back so the Build-it button returns (#2627). + if (lastApprovedPlanIdRef.current) { + const failedId = lastApprovedPlanIdRef.current; + lastApprovedPlanIdRef.current = null; + setApprovedPlanIds((prev) => { + if (!prev.has(failedId)) return prev; + const next = new Set(prev); + next.delete(failedId); + return next; + }); + } const text = lastSubmittedRef.current; if (!text) return; const textarea = promptInputWrapRef.current?.querySelector('textarea'); @@ -2149,6 +2189,16 @@ const ChatbotEnhanced = React.forwardRef( {planBuiltLabel}
+ ) : approvedPlanIds.has(tool.toolCallId) ? ( +
+ + + {planBuildingLabel} + +
) : (
(
+ ) : approvedPlanIds.has(tool.toolCallId) ? ( +
+ + + {planBuildingLabel} + +
) : (
(