Skip to content

Commit 74db43e

Browse files
fix(console): Home's build chip and /ai read the agent catalog on a deployment without AI (#26716)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbDZiPLsrXtX5sZKRTuAwBEC)_ Fixes #26614 Clause-②: yes Metadata-protocol: none ## 维护者速读 - **改了什么**:没有 AI 智能体的部署上(`GET /api/v1/ai/agents` 为空或不可用),首页问候行的「构建应用」改为「在 Studio 中新建」,点开即进入 Studio 首页并直接弹出新建软件包对话框(`/studio?new=package`);没有「管理元数据」权限的会话则不显示这个按钮(「从模板开始」仍带着原因说明)。直接访问 `/_console/ai` 不再静默跳回首页,而是原地显示「AI 助手不可用 · 当前部署未启用 AI 助手」,带「返回首页」按钮,有权编写的管理员还会看到同一个「在 Studio 中新建」链接。有智能体的部署一切照旧。 - **为什么改**:在 HotCRM 本地(18.2.0、无 AI 智能体)上,「构建应用」承诺了一个无法开始的 AI 搭建,而 `/ai` 把人一声不响地送回首页,读起来像坏链接。 - **风险与回滚**:低。没有新端点、没有 spec 改动、没有元数据键;只读已有的智能体目录读数(`useAgents` 的同一份缓存)。按钮会等目录读数返回再画,所以有 AI 的部署上「构建应用」比以前晚一帧出现。新增发布面:i18n 键 `home.build.inStudio`(十个语言包)、`STUDIO_NEW_PACKAGE_PATH` 常量、`AiUnavailable` 抽成独立模块。`RequireAiSurface` 的 `redirectTo` prop 保留原名,改为决定该页面「返回首页」去哪。回滚即 revert 本 PR。 - **先看**:`ui/packages/app-shell/src/console/home/HomePage.tsx`(`useHomeAiAvailability` 的 `aiResolved` 与两枚构建按钮);`ui/packages/app-shell/src/console/ConsoleShell.tsx`(`RequireAiSurface` 不再重定向);`ui/packages/app-shell/src/views/studio-design/BuilderLanding.tsx`(`?new=package` 深链接)。 - **两分钟验证**:`pnpm --dir ui exec vitest run packages/app-shell/src/console/home/__tests__/HomePage.noAgentsBuildChip-26614.test.tsx packages/app-shell/src/console/__tests__/RequireAiSurface.test.tsx packages/app-shell/src/views/studio-design/BuilderLanding.newPackageDeepLink-26614.test.tsx`,预期全绿;或在没有 AI 的本地运行时打开 Console 首页,看到「在 Studio 中新建」,再打开 `/_console/ai` 看到「当前部署未启用 AI 助手」。 - **需要你做的**:无,席位落地。 ## What changes **Home's build chip reads the agent catalog.** `useHomeAiAvailability` (the Home's existing `useAgents` read of `GET /api/v1/ai/agents`, no second fetch path) now also reports `aiResolved`, with `useAiSurfaceEnabled`'s latch, so the chip waits for the catalog's answer instead of drawing one form and swapping to the other. For an admin on a runtime with `features.aiStudio` on: | catalog | may author | chip | |---|---|---| | one or more agents | yes | "Build an app" (「构建应用」) to `/studio`, unchanged | | one or more agents | no | "Build an app", dimmed with its reason, unchanged | | empty or failed | yes | "New in Studio" (「在 Studio 中新建」) to `/studio?new=package` | | empty or failed | no | no build chip; "Start with a template" keeps the reason | | not answered yet | any | no build chip | **`/ai` with no agent.** `RequireAiSurface` (`console/ConsoleShell.tsx`, the guard on the three `/ai` routes in `apps/console/src/App.tsx`) no longer redirects home. It renders the AI-unavailable screen in place, in a full-height frame: the title and description already in every pack (`console.ai.unavailableTitle`, `console.ai.unavailableDescription`: 「AI 助手不可用」 · 「当前部署未启用 AI 助手。其他功能一切正常。」), "Back to home" to the declared landing (objectui#7373, unchanged), and, for an admin who may author on a runtime with `features.aiStudio` on, a "New in Studio" link to `/studio?new=package`. That screen was `AiChatPage`'s private `AiUnavailable` (shown when the catalog resolves empty after the gate); it moves to `console/ai/AiUnavailable.tsx`, and both render it. `redirectTo` keeps its name (renaming a published prop breaks every host that passes it) and now sets where the screen's way home leads. **Studio's new-package flow.** `/studio?new=package` opens the Studio landing (`BuilderLanding`) with its create-package dialog open, and drops the param with a replace, so a refresh or Back does not reopen it. The path's one spelling is `STUDIO_NEW_PACKAGE_PATH` in `views/studio-design/studioNewPackage.ts`, a module of its own so Home and the `/ai` gate do not import `BuilderLanding` into the console's eager closure (`apps/console/src/components/studioBuilder.ts` says why). `new` is a page-scoped param of the landing, not one of `urlParams.ts`'s reserved names. **i18n.** One new key, `home.build.inStudio`, in all ten packs. The order named two new strings; the second (「此部署未启用 AI 助手」) already exists as `console.ai.unavailableDescription`, so it is reused, not added. **Callers of `RequireAiSurface`.** Measured with `git grep`: the only element callers are the three `/ai` routes in `apps/console/src/App.tsx`, and they change as described. `console/AppContent.tsx` and `apps/console/src/components/StudioRoute.tsx` name it only in comments, which this PR corrects; neither wraps anything with it. `ui/e2e/console-boot-indicator.spec.ts` names it in a comment only and pins no redirect. Five `apps/console` tests stub it as a pass-through, unchanged. ## Tests - `console/home/__tests__/HomePage.noAgentsBuildChip-26614.test.tsx` (new, real `HomePage`, a catalog stand-in that moves as `useAgents` does: empty first frame, loading, answer): with an agent, "Build an app" to `/studio` and dimmed for a non-author; with none, "New in Studio" to `/studio?new=package`; with none and no authoring capability, no build chip while the template chip keeps the reason; while the catalog is in flight, neither form. - `console/__tests__/RequireAiSurface.test.tsx`: the no-agent case now asserts the screen in place (no redirect, no retry); the Studio link for an admin who may author, and its absence for a non-admin, a non-author and a runtime without authoring; the two objectui#7373 cases now click the screen's "Back to home" and land on the declared landing or the launcher. - `views/studio-design/BuilderLanding.newPackageDeepLink-26614.test.tsx` (new, real `BuilderLanding` under a real router): the dialog opens on arrival and the param leaves the address; other params stay; the bare landing and an unknown value open nothing. - `console/__tests__/bootRedirectCoverage.test.tsx`: the no-agent case is no longer a redirect; it still asserts the deciding commit covers the viewport, which the screen's full-height frame does. - `console/home/__tests__/HomePage.greetingAndApps-12601.test.tsx`: its catalog stand-in now holds one custom agent, so the builder-chip cases it pins keep the "Build an app" form they were written for. ## Acceptance notes - The screenshot of the home on a runtime without AI is not attached: this container has no HotCRM deployment. - On a transient `/agents` failure the guard now shows the same "not enabled" screen it used to redirect away from: `useAiSurfaceEnabled` reports no error, so the guard cannot tell a failure from an empty catalog. `AiChatPage`'s own screen, reached when the guard passes, still tells them apart and offers a retry. - With agents present, the build chip appears once the catalog answers rather than on the first frame. ## Verification All local readings below were taken in a worktree on this branch; heads named per reading. <details><summary>Narrowed ui test run, head d566d91: 221 files, 2414 passed, 13 skipped</summary> `pnpm --dir ui exec vitest run --maxWorkers=2` over every app-shell and console test file that names `HomePage`, `ConsoleShell`, `AiChatPage`, `BuilderLanding`, `AiUnavailable` or `studioNewPackage` (132 files, from `git grep -l`), plus `packages/i18n/` whole: `Test Files 221 passed (221)`, `Tests 2414 passed | 13 skipped (2427)`, lock VERDICT command-exit 0. This is a declared narrowing; CI's Test UI shards run the full suite. </details> <details><summary>Final head a967d2f (one commit after the run above, BuilderLanding only)</summary> - Every `BuilderLanding.*` test and every `StudioRoute*` test: `Test Files 14 passed (14)`, `Tests 93 passed (93)`. - `turbo run type-check --filter=@object-ui/app-shell`: 64 tasks successful. On d566d91, `--filter=@object-ui/app-shell --filter=@object-ui/console --filter=@object-ui/i18n`: 72 tasks successful. - `pnpm --dir ui exec vitest run scripts/`: 178 passed, 2 skipped files; 5065 tests passed. - `pnpm check:new-line-citations` (from `ui/`): 0 new citations. - `eslint --no-inline-config` over the changed ui files: 0 errors (warnings only, of rules already warned across these files). </details> <details><summary>Derived gates: dispatch-gates --commands, 35 families, all exit 0</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 35 commands from the diff; each ran with its exit code recorded, all 0 (two of them, `check:query-options-erasure` and `check:type-check-debt`, re-run without the 300 s cap that first stopped them). `--ran` reconciliation: "35 derived famil(ies) accounted for — 35 run, 0 NOT-MEASURED". The derived set reads paths only, and the last commit touched no new path. Type-check lanes, wide-population families and the always-run tail are CI's. </details> `dogfood_battery`: not run (no `!` in the title, no `(narrowing)` arm). ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (`packages/spec` untouched.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (CI's Lint UI is the authority.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jUo2twLdcxA9c695m5)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent bd504f5 commit 74db43e

25 files changed

Lines changed: 605 additions & 98 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
fix(app-shell,i18n): on a deployment with no AI agent, Home's build chip opens Studio's new-package flow as "New in Studio", and `/ai` says no assistant is enabled instead of redirecting home
7+
8+
Clause-②: yes
9+
Metadata-protocol: none
10+
11+
- **Home's build chip reads the agent catalog.** On a deployment whose catalog (`GET /api/v1/ai/agents`) is empty or absent, the greeting row's "Build an app" (「构建应用」) promised a build nothing there could start. There it now reads "New in Studio" (「在 Studio 中新建」) and opens Studio's new-package flow; a session that may not author gets no build chip at all, while "Start with a template" keeps the reason. The chip waits for the catalog's answer, so neither form flashes first. With an agent in the catalog nothing changes.
12+
- **`/ai` with no agent.** `RequireAiSurface` used to redirect a visit to `/ai` home with no word of why. It now shows the AI-unavailable screen in place (「AI 助手不可用」 · 「当前部署未启用 AI 助手。其他功能一切正常。」), with the way home and, for an admin who may author on a runtime that offers authoring, the same "New in Studio" link. The screen is the one `AiChatPage` already showed for an empty catalog, now its own module (`console/ai/AiUnavailable.tsx`) shared by both. `RequireAiSurface`'s `redirectTo` prop keeps its name and now sets where that screen's way home leads.
13+
- **Studio deep link.** `/studio?new=package` opens the Studio landing with the create-package dialog open, and drops the param from the address. `STUDIO_NEW_PACKAGE_PATH` (`views/studio-design/studioNewPackage.ts`) is its one spelling.
14+
- **i18n.** New key `home.build.inStudio` in all ten packs.

‎ui/apps/console/src/App.tsx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -396,10 +396,10 @@ export function App() {
396396
397397
`RequireAiSurface` keeps these from dead-ending on a runtime that
398398
serves no AI (Community Edition): with AI unavailable a stale
399-
bookmark / external link redirects to home instead of mounting a
400-
chat with no agent to talk to. Cloud installs pass straight
401-
through. Purely additive runtime gating — same server signal the
402-
floating-chat FAB has always used.
399+
bookmark / external link gets a screen saying so, with the way
400+
home, instead of a chat with no agent to talk to. Cloud installs
401+
pass straight through. Purely additive runtime gating — same
402+
server signal the floating-chat FAB has always used.
403403
*/}
404404
<Route path="/ai" element={
405405
<ProtectedRoute>

‎ui/apps/console/src/components/StudioRoute.tsx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -128,10 +128,10 @@ export function RequireStudioAccess({
128128
* DECLARED landing (objectui#7373), which is the environment launcher
129129
* wherever no app declares one; an explicit value still wins.
130130
*
131-
* It was `redirectTo` while a non-holder was redirected there, the posture of
132-
* `RequireAiSurface`, which still redirects: a runtime that serves no agent
133-
* has nothing to refuse, and what reaches it is a stale link. A non-holder
134-
* here is refused a capability, and is now told so (objectui#12035).
131+
* It was `redirectTo` while a non-holder was redirected there. A non-holder
132+
* here is refused a capability, and is now told so (objectui#12035);
133+
* `RequireAiSurface` later took the same posture for a runtime that serves
134+
* no agent (objectstack#26614).
135135
*/
136136
homeTo?: string;
137137
}) {

‎ui/packages/app-shell/src/console/AppContent.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1218,8 +1218,8 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
12181218
// that leave the router — see `organizations/resolveHomeUrl.ts`). Both the
12191219
// declared landing and the launcher fallback are
12201220
// part of the outer skeleton every host that mounts this component provides
1221-
// (see this file's header), and `RequireAiSurface` in `ConsoleShell.tsx`
1222-
// already bounces the same way for a surface this runtime cannot serve.
1221+
// (see this file's header), and the no-assistant screen `RequireAiSurface`
1222+
// in `ConsoleShell.tsx` shows leads home the same way.
12231223
//
12241224
// Telling "you may not enter THIS app" apart from "this app is not published"
12251225
// is the other half of #4473 and stays gated on the backend permission-denied

‎ui/packages/app-shell/src/console/ConsoleShell.tsx‎

Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
*/
1212

1313
import { Suspense, useEffect, useRef, useState, type ReactNode } from 'react';
14-
import { Navigate, useLocation } from 'react-router-dom';
14+
import { Navigate, useLocation, useNavigate } from 'react-router-dom';
1515
import { AuthGuard, useAuth, createAuthenticatedFetch } from '@object-ui/auth';
1616
import { useObjectTranslation } from '@object-ui/i18n';
1717
import { SchemaRendererProvider, ActionProvider, NotificationProvider, HostMapStyleProvider } from '@object-ui/react';
@@ -31,6 +31,7 @@ import { appRouteSegment } from '../utils/appRoute.js';
3131
import { SETUP_APP_PACKAGE_ID, SETUP_APP_NAME } from './setupApp.js';
3232
import { useAiSurfaceEnabled } from '../hooks/useAiSurface.js';
3333
import { useHomePath } from '../hooks/useHomePath.js';
34+
import { AiUnavailable } from './ai/AiUnavailable.js';
3435
import { PreviewModeProvider } from '../preview/PreviewModeContext.js';
3536
import { NavigationProvider } from '../context/NavigationContext.js';
3637
import { FavoritesProvider } from '../context/FavoritesProvider.js';
@@ -457,38 +458,50 @@ export function RequireOrganization({ children }: { children: ReactNode }) {
457458
* RequireAiSurface — gate for the `/ai*` routes. The console is edition-agnostic
458459
* (MIT, runtime-gated): a Community Edition runtime serves no AI agent, so a
459460
* stale `/ai` bookmark or external link must not land on a broken/empty chat.
460-
* When the server serves no agent it redirects to `redirectTo` (home) instead.
461+
* When the server serves no agent it shows `AiUnavailable` in place of the
462+
* chat: one line saying this deployment has no AI assistant enabled, the way
463+
* home, and Studio's new-package flow for a session that may author.
464+
*
465+
* It used to redirect home there (objectstack#26614), which read exactly like
466+
* a broken link: the visitor arrived on Home with no word of why. Saying so in
467+
* place is the posture `RequireStudioAccess` took for the same reason
468+
* (objectui#12035), and the URL stays.
461469
*
462470
* Availability is the live agent catalog (see {@link useAiSurfaceEnabled}) — the
463471
* same signal every other AI entry point now gates on, so the route is reachable
464-
* from exactly the entry points that are visible (no shown CTA that bounces back
465-
* to home, no hidden FAB to a working route). Waits for the catalog to resolve
466-
* before deciding so the redirect never flashes on first paint.
472+
* from exactly the entry points that are visible (no hidden FAB to a working
473+
* route). Waits for the catalog to resolve before deciding, so the screen never
474+
* flashes on first paint.
467475
*
468-
* WHICH home it bounces to is the DECLARED landing (objectui#7373), resolved by
469-
* `useHomePath()` — the default is no longer the launcher literal. A caller that
470-
* passes `redirectTo` still wins, and on a deployment that declares no landing
471-
* the hook answers the launcher, so every ordinary environment is unchanged.
476+
* WHERE the screen's way home leads is the DECLARED landing (objectui#7373),
477+
* resolved by `useHomePath()`; a caller that passes `redirectTo` still wins.
478+
* The prop keeps the name it had while this gate redirected there, because
479+
* renaming a published prop breaks every host that passes it.
472480
*/
473481
export function RequireAiSurface({
474482
children,
475483
redirectTo,
476484
}: {
477485
children: ReactNode;
478-
/** Overrides the declared-home default. */
486+
/** Where the no-assistant screen's way home leads; overrides the declared home. */
479487
redirectTo?: string;
480488
}) {
481489
const homePath = useHomePath();
490+
const navigate = useNavigate();
491+
const { t } = useObjectTranslation();
482492
const { enabled, isLoading } = useAiSurfaceEnabled();
483493
if (isLoading) return <LoadingFallback />;
484-
// Splash-preserving handoff (objectui#6507). This is a BOOT-path redirect
485-
// even though `/ai` is reachable from inside the console: every in-app entry
486-
// point gates on this same `useAiSurfaceEnabled` signal (`AppHeader`'s
487-
// assistant button, `ConsoleLayout`'s dock, `HomeLayout`/`HomePage`), so on a
488-
// runtime where this branch fires none of them is rendered. What reaches it
489-
// is a stale bookmark or an external link — a first navigation, with the
490-
// splash still up and no layout underneath.
491-
if (!enabled) return <RedirectWithSplash to={redirectTo ?? homePath} replace />;
494+
// The full-viewport frame the boot splash hands off to: what reaches this
495+
// branch is a first navigation (every in-app entry point gates on this same
496+
// signal, so none is rendered on such a runtime), with no layout underneath
497+
// (objectui#6507).
498+
if (!enabled) {
499+
return (
500+
<div className="flex h-screen flex-col bg-background" data-testid="ai-surface-unavailable">
501+
<AiUnavailable hasError={false} onHome={() => navigate(redirectTo ?? homePath)} t={t} />
502+
</div>
503+
);
504+
}
492505
return <>{children}</>;
493506
}
494507

‎ui/packages/app-shell/src/console/__tests__/RequireAiSurface.test.tsx‎

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, it, expect, vi, beforeEach } from 'vitest';
22
import React from 'react';
33
import { render, screen } from '@testing-library/react';
4+
import userEvent from '@testing-library/user-event';
45
import { MemoryRouter, Routes, Route } from 'react-router-dom';
56
import { MetadataCtx, type MetadataContextValue } from '@object-ui/react';
67
import { RequireAiSurface } from '../ConsoleShell';
@@ -13,6 +14,29 @@ vi.mock('../../hooks/useAiSurface', () => ({
1314
}));
1415
const mockSurface = vi.mocked(useAiSurfaceEnabled);
1516

17+
/**
18+
* Who is looking, and whether this runtime offers authoring: the three terms
19+
* the no-assistant screen offers Studio's new-package flow on.
20+
*/
21+
const viewer = vi.hoisted(() => ({ isAdmin: true, canAuthorMetadata: true, aiStudio: true }));
22+
vi.mock('@object-ui/auth', async (importOriginal) => ({
23+
...(await importOriginal<Record<string, unknown>>()),
24+
useWorkspaceAdminStatus: () => ({ isAdmin: viewer.isAdmin, isResolved: true }),
25+
}));
26+
vi.mock('../../hooks/useCanAuthorMetadata', async (importOriginal) => ({
27+
...(await importOriginal<Record<string, unknown>>()),
28+
useCanAuthorMetadata: () => viewer.canAuthorMetadata,
29+
}));
30+
vi.mock('../../runtime-config', async (importOriginal) => ({
31+
...(await importOriginal<Record<string, unknown>>()),
32+
isAiStudioEnabled: () => viewer.aiStudio,
33+
}));
34+
// `t` echoes the key, so a hardcoded string fails here.
35+
vi.mock('@object-ui/i18n', async (importOriginal) => ({
36+
...(await importOriginal<Record<string, unknown>>()),
37+
useObjectTranslation: () => ({ t: (key: string) => `«${key}»`, language: 'en' }),
38+
}));
39+
1640
/**
1741
* The app list this guard's home target is resolved from (objectui#7373).
1842
* `undefined` renders the guard with no metadata context at all — the shape
@@ -59,6 +83,9 @@ function renderGuardedAi(apps?: MetadataContextValue['apps']) {
5983
describe('RequireAiSurface', () => {
6084
beforeEach(() => {
6185
mockSurface.mockReturnValue({ enabled: true, isLoading: false });
86+
viewer.isAdmin = true;
87+
viewer.canAuthorMetadata = true;
88+
viewer.aiStudio = true;
6289
});
6390

6491
it('renders the AI surface when the server serves agents (cloud install)', () => {
@@ -67,41 +94,73 @@ describe('RequireAiSurface', () => {
6794
expect(screen.queryByText('HOME')).not.toBeInTheDocument();
6895
});
6996

70-
it('redirects to home when no agents are served (Community Edition) — no dead-end chat', () => {
97+
it('says in place that no assistant is enabled when no agents are served (objectstack#26614)', () => {
98+
// Community Edition. It used to redirect home, which read like a broken
99+
// link; now the URL stays and the screen says why, with the way home.
71100
mockSurface.mockReturnValue({ enabled: false, isLoading: false });
72101
renderGuardedAi();
73102
expect(screen.queryByText('AI CHAT')).not.toBeInTheDocument();
74-
expect(screen.getByText('HOME')).toBeInTheDocument();
103+
expect(screen.queryByText('HOME')).not.toBeInTheDocument();
104+
const page = screen.getByTestId('ai-unavailable');
105+
expect(page).toHaveTextContent('«console.ai.unavailableDescription»');
106+
expect(screen.getByTestId('ai-unavailable-home')).toHaveTextContent('«console.ai.unavailableHome»');
107+
// Nothing failed, so nothing is offered again.
108+
expect(screen.queryByTestId('ai-unavailable-retry')).toBeNull();
109+
});
110+
111+
it('offers Studio\'s new-package flow to a session that may author (objectstack#26614)', () => {
112+
mockSurface.mockReturnValue({ enabled: false, isLoading: false });
113+
renderGuardedAi();
114+
const studio = screen.getByTestId('ai-unavailable-studio');
115+
expect(studio).toHaveTextContent('«home.build.inStudio»');
116+
expect(studio).toHaveAttribute('href', '/studio?new=package');
117+
});
118+
119+
it.each([
120+
['a non-admin', () => { viewer.isAdmin = false; }],
121+
['a session without the authoring capability', () => { viewer.canAuthorMetadata = false; }],
122+
['a runtime that offers no authoring', () => { viewer.aiStudio = false; }],
123+
])('offers no Studio link to %s (objectstack#26614)', (_who, arrange) => {
124+
arrange();
125+
mockSurface.mockReturnValue({ enabled: false, isLoading: false });
126+
renderGuardedAi();
127+
expect(screen.getByTestId('ai-unavailable')).toBeInTheDocument();
128+
expect(screen.queryByTestId('ai-unavailable-studio')).toBeNull();
75129
});
76130

77-
it('waits (neither chat nor redirect) while the agent catalog is still resolving', () => {
131+
it('waits (neither chat nor the no-assistant screen) while the agent catalog is still resolving', () => {
78132
mockSurface.mockReturnValue({ enabled: false, isLoading: true });
79133
renderGuardedAi();
80134
expect(screen.queryByText('AI CHAT')).not.toBeInTheDocument();
135+
expect(screen.queryByTestId('ai-unavailable')).not.toBeInTheDocument();
81136
expect(screen.queryByText('HOME')).not.toBeInTheDocument();
82137
});
83138

84-
it('bounces to the DECLARED landing, not the launcher, where one is declared (objectui#7373)', () => {
139+
it('leads home to the DECLARED landing, not the launcher, where one is declared (objectui#7373)', async () => {
85140
// The card's case, on this guard: a stale `/ai` bookmark opened against a
86141
// control plane that serves no agent. Pre-#7373 the default was the `/home`
87142
// literal, so the customer landed among environment cards that cannot act
88143
// on anything their deployment has. This pin fails on that implementation.
144+
const user = userEvent.setup();
89145
mockSurface.mockReturnValue({ enabled: false, isLoading: false });
90146
renderGuardedAi([
91147
{ name: 'cloud_control', label: 'Cloud', isDefault: true },
92148
{ name: 'account', label: 'Account' },
93149
]);
150+
await user.click(screen.getByTestId('ai-unavailable-home'));
94151
expect(screen.getByText('DECLARED LANDING')).toBeInTheDocument();
95152
expect(screen.queryByText('HOME')).not.toBeInTheDocument();
96153
});
97154

98-
it('keeps the launcher for an ordinary environment that declares nothing', () => {
155+
it('keeps the launcher for an ordinary environment that declares nothing', async () => {
99156
// The status quo, stated as its own case so the two answers cannot be
100157
// confused for one: an app list WITHOUT a declaration still resolves to the
101158
// launcher, and so does a guard mounted outside a metadata provider (every
102159
// other case in this file).
160+
const user = userEvent.setup();
103161
mockSurface.mockReturnValue({ enabled: false, isLoading: false });
104162
renderGuardedAi([{ name: 'crm', label: 'CRM' }, { name: 'setup', label: 'Setup' }]);
163+
await user.click(screen.getByTestId('ai-unavailable-home'));
105164
expect(screen.getByText('HOME')).toBeInTheDocument();
106165
expect(screen.queryByText('DECLARED LANDING')).not.toBeInTheDocument();
107166
});

‎ui/packages/app-shell/src/console/__tests__/bootRedirectCoverage.test.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,11 @@ describe('boot-gate coverage — ConsoleShell (objectui#6507)', () => {
192192
expectCovered(reading, 'RequireOrganization (no org, multi-org on)');
193193
});
194194

195-
it('RequireAiSurface :382 — the runtime serves no agent', async () => {
195+
it('RequireAiSurface — the runtime serves no agent', async () => {
196+
// No redirect any more (objectstack#26614): the gate shows its
197+
// no-assistant screen in place. The deciding commit must still hand the
198+
// splash to something that covers the viewport, which that screen's
199+
// full-height frame does.
196200
aiSurface = { enabled: false, isLoading: false };
197201
const reading = await coverageAt(
198202
'/ai',

‎ui/packages/app-shell/src/console/ai/AiChatPage.tsx‎

Lines changed: 3 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,6 @@ import {
4444
SheetDescription,
4545
SheetHeader,
4646
SheetTitle,
47-
Empty,
48-
EmptyTitle,
49-
EmptyDescription,
5047
DropdownMenu,
5148
DropdownMenuTrigger,
5249
DropdownMenuContent,
@@ -143,6 +140,7 @@ import { BuildDebugDrawer } from './BuildDebugDrawer.js';
143140
import { isConversationZh } from './conversationLanguage.js';
144141
import { resolveOutboundAgentText, type OutboundAgentTextKey } from './outboundAgentText.js';
145142
import { resolveApiBase } from './resolveApiBase.js';
143+
import { AiUnavailable } from './AiUnavailable.js';
146144

147145
function partString(part: HydratedUIMessagePart, key: string): string | undefined {
148146
const value = part[key];
@@ -944,8 +942,8 @@ export function AiChatPage({ apiBase: apiBaseProp, defaultAgent: defaultAgentPro
944942
useAgents({ apiBase });
945943
const catalogNames = useMemo(() => agents.map((a) => a.name), [agents]);
946944
// Catalog resolved with no agent to talk to. The `/ai` route guard already
947-
// redirects when discovery reports AI unavailable (Community Edition), so this
948-
// is the secondary safety net: a deployment that reports AI enabled but serves
945+
// shows `AiUnavailable` itself when the runtime serves no agent (Community
946+
// Edition), so this is the secondary safety net: a deployment that reports AI enabled but serves
949947
// no agent (misconfig), a transient `/agents` failure, or a `VITE_AI_BASE_URL`
950948
// server that returns an empty list. Either way, degrade to a graceful state
951949
// instead of the agent-less echo chat (autoResponse) that ChatPane falls into.
@@ -1527,56 +1525,6 @@ export function AiChatPage({ apiBase: apiBaseProp, defaultAgent: defaultAgentPro
15271525
);
15281526
}
15291527

1530-
/**
1531-
* Graceful state for `/ai` when the agent catalog resolved empty — shown
1532-
* instead of an agent-less echo chat. `hasError` distinguishes "AI not enabled
1533-
* on this deployment" (Community Edition) from "couldn't reach the AI service"
1534-
* (offline/misconfig), which also offers a retry. Either way there's a way out
1535-
* (back to home), so the route never dead-ends.
1536-
*/
1537-
function AiUnavailable({
1538-
hasError,
1539-
onRetry,
1540-
onHome,
1541-
t,
1542-
}: {
1543-
hasError: boolean;
1544-
onRetry: () => void;
1545-
onHome: () => void;
1546-
t: (key: string, options?: Record<string, unknown>) => string;
1547-
}) {
1548-
return (
1549-
<div className="flex flex-1 items-center justify-center p-6" data-testid="ai-unavailable">
1550-
<Empty>
1551-
<EmptyTitle>
1552-
{t('console.ai.unavailableTitle', { defaultValue: 'AI assistant unavailable' })}
1553-
</EmptyTitle>
1554-
<EmptyDescription>
1555-
{hasError
1556-
? t('console.ai.unavailableError', {
1557-
defaultValue:
1558-
"Couldn't reach the AI service. It may be temporarily offline — try again, or head back home.",
1559-
})
1560-
: t('console.ai.unavailableDescription', {
1561-
defaultValue:
1562-
"This deployment doesn't have an AI assistant enabled. Everything else works as usual.",
1563-
})}
1564-
</EmptyDescription>
1565-
<div className="mt-6 flex flex-col items-center gap-3 sm:flex-row">
1566-
{hasError && (
1567-
<Button variant="outline" onClick={onRetry} data-testid="ai-unavailable-retry">
1568-
{t('console.ai.unavailableRetry', { defaultValue: 'Try again' })}
1569-
</Button>
1570-
)}
1571-
<Button onClick={onHome} data-testid="ai-unavailable-home">
1572-
{t('console.ai.unavailableHome', { defaultValue: 'Back to home' })}
1573-
</Button>
1574-
</div>
1575-
</Empty>
1576-
</div>
1577-
);
1578-
}
1579-
15801528
interface ChatPaneProps {
15811529
/**
15821530
* cloud#1610 — what the user is currently discussing, derived by the HOST

0 commit comments

Comments
 (0)