Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/30895-home-connect-assistant.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@object-ui/app-shell': patch
'@object-ui/i18n': patch
'@object-ui/console': patch
---

feat(console): Home offers Connect an AI Assistant until the person has connected one; the first-run checklist gains its row

Clause-②: yes (widening)
Metadata-protocol: none

- **`@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`.
- **`@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.
- **`@object-ui/i18n`**: `home.connectAssistant.title`, `body`, `action`, `dismiss` and `console.firstRunChecklist.connectAssistant.title`, `todo`, `done`, `action` added in all eleven packs.
39 changes: 38 additions & 1 deletion ui/apps/console/src/pages/settings/FirstRunChecklist.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@
* Only for a session that may write mail.
* 6. Invite colleagues: a second member or a pending invitation; links to
* Setup → Users, where Invite User is.
* 7. Connect an AI assistant: done once this administrator has authorized an
* AI assistant or holds an API key that still works, read from those
* records by `useConnectedAccess` (the read Connect an AI Assistant's
* Connected line uses), never from a click; links to that page. Only
* where the served Account app carries the page (MCP turned on).
*
* A row whose state cannot be read is left out rather than shown as not done.
* Once every row shown is done the checklist draws nothing; it comes back on
Expand Down Expand Up @@ -63,7 +68,7 @@ import { Link } from 'react-router-dom';
import { toast } from 'sonner';
import { CheckCircle2, Circle, Loader2, X } from 'lucide-react';
import { Button, cn } from '@object-ui/components';
import { useMetadata, resolveSetupAppPath } from '@object-ui/app-shell';
import { connectAssistantPath, resolveSetupAppPath, useConnectedAccess, useMetadata } from '@object-ui/app-shell';
import { useAuth } from '@object-ui/auth';
import { useObjectTranslation } from '@object-ui/i18n';
import { usePermissions } from '@object-ui/permissions';
Expand Down Expand Up @@ -403,6 +408,10 @@ export function FirstRunChecklist() {
const colleagues = invited && invited.identity === identity ? invited.colleagues : undefined;
const dismissed =
recordKey !== null && (hiddenKey === recordKey || wasDismissed(deviceStorage(), recordKey));
// Connect an AI Assistant: the row reads whether this administrator has
// connected one only once the checklist is going to draw.
const assistantPath = connectAssistantPath(apps);
const assistant = useConnectedAccess({ enabled: Boolean(settings && !dismissed && assistantPath) });
const setSettings = (update: (prev: ChecklistSettingsState) => ChecklistSettingsState) =>
setLoaded((prev) => (prev ? { identity: prev.identity, settings: update(prev.settings) } : prev));

Expand Down Expand Up @@ -546,6 +555,34 @@ export function FirstRunChecklist() {
});
}

if (assistantPath && assistant.state === 'ready') {
const done = assistant.connected;
rows.push({
id: 'connect_assistant',
done,
node: (
<ChecklistRow
key="connect_assistant"
id="connect_assistant"
done={done}
title={t('console.firstRunChecklist.connectAssistant.title')}
detail={
done
? t('console.firstRunChecklist.connectAssistant.done')
: t('console.firstRunChecklist.connectAssistant.todo')
}
action={
<RowLink
to={assistantPath}
label={done ? t('console.firstRunChecklist.view') : t('console.firstRunChecklist.connectAssistant.action')}
testId="first-run-checklist-link-connect_assistant"
/>
}
/>
),
});
}

const doneCount = rows.filter((row) => row.done).length;
if (rows.length === 0 || doneCount === rows.length) return null;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ import {
type MePermissionsResponse,
} from '@object-ui/permissions';

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

vi.mock('@object-ui/react', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useAdapter: () => SHELL.adapter,
}));

import { FirstRunChecklist } from '../FirstRunChecklist';
import { __resetBootPainted } from '../../../loadLocales';
import type { Specifier } from '../types';
Expand Down Expand Up @@ -221,6 +229,8 @@ beforeEach(() => {
mailValues = { provider: resolved('smtp') };
putRefusal = null;
AUTH.activeOrganization = { id: 'org_1', name: 'Default Organization' };
SHELL.apps = [];
SHELL.adapter = null;
AUTH.getMembers.mockReset().mockResolvedValue([{ id: 'm_1' }]);
AUTH.listInvitations.mockReset().mockResolvedValue([]);
browserZone = 'Asia/Shanghai';
Expand Down Expand Up @@ -411,3 +421,85 @@ describe('the reads wait for the first paint (objectui#12980)', () => {
expect(calls('GET', '/api/settings')).toHaveLength(1);
});
});

describe('Connect an AI assistant: done from a real record, never from a click', () => {
const ACCOUNT_APP = {
name: 'account',
navigation: [{ type: 'group', id: 'grp', children: [{ type: 'page', id: 'nav_connect_agent', pageName: 'connect_agent' }] }],
};
let granted: unknown[];
let keys: Record<string, unknown>[];
let list: ReturnType<typeof vi.fn>;
let find: ReturnType<typeof vi.fn>;

beforeEach(() => {
granted = [];
keys = [];
list = vi.fn(async () => ({ clients: granted, truncated: false }));
find = vi.fn(async () => ({ data: keys }));
SHELL.apps = [ACCOUNT_APP];
SHELL.adapter = { getClient: () => ({ oauth: { authorizedClients: { list } } }), find };
});

it('nothing connected: the row is not done and links to the page', async () => {
keys = [{ id: 'k_old', name: 'old', revoked: true, expires_at: null }];
renderChecklist(ADMIN);
const r = await screen.findByTestId('first-run-checklist-row-connect_assistant');
expect(r.getAttribute('data-done')).toBe('false');
expect(within(r).getByText('Work in this system from Claude, Doubao and similar assistants, as yourself.')).toBeTruthy();
expect(screen.getByTestId('first-run-checklist-link-connect_assistant').getAttribute('href')).toBe(
'/apps/account/page/connect_agent',
);
expect(screen.getByTestId('first-run-checklist-progress').textContent).toBe('0 of 7 done');
expect(find).toHaveBeenCalledWith('sys_api_key', expect.objectContaining({ $filter: { user_id: 'u_admin' } }));
});

it('an authorized assistant makes it done', async () => {
granted = [{ id: 'c1', clientId: 'cl', name: 'Claude Desktop', scopes: [], organizationId: null, authorizedAt: null, lastUsedAt: null }];
renderChecklist(ADMIN);
const r = await screen.findByTestId('first-run-checklist-row-connect_assistant');
expect(r.getAttribute('data-done')).toBe('true');
expect(within(r).getByText('An AI assistant or API key of yours is connected.')).toBeTruthy();
});

it('an API key that still works makes it done', async () => {
keys = [{ id: 'k_live', name: 'doubao key', revoked: false, expires_at: null }];
renderChecklist(ADMIN);
const r = await screen.findByTestId('first-run-checklist-row-connect_assistant');
expect(r.getAttribute('data-done')).toBe('true');
});

it('opening the page through the row writes nothing and leaves it not done', async () => {
renderChecklist(ADMIN);
const link = await screen.findByTestId('first-run-checklist-link-connect_assistant');
fireEvent.click(link);
await settle();
expect(row('connect_assistant').getAttribute('data-done')).toBe('false');
expect(fetchMock.mock.calls.filter(([, init]) => ((init as RequestInit | undefined)?.method ?? 'GET') !== 'GET')).toHaveLength(0);
});

it('a read that fails leaves the row out rather than not done', async () => {
list.mockRejectedValue(new Error('down'));
renderChecklist(ADMIN);
await screen.findByTestId('first-run-checklist-row-invite');
await settle();
expect(screen.queryByTestId('first-run-checklist-row-connect_assistant')).toBeNull();
});

it('MCP turned off (no page in the Account app): no row and no read', async () => {
SHELL.apps = [{ name: 'account', navigation: [] }];
renderChecklist(ADMIN);
await screen.findByTestId('first-run-checklist-row-invite');
await settle();
expect(screen.queryByTestId('first-run-checklist-row-connect_assistant')).toBeNull();
expect(list).not.toHaveBeenCalled();
expect(find).not.toHaveBeenCalled();
});

it('a member reads nothing of it', async () => {
renderChecklist(MEMBER);
await settle();
expect(list).not.toHaveBeenCalled();
expect(find).not.toHaveBeenCalled();
});
});
40 changes: 40 additions & 0 deletions ui/packages/app-shell/src/console/connect/connectAssistantPath.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Where Connect an AI Assistant opens, for every entry that leads to it: the
* avatar menu item, the Home card and the administrator's first-run checklist
* row.
*
* The page is `@objectstack/mcp`'s `connect_agent`, reached through the served
* Account app. `@objectstack/mcp` contributes the Account nav entry exactly
* when it registers the page, so a deployment with MCP turned off answers
* `undefined` here and no entry draws a dead link.
*/

import { appAddressSegment } from '../../utils/appRoute.js';

/** The page the Connect an AI Assistant entries open (`@objectstack/mcp`). */
export const CONNECT_ASSISTANT_PAGE = 'connect_agent';

/**
* Whether a served navigation tree carries a `page` entry for `pageName`,
* groups included. The tree is the server's, already folded and filtered for
* this caller, so a hit means the page is registered and reachable.
*/
export function navCarriesPage(items: unknown, pageName: string): boolean {
if (!Array.isArray(items)) return false;
return items.some((item: { type?: unknown; pageName?: unknown; children?: unknown } | null | undefined) =>
(item?.type === 'page' && item?.pageName === pageName)
|| navCarriesPage(item?.children, pageName));
}

type ServedApp = { name?: unknown; navigation?: unknown } & Record<string, unknown>;

/** The Console path of Connect an AI Assistant, or `undefined` while the served Account app does not carry it. */
export function connectAssistantPath<T extends object>(apps: readonly T[] | null | undefined): string | undefined {
const served = (apps ?? []) as unknown as readonly ServedApp[];
const accountApp = served.find((a) => a?.name === 'account');
return accountApp && navCarriesPage(accountApp.navigation, CONNECT_ASSISTANT_PAGE)
? `/apps/${appAddressSegment(served, accountApp)}/page/${CONNECT_ASSISTANT_PAGE}`
: undefined;
}
21 changes: 16 additions & 5 deletions ui/packages/app-shell/src/console/connect/connectedAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@
* found a connection: a failed read is never reported as "nothing connected".
* `unknown` also covers a render without an adapter or a signed-in person.
*
* Its readers: the Connected line on Connect an AI Assistant, the Home card
* that offers connecting one (hidden once connected), and the administrator's
* first-run checklist row (done once connected).
*
* It reads again when either record changes on this page: the adapter's
* mutation event (or the mint's `notifyDataChanged`) for `sys_api_key`, and the
* assistants list's withdraw, which announces `sys_oauth_consent`.
Expand Down Expand Up @@ -111,10 +115,16 @@ export function combineConnectedReads(
return { state: 'loading' };
}

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

Expand All @@ -124,9 +134,10 @@ export function useConnectedAccess(): ConnectedAccess {
useEffect(() => {
if (!adapter || !userId) return;
let live = true;
adapter
.getClient()
.oauth.authorizedClients.list()
// Through a promise, so an adapter without the auth client is a failed
// read rather than a throw out of the effect.
Promise.resolve()
.then(() => adapter.getClient().oauth.authorizedClients.list())
.then(
(value) => live && setAssistants({ state: 'ready', value: value.clients.map(assistantEntry) }),
() => live && setAssistants({ state: 'failed' }),
Expand Down
84 changes: 84 additions & 0 deletions ui/packages/app-shell/src/console/home/ConnectAssistantCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* Home's "Connect an AI Assistant" card: one plain sentence that this system
* can be worked from Claude, Doubao and similar AI assistants, and one button
* to Connect an AI Assistant. For every signed-in person.
*
* Drawn only when all of these hold:
* - the served Account app carries the page (`connectAssistantPath`; a
* deployment with MCP turned off has nowhere to send the button);
* - the person has not hidden it on this browser (`connectAssistantCardGate`);
* - `useConnectedAccess` answered that the person has connected nothing: no
* authorized assistant and no API key that still works. While that read is
* out, or when it cannot be read, the card stays away rather than offering
* a connection the person may already have.
*
* Once the person connects one it is gone on the next read, and it does not
* come back after a dismissal.
*/

import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '@object-ui/auth';
import { Button } from '@object-ui/components';
import { useObjectTranslation } from '@object-ui/i18n';
import { Bot, X } from 'lucide-react';
import { useMetadata } from '../../providers/MetadataProvider.js';
import { connectAssistantPath } from '../connect/connectAssistantPath.js';
import { useConnectedAccess } from '../connect/connectedAccess.js';
import { cardStorage, markCardDismissed, wasCardDismissed } from './connectAssistantCardGate.js';

export function ConnectAssistantCard() {
const { t } = useObjectTranslation();
const navigate = useNavigate();
const { user } = useAuth();
const { apps } = useMetadata();
const userId = user?.id ?? null;
const path = connectAssistantPath(apps);
// Hidden in this mount by the close button; an earlier visit's record is read below.
const [hiddenFor, setHiddenFor] = useState<string | null>(null);
const dismissed = userId !== null && (hiddenFor === userId || wasCardDismissed(cardStorage(), userId));
const access = useConnectedAccess({ enabled: Boolean(path && userId && !dismissed) });

if (!path || !userId || dismissed) return null;
if (access.state !== 'ready' || access.connected) return null;

const dismiss = () => {
markCardDismissed(cardStorage(), userId);
setHiddenFor(userId);
};

return (
<div className="px-4 sm:px-6 lg:px-8 pt-4">
<div className="mx-auto max-w-[1120px]">
<section
className="flex items-center gap-3 rounded-xl border border-border bg-card px-4 py-3"
data-testid="home-connect-assistant"
aria-labelledby="home-connect-assistant-title"
>
<Bot className="h-5 w-5 shrink-0 text-muted-foreground" aria-hidden />
<div className="min-w-0 flex-1">
<h2 id="home-connect-assistant-title" className="text-sm font-medium">
{t('home.connectAssistant.title')}
</h2>
<p className="text-[13px] text-muted-foreground">{t('home.connectAssistant.body')}</p>
</div>
<Button size="sm" variant="outline" onClick={() => navigate(path)} data-testid="home-connect-assistant-open">
{t('home.connectAssistant.action')}
</Button>
<button
type="button"
onClick={dismiss}
aria-label={t('home.connectAssistant.dismiss')}
title={t('home.connectAssistant.dismiss')}
className="shrink-0 rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
data-testid="home-connect-assistant-dismiss"
>
<X className="h-4 w-4" />
</button>
</section>
</div>
</div>
);
}
Loading
Loading