Skip to content

Commit a0b40f2

Browse files
feat(console): Home offers Connect an AI Assistant until the person has connected one; the first-run checklist gains its row (#31236)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XUajPDb8jMKTWyncZpJ2wTS)_ Fixes #30895 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - 改了什么:首页新增一张可关闭的「在 AI 助手里使用本系统」卡片,一句大白话加一个按钮直达「连接 AI 助手」;本人连接过助手或持有有效 API 密钥后自动不再显示,关闭后在本浏览器对本人不再显示。管理员的首次设置清单新增「连接 AI 助手」一行,完成状态读真实记录(已授权的助手或有效 API 密钥),不靠点击。 - 为什么改:卡 #30895 期望 2 与期望 3(期望 1、4、5 已由 PR #30935 落地);两者都要知道「此人是否已连接」,复用 PR #31198 的同一份读取。 - 风险与回滚:只读两份已有记录,不新增服务端接口;读不到时卡片不出现、清单行不出现(不误报「未连接」);MCP 关闭的部署两者都不出现。回滚即还原本 PR,无数据迁移。 - 先看:ui/packages/app-shell/src/console/home/ConnectAssistantCard.tsx(何时出现)、ui/apps/console/src/pages/settings/FirstRunChecklist.tsx 的 connect_assistant 行、ui/packages/app-shell/src/console/connect/connectAssistantPath.ts(从头像菜单抽出的同一条路径规则)。 - 两分钟验证:pnpm dev 后用一个从未连接过的账号打开首页,看到卡片并点按钮进入「连接 AI 助手」;建一把 API 密钥后回首页,卡片消失,管理员清单该行变为已完成。 - 需要你做的:无,席位落地。 ## What changes - **Home card** (`console/home/ConnectAssistantCard.tsx`, mounted in `HomePage.tsx` under the recovery-password reminder, in both the empty and the full Home). For every signed-in person: "Work here from your AI assistant", one sentence that they can look up and change their records from Claude, Doubao and similar assistants, always as themselves, and one button, Connect an AI Assistant, which opens the page. It is drawn only when all of these hold: - the served Account app carries the page (MCP turned on); - the person has not closed it on this browser; - `useConnectedAccess` answered that they have connected nothing: no authorized assistant and no API key that still works. While the read is out, or when it fails, the card stays away rather than offering a connection the person may already have. A dismissed card reads nothing. - **Dismissal** (`connectAssistantCardGate.ts`). Measured before adding anything: Home keeps no server-side preference; its two existing dismissible items (the recovery-password reminder and the first-run checklist) remember a dismissal in the browser's `localStorage`. The card does the same, keyed by the person (`os:home:connect-assistant-dismissed:USER`), so one person's dismissal on a shared browser does not hide it for the next. - **First-run checklist row** (`FirstRunChecklist.tsx`, row 7, `connect_assistant`). "Connect an AI assistant": done once this administrator has authorized an assistant or holds an API key that still works, read from those records, never from a click; links to the page ("Connect" / "View"). Left out, like the other rows, when its state cannot be read, and left out where MCP is turned off. The read starts only once the checklist itself is going to draw, so a member's Home asks nothing for it. - **One read, reused** (`connectedAccess.ts`). Both use `useConnectedAccess` from PR #31198; no second copy. It gains `{ enabled }` so a caller that will not draw (a dismissed card, a member's checklist) costs no request, and an adapter without the auth client is now a failed read instead of a throw out of the effect. - **One path rule** (`connectAssistantPath.ts`, new). The avatar menu's "does the Account app carry the page, and what is its address" logic moves out of `AppHeader.tsx` unchanged, and the avatar item, the Home card and the checklist row all read it. - `@object-ui/app-shell` now exports `useConnectedAccess` (with its types) and `connectAssistantPath`, for the console's checklist. - **Language packs**, all eleven: `home.connectAssistant.title`, `body`, `action`, `dismiss` and `console.firstRunChecklist.connectAssistant.title`, `todo`, `done`, `action` added; nothing reworded or removed. `zh-Hant` regenerated with `gen:zh-hant`. - Changeset `.changeset/30895-home-connect-assistant.md` (`@object-ui/app-shell`, `@object-ui/i18n`, `@object-ui/console`: patch). ## Why The card's Expected 2 ("Home shows a dismissible card … hidden once dismissed or once the person has connected one") and Expected 3 ("the administrator's first-run checklist on Home gains a row for it. Its done state is read from a real record … never from a click"). Expected 1, 4 and 5 landed in PR #30935; 2 and 3 waited for the "has connected" read, which PR #31198 built. ## Tests - `ConnectAssistantCard-30895.test.tsx`, 9 cases, real router, real data bus and the real hook (only the adapter, the person and the served apps are stubbed): §1 nothing connected (only a revoked and an expired key) shows the card, and clicking its button lands on the Connect route; it reads only the person's own keys; §2 an authorized assistant, or a key that still works: no card; §3 while the read is out, or when it fails: no card; §4 the close button hides it, a later mount stays hidden and reads nothing, another person on the same browser still sees it; §5 no page in the Account app: no card and no read; §6 a key minted while it is shown (the data bus) takes it away. - `FirstRunChecklist.test.tsx`, 7 new cases on the real settings client: not done with nothing connected (link to `/apps/account/page/connect_agent`, "0 of 7 done"); done from an authorized assistant; done from a working API key; clicking the row's link writes nothing and leaves it not done; a failed read leaves the row out; MCP off leaves the row out and reads nothing; a member reads nothing of it. - Ablations (one-off, `scripts/ablation-replace.mjs`, restored blob equal to HEAD each time): showing the card while connected (`|| access.connected` removed) turned three cases red (3 failed, 6 passed); ignoring the stored dismissal turned §4 red (1 failed, 8 passed); a checklist row that is never done (`const done = false`) turned two cases red (2 failed, 15 passed). ## Acceptance notes - **Both the card and the row for an administrator.** The card is for everyone and the row is the administrator's checklist, so an administrator who has connected nothing sees both. On their Home each reads the two records once (two reads more than a member). - **An API key counts as connected**, as the card says ("an authorization or an API key the person holds"); a revoked or expired key does not. - **File surface**: `layout/AppHeader.tsx` and `index.ts` are outside the claim's listed files. AppHeader only gives up its copy of the path rule to `connectAssistantPath.ts` (21 lines out, 4 in, behaviour unchanged, its tests pass); `index.ts` adds the two exports the console needs. - `Clause-②: yes (widening)`: eight new catalogue keys per published `@object-ui/i18n` pack, and two new exports from `@object-ui/app-shell` (a private package); nothing removed. ## Verification <details><summary>Live, showcase on this branch (Vite console against it), admin and member, en and zh</summary> - Administrator, en, nothing connected: Home shows the card ("Work here from your AI assistant … Connect an AI Assistant") and the checklist row "Connect an AI assistant" not done ("2 of 7 done"); the card's button opened `/apps/com.objectstack.account/page/connect_agent`. - Administrator after minting an API key (`POST /api/v1/keys`): no card; the row is done ("An AI assistant or API key of yours is connected.", "View"), "3 of 7 done". In zh the same row reads 「连接 AI 助手 / 你已连接了 AI 助手或 API 密钥。/ 查看」, 「已完成 3/7」. - Member (created with `admin/create-user`, password changed), zh, nothing connected: the card 「在 AI 助手里使用本系统 … 连接 AI 助手」, no checklist. Closing it removed it; after a reload it stayed hidden. - The server was the showcase built from the earlier base of this thread, with this branch's console; the change is console-only. </details> <details><summary>Local tests and gates</summary> - `pnpm --dir ui exec vitest run packages/app-shell/src/console/home/ packages/app-shell/src/console/connect/ packages/app-shell/src/layout/ apps/console/src/pages/settings/ packages/i18n/` on head 68ff0b2: 318 files passed, 3263 tests passed, 13 skipped. - `type-check` for `@object-ui/app-shell`, `@object-ui/i18n` and `@object-ui/console`: exit 0 (after building the console's dependency closure). eslint on the touched files: 0 errors; the new files carry no warnings. - `node scripts/pm/dispatch-gates.mjs --commands` derived 38 families on head 68ff0b2; all 38 exit 0, `--ran` reconciliation: 38 derived, 38 run, 0 NOT-MEASURED, 0 UNRUN (includes `pnpm --dir ui exec vitest run scripts/` and `node scripts/check-sdui-manifest.mjs`). </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (No spec change.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; eslint on the touched files passes (the repo-wide `pnpm lint` is CI's). - [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: n/a. - [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. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5pMhSEyeAKtt6btsBe --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5pMhSEyeAKtt6btsBe)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent fba4c00 commit a0b40f2

22 files changed

Lines changed: 694 additions & 29 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+
'@object-ui/console': patch
5+
---
6+
7+
feat(console): Home offers Connect an AI Assistant until the person has connected one; the first-run checklist gains its row
8+
9+
Clause-②: yes (widening)
10+
Metadata-protocol: none
11+
12+
- **`@object-ui/app-shell`**: Home shows a card to every signed-in person who has connected nothing yet: they can work in this system from Claude, Doubao and similar AI assistants, as themselves, with one button to Connect an AI Assistant. It is hidden once they have authorized an assistant or hold an API key that still works (read by `useConnectedAccess`, the same read as the Connect page's Connected line), and once they close it (remembered for that person on that browser, like Home's other dismissible items). While the read is out or fails, and where MCP is turned off, it is not shown. `useConnectedAccess` and `connectAssistantPath` are now exported, and the avatar menu item reads its path from `connectAssistantPath`.
13+
- **`@object-ui/console`**: the administrator's first-run checklist gains "Connect an AI assistant", done once that administrator has authorized an assistant or holds a working API key (never from a click), with a link to the page; left out when that cannot be read or MCP is turned off.
14+
- **`@object-ui/i18n`**: `home.connectAssistant.title`, `body`, `action`, `dismiss` and `console.firstRunChecklist.connectAssistant.title`, `todo`, `done`, `action` added in all eleven packs.

‎ui/apps/console/src/pages/settings/FirstRunChecklist.tsx‎

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@
3333
* Only for a session that may write mail.
3434
* 6. Invite colleagues: a second member or a pending invitation; links to
3535
* Setup → Users, where Invite User is.
36+
* 7. Connect an AI assistant: done once this administrator has authorized an
37+
* AI assistant or holds an API key that still works, read from those
38+
* records by `useConnectedAccess` (the read Connect an AI Assistant's
39+
* Connected line uses), never from a click; links to that page. Only
40+
* where the served Account app carries the page (MCP turned on).
3641
*
3742
* A row whose state cannot be read is left out rather than shown as not done.
3843
* Once every row shown is done the checklist draws nothing; it comes back on
@@ -63,7 +68,7 @@ import { Link } from 'react-router-dom';
6368
import { toast } from 'sonner';
6469
import { CheckCircle2, Circle, Loader2, X } from 'lucide-react';
6570
import { Button, cn } from '@object-ui/components';
66-
import { useMetadata, resolveSetupAppPath } from '@object-ui/app-shell';
71+
import { connectAssistantPath, resolveSetupAppPath, useConnectedAccess, useMetadata } from '@object-ui/app-shell';
6772
import { useAuth } from '@object-ui/auth';
6873
import { useObjectTranslation } from '@object-ui/i18n';
6974
import { usePermissions } from '@object-ui/permissions';
@@ -403,6 +408,10 @@ export function FirstRunChecklist() {
403408
const colleagues = invited && invited.identity === identity ? invited.colleagues : undefined;
404409
const dismissed =
405410
recordKey !== null && (hiddenKey === recordKey || wasDismissed(deviceStorage(), recordKey));
411+
// Connect an AI Assistant: the row reads whether this administrator has
412+
// connected one only once the checklist is going to draw.
413+
const assistantPath = connectAssistantPath(apps);
414+
const assistant = useConnectedAccess({ enabled: Boolean(settings && !dismissed && assistantPath) });
406415
const setSettings = (update: (prev: ChecklistSettingsState) => ChecklistSettingsState) =>
407416
setLoaded((prev) => (prev ? { identity: prev.identity, settings: update(prev.settings) } : prev));
408417

@@ -546,6 +555,34 @@ export function FirstRunChecklist() {
546555
});
547556
}
548557

558+
if (assistantPath && assistant.state === 'ready') {
559+
const done = assistant.connected;
560+
rows.push({
561+
id: 'connect_assistant',
562+
done,
563+
node: (
564+
<ChecklistRow
565+
key="connect_assistant"
566+
id="connect_assistant"
567+
done={done}
568+
title={t('console.firstRunChecklist.connectAssistant.title')}
569+
detail={
570+
done
571+
? t('console.firstRunChecklist.connectAssistant.done')
572+
: t('console.firstRunChecklist.connectAssistant.todo')
573+
}
574+
action={
575+
<RowLink
576+
to={assistantPath}
577+
label={done ? t('console.firstRunChecklist.view') : t('console.firstRunChecklist.connectAssistant.action')}
578+
testId="first-run-checklist-link-connect_assistant"
579+
/>
580+
}
581+
/>
582+
),
583+
});
584+
}
585+
549586
const doneCount = rows.filter((row) => row.done).length;
550587
if (rows.length === 0 || doneCount === rows.length) return null;
551588

‎ui/apps/console/src/pages/settings/__tests__/FirstRunChecklist.test.tsx‎

Lines changed: 94 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@ import {
2727
type MePermissionsResponse,
2828
} from '@object-ui/permissions';
2929

30-
const { AUTH, toastError, toastSuccess } = vi.hoisted(() => ({
30+
const { AUTH, toastError, toastSuccess, SHELL } = vi.hoisted(() => ({
31+
// What the Connect an AI Assistant row reads: the served apps (for the page's
32+
// path) and the adapter (for what this administrator has connected).
33+
SHELL: { apps: [] as unknown[], adapter: null as unknown },
3134
AUTH: {
3235
user: { id: 'u_admin' },
3336
activeOrganization: { id: 'org_1', name: 'Default Organization' } as { id: string; name: string },
@@ -47,10 +50,15 @@ vi.mock('sonner', () => ({ toast: { error: toastError, success: toastSuccess } }
4750
// provider; the real surface is inherited and only that read is stubbed.
4851
vi.mock('@object-ui/app-shell', async (importOriginal) => ({
4952
...(await importOriginal<Record<string, unknown>>()),
50-
useMetadata: () => ({ apps: [], objects: [], loading: false, error: null, refresh: async () => {} }),
53+
useMetadata: () => ({ apps: SHELL.apps, objects: [], loading: false, error: null, refresh: async () => {} }),
5154
resolveSetupAppPath: () => '/apps/setup',
5255
}));
5356

57+
vi.mock('@object-ui/react', async (importOriginal) => ({
58+
...(await importOriginal<Record<string, unknown>>()),
59+
useAdapter: () => SHELL.adapter,
60+
}));
61+
5462
import { FirstRunChecklist } from '../FirstRunChecklist';
5563
import { __resetBootPainted } from '../../../loadLocales';
5664
import type { Specifier } from '../types';
@@ -221,6 +229,8 @@ beforeEach(() => {
221229
mailValues = { provider: resolved('smtp') };
222230
putRefusal = null;
223231
AUTH.activeOrganization = { id: 'org_1', name: 'Default Organization' };
232+
SHELL.apps = [];
233+
SHELL.adapter = null;
224234
AUTH.getMembers.mockReset().mockResolvedValue([{ id: 'm_1' }]);
225235
AUTH.listInvitations.mockReset().mockResolvedValue([]);
226236
browserZone = 'Asia/Shanghai';
@@ -411,3 +421,85 @@ describe('the reads wait for the first paint (objectui#12980)', () => {
411421
expect(calls('GET', '/api/settings')).toHaveLength(1);
412422
});
413423
});
424+
425+
describe('Connect an AI assistant: done from a real record, never from a click', () => {
426+
const ACCOUNT_APP = {
427+
name: 'account',
428+
navigation: [{ type: 'group', id: 'grp', children: [{ type: 'page', id: 'nav_connect_agent', pageName: 'connect_agent' }] }],
429+
};
430+
let granted: unknown[];
431+
let keys: Record<string, unknown>[];
432+
let list: ReturnType<typeof vi.fn>;
433+
let find: ReturnType<typeof vi.fn>;
434+
435+
beforeEach(() => {
436+
granted = [];
437+
keys = [];
438+
list = vi.fn(async () => ({ clients: granted, truncated: false }));
439+
find = vi.fn(async () => ({ data: keys }));
440+
SHELL.apps = [ACCOUNT_APP];
441+
SHELL.adapter = { getClient: () => ({ oauth: { authorizedClients: { list } } }), find };
442+
});
443+
444+
it('nothing connected: the row is not done and links to the page', async () => {
445+
keys = [{ id: 'k_old', name: 'old', revoked: true, expires_at: null }];
446+
renderChecklist(ADMIN);
447+
const r = await screen.findByTestId('first-run-checklist-row-connect_assistant');
448+
expect(r.getAttribute('data-done')).toBe('false');
449+
expect(within(r).getByText('Work in this system from Claude, Doubao and similar assistants, as yourself.')).toBeTruthy();
450+
expect(screen.getByTestId('first-run-checklist-link-connect_assistant').getAttribute('href')).toBe(
451+
'/apps/account/page/connect_agent',
452+
);
453+
expect(screen.getByTestId('first-run-checklist-progress').textContent).toBe('0 of 7 done');
454+
expect(find).toHaveBeenCalledWith('sys_api_key', expect.objectContaining({ $filter: { user_id: 'u_admin' } }));
455+
});
456+
457+
it('an authorized assistant makes it done', async () => {
458+
granted = [{ id: 'c1', clientId: 'cl', name: 'Claude Desktop', scopes: [], organizationId: null, authorizedAt: null, lastUsedAt: null }];
459+
renderChecklist(ADMIN);
460+
const r = await screen.findByTestId('first-run-checklist-row-connect_assistant');
461+
expect(r.getAttribute('data-done')).toBe('true');
462+
expect(within(r).getByText('An AI assistant or API key of yours is connected.')).toBeTruthy();
463+
});
464+
465+
it('an API key that still works makes it done', async () => {
466+
keys = [{ id: 'k_live', name: 'doubao key', revoked: false, expires_at: null }];
467+
renderChecklist(ADMIN);
468+
const r = await screen.findByTestId('first-run-checklist-row-connect_assistant');
469+
expect(r.getAttribute('data-done')).toBe('true');
470+
});
471+
472+
it('opening the page through the row writes nothing and leaves it not done', async () => {
473+
renderChecklist(ADMIN);
474+
const link = await screen.findByTestId('first-run-checklist-link-connect_assistant');
475+
fireEvent.click(link);
476+
await settle();
477+
expect(row('connect_assistant').getAttribute('data-done')).toBe('false');
478+
expect(fetchMock.mock.calls.filter(([, init]) => ((init as RequestInit | undefined)?.method ?? 'GET') !== 'GET')).toHaveLength(0);
479+
});
480+
481+
it('a read that fails leaves the row out rather than not done', async () => {
482+
list.mockRejectedValue(new Error('down'));
483+
renderChecklist(ADMIN);
484+
await screen.findByTestId('first-run-checklist-row-invite');
485+
await settle();
486+
expect(screen.queryByTestId('first-run-checklist-row-connect_assistant')).toBeNull();
487+
});
488+
489+
it('MCP turned off (no page in the Account app): no row and no read', async () => {
490+
SHELL.apps = [{ name: 'account', navigation: [] }];
491+
renderChecklist(ADMIN);
492+
await screen.findByTestId('first-run-checklist-row-invite');
493+
await settle();
494+
expect(screen.queryByTestId('first-run-checklist-row-connect_assistant')).toBeNull();
495+
expect(list).not.toHaveBeenCalled();
496+
expect(find).not.toHaveBeenCalled();
497+
});
498+
499+
it('a member reads nothing of it', async () => {
500+
renderChecklist(MEMBER);
501+
await settle();
502+
expect(list).not.toHaveBeenCalled();
503+
expect(find).not.toHaveBeenCalled();
504+
});
505+
});
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Where Connect an AI Assistant opens, for every entry that leads to it: the
5+
* avatar menu item, the Home card and the administrator's first-run checklist
6+
* row.
7+
*
8+
* The page is `@objectstack/mcp`'s `connect_agent`, reached through the served
9+
* Account app. `@objectstack/mcp` contributes the Account nav entry exactly
10+
* when it registers the page, so a deployment with MCP turned off answers
11+
* `undefined` here and no entry draws a dead link.
12+
*/
13+
14+
import { appAddressSegment } from '../../utils/appRoute.js';
15+
16+
/** The page the Connect an AI Assistant entries open (`@objectstack/mcp`). */
17+
export const CONNECT_ASSISTANT_PAGE = 'connect_agent';
18+
19+
/**
20+
* Whether a served navigation tree carries a `page` entry for `pageName`,
21+
* groups included. The tree is the server's, already folded and filtered for
22+
* this caller, so a hit means the page is registered and reachable.
23+
*/
24+
export function navCarriesPage(items: unknown, pageName: string): boolean {
25+
if (!Array.isArray(items)) return false;
26+
return items.some((item: { type?: unknown; pageName?: unknown; children?: unknown } | null | undefined) =>
27+
(item?.type === 'page' && item?.pageName === pageName)
28+
|| navCarriesPage(item?.children, pageName));
29+
}
30+
31+
type ServedApp = { name?: unknown; navigation?: unknown } & Record<string, unknown>;
32+
33+
/** The Console path of Connect an AI Assistant, or `undefined` while the served Account app does not carry it. */
34+
export function connectAssistantPath<T extends object>(apps: readonly T[] | null | undefined): string | undefined {
35+
const served = (apps ?? []) as unknown as readonly ServedApp[];
36+
const accountApp = served.find((a) => a?.name === 'account');
37+
return accountApp && navCarriesPage(accountApp.navigation, CONNECT_ASSISTANT_PAGE)
38+
? `/apps/${appAddressSegment(served, accountApp)}/page/${CONNECT_ASSISTANT_PAGE}`
39+
: undefined;
40+
}

‎ui/packages/app-shell/src/console/connect/connectedAccess.ts‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@
2626
* found a connection: a failed read is never reported as "nothing connected".
2727
* `unknown` also covers a render without an adapter or a signed-in person.
2828
*
29+
* Its readers: the Connected line on Connect an AI Assistant, the Home card
30+
* that offers connecting one (hidden once connected), and the administrator's
31+
* first-run checklist row (done once connected).
32+
*
2933
* It reads again when either record changes on this page: the adapter's
3034
* mutation event (or the mint's `notifyDataChanged`) for `sys_api_key`, and the
3135
* assistants list's withdraw, which announces `sys_oauth_consent`.
@@ -111,10 +115,16 @@ export function combineConnectedReads(
111115
return { state: 'loading' };
112116
}
113117

114-
export function useConnectedAccess(): ConnectedAccess {
118+
/**
119+
* `enabled: false` reads nothing and answers `unknown`: a caller that may not
120+
* need the answer (a dismissed Home card, a member's Home with no checklist)
121+
* costs no request. Hooks cannot be called conditionally, so this is how a
122+
* caller skips the read.
123+
*/
124+
export function useConnectedAccess({ enabled = true }: { enabled?: boolean } = {}): ConnectedAccess {
115125
const adapter = useAdapter();
116126
const { user } = useAuth();
117-
const userId = user?.id ?? null;
127+
const userId = enabled ? user?.id ?? null : null;
118128
const consentsNonce = useDataInvalidation('sys_oauth_consent');
119129
const keysNonce = useDataInvalidation('sys_api_key');
120130

@@ -124,9 +134,10 @@ export function useConnectedAccess(): ConnectedAccess {
124134
useEffect(() => {
125135
if (!adapter || !userId) return;
126136
let live = true;
127-
adapter
128-
.getClient()
129-
.oauth.authorizedClients.list()
137+
// Through a promise, so an adapter without the auth client is a failed
138+
// read rather than a throw out of the effect.
139+
Promise.resolve()
140+
.then(() => adapter.getClient().oauth.authorizedClients.list())
130141
.then(
131142
(value) => live && setAssistants({ state: 'ready', value: value.clients.map(assistantEntry) }),
132143
() => live && setAssistants({ state: 'failed' }),
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Home's "Connect an AI Assistant" card: one plain sentence that this system
5+
* can be worked from Claude, Doubao and similar AI assistants, and one button
6+
* to Connect an AI Assistant. For every signed-in person.
7+
*
8+
* Drawn only when all of these hold:
9+
* - the served Account app carries the page (`connectAssistantPath`; a
10+
* deployment with MCP turned off has nowhere to send the button);
11+
* - the person has not hidden it on this browser (`connectAssistantCardGate`);
12+
* - `useConnectedAccess` answered that the person has connected nothing: no
13+
* authorized assistant and no API key that still works. While that read is
14+
* out, or when it cannot be read, the card stays away rather than offering
15+
* a connection the person may already have.
16+
*
17+
* Once the person connects one it is gone on the next read, and it does not
18+
* come back after a dismissal.
19+
*/
20+
21+
import { useState } from 'react';
22+
import { useNavigate } from 'react-router-dom';
23+
import { useAuth } from '@object-ui/auth';
24+
import { Button } from '@object-ui/components';
25+
import { useObjectTranslation } from '@object-ui/i18n';
26+
import { Bot, X } from 'lucide-react';
27+
import { useMetadata } from '../../providers/MetadataProvider.js';
28+
import { connectAssistantPath } from '../connect/connectAssistantPath.js';
29+
import { useConnectedAccess } from '../connect/connectedAccess.js';
30+
import { cardStorage, markCardDismissed, wasCardDismissed } from './connectAssistantCardGate.js';
31+
32+
export function ConnectAssistantCard() {
33+
const { t } = useObjectTranslation();
34+
const navigate = useNavigate();
35+
const { user } = useAuth();
36+
const { apps } = useMetadata();
37+
const userId = user?.id ?? null;
38+
const path = connectAssistantPath(apps);
39+
// Hidden in this mount by the close button; an earlier visit's record is read below.
40+
const [hiddenFor, setHiddenFor] = useState<string | null>(null);
41+
const dismissed = userId !== null && (hiddenFor === userId || wasCardDismissed(cardStorage(), userId));
42+
const access = useConnectedAccess({ enabled: Boolean(path && userId && !dismissed) });
43+
44+
if (!path || !userId || dismissed) return null;
45+
if (access.state !== 'ready' || access.connected) return null;
46+
47+
const dismiss = () => {
48+
markCardDismissed(cardStorage(), userId);
49+
setHiddenFor(userId);
50+
};
51+
52+
return (
53+
<div className="px-4 sm:px-6 lg:px-8 pt-4">
54+
<div className="mx-auto max-w-[1120px]">
55+
<section
56+
className="flex items-center gap-3 rounded-xl border border-border bg-card px-4 py-3"
57+
data-testid="home-connect-assistant"
58+
aria-labelledby="home-connect-assistant-title"
59+
>
60+
<Bot className="h-5 w-5 shrink-0 text-muted-foreground" aria-hidden />
61+
<div className="min-w-0 flex-1">
62+
<h2 id="home-connect-assistant-title" className="text-sm font-medium">
63+
{t('home.connectAssistant.title')}
64+
</h2>
65+
<p className="text-[13px] text-muted-foreground">{t('home.connectAssistant.body')}</p>
66+
</div>
67+
<Button size="sm" variant="outline" onClick={() => navigate(path)} data-testid="home-connect-assistant-open">
68+
{t('home.connectAssistant.action')}
69+
</Button>
70+
<button
71+
type="button"
72+
onClick={dismiss}
73+
aria-label={t('home.connectAssistant.dismiss')}
74+
title={t('home.connectAssistant.dismiss')}
75+
className="shrink-0 rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
76+
data-testid="home-connect-assistant-dismiss"
77+
>
78+
<X className="h-4 w-4" />
79+
</button>
80+
</section>
81+
</div>
82+
</div>
83+
);
84+
}

0 commit comments

Comments
 (0)