@object-ui/app-shell 11.0.0 → 11.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +104 -0
- package/dist/chrome/LoadingScreen.js +1 -1
- package/dist/console/ConsoleShell.js +2 -1
- package/dist/console/RemediationOverlay.d.ts +17 -0
- package/dist/console/RemediationOverlay.js +113 -0
- package/dist/console/ai/AiChatPage.d.ts +36 -0
- package/dist/console/ai/AiChatPage.js +104 -13
- package/dist/console/home/CloudOnboardingNext.d.ts +10 -0
- package/dist/console/home/CloudOnboardingNext.js +117 -0
- package/dist/console/home/HomePage.js +2 -1
- package/dist/console/organizations/OrganizationsPage.js +6 -2
- package/dist/console/organizations/resolveHomeUrl.d.ts +7 -0
- package/dist/console/organizations/resolveHomeUrl.js +19 -2
- package/dist/hooks/useChatConversation.d.ts +22 -0
- package/dist/hooks/useChatConversation.js +57 -10
- package/dist/hooks/useReconcileOnError.js +12 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/dist/layout/ConsoleFloatingChatbot.js +54 -7
- package/dist/layout/WorkspaceSwitcher.d.ts +4 -2
- package/dist/layout/WorkspaceSwitcher.js +15 -10
- package/dist/preview/DraftPreviewBar.js +15 -11
- package/dist/views/PageView.js +12 -2
- package/dist/views/RecordDetailView.js +1 -0
- package/package.json +38 -38
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
3
|
+
/**
|
|
4
|
+
* CloudOnboardingNext — the state-aware "next step" block for the Cloud
|
|
5
|
+
* control-plane Welcome page, registered as the SDUI widget
|
|
6
|
+
* `cloud:onboarding-next`.
|
|
7
|
+
*
|
|
8
|
+
* The Welcome page is otherwise static SDUI, but the single most useful thing
|
|
9
|
+
* it can show — "what do I do next?" — depends on live state the metadata can't
|
|
10
|
+
* carry: does the caller's org already have its production environment? New
|
|
11
|
+
* signups are auto-provisioned one, so a static "Step 1: create an environment"
|
|
12
|
+
* is wrong for most first-time users. This widget resolves that one signal from
|
|
13
|
+
* the same `/cloud/environment-entitlements` endpoint the environment list uses
|
|
14
|
+
* and renders the right primary action:
|
|
15
|
+
*
|
|
16
|
+
* • has production env → "Open Production" (the doorway into the env, where
|
|
17
|
+
* building happens) + a "Manage environments" link.
|
|
18
|
+
* • no production env → "Create your environment" (the real first step).
|
|
19
|
+
* • loading → a neutral skeleton (no CTA flashes / layout jump).
|
|
20
|
+
* • unknown / error → degrade to BOTH actions, so the button always works.
|
|
21
|
+
*
|
|
22
|
+
* Routes and the "Open Production" endpoint come from the page metadata
|
|
23
|
+
* (`properties`) so the Cloud app owns its URLs; this component owns only the
|
|
24
|
+
* state logic. It mirrors `useEnvironmentEntitlements`' authoritative summary
|
|
25
|
+
* fetch (org-scoped GET) without its list-only gating.
|
|
26
|
+
*/
|
|
27
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
28
|
+
import { useNavigate } from 'react-router-dom';
|
|
29
|
+
import { Button, Skeleton } from '@object-ui/components';
|
|
30
|
+
import { Rocket, Plus, Settings2 } from 'lucide-react';
|
|
31
|
+
import { useAuth } from '@object-ui/auth';
|
|
32
|
+
import { createAuthenticatedFetch } from '@object-ui/auth';
|
|
33
|
+
import { ComponentRegistry } from '@object-ui/core';
|
|
34
|
+
const DEFAULT_OPEN_PRODUCTION_URL = '/api/v1/cloud/environments/production/sso-open';
|
|
35
|
+
const DEFAULT_ENVIRONMENTS_ROUTE = '/apps/cloud_control/sys_environment';
|
|
36
|
+
/** Resolve the active locale's string (cheap; the page uses {en,zh} pairs). */
|
|
37
|
+
function pick(label) {
|
|
38
|
+
const lang = (typeof document !== 'undefined' && document.documentElement.getAttribute('lang')) || 'en';
|
|
39
|
+
return lang.toLowerCase().startsWith('zh') ? label.zh : label.en;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Resolve `hasProductionEnv` from the org-scoped entitlements summary. Returns
|
|
43
|
+
* `unknown` on any failure so the caller degrades gracefully rather than
|
|
44
|
+
* blocking the user behind a wrong "create" CTA.
|
|
45
|
+
*/
|
|
46
|
+
function useProductionEnvState() {
|
|
47
|
+
const { activeOrganization } = useAuth();
|
|
48
|
+
const orgId = activeOrganization?.id;
|
|
49
|
+
const authFetch = useMemo(() => createAuthenticatedFetch(), []);
|
|
50
|
+
const [state, setState] = useState({ phase: 'loading' });
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
let cancelled = false;
|
|
53
|
+
const apiBase = (import.meta.env?.VITE_SERVER_URL || '').replace(/\/+$/, '');
|
|
54
|
+
const qs = orgId ? `?organizationId=${encodeURIComponent(orgId)}` : '';
|
|
55
|
+
(async () => {
|
|
56
|
+
try {
|
|
57
|
+
const res = await authFetch(`${apiBase}/api/v1/cloud/environment-entitlements${qs}`, {
|
|
58
|
+
method: 'GET',
|
|
59
|
+
credentials: 'include',
|
|
60
|
+
});
|
|
61
|
+
if (!res.ok)
|
|
62
|
+
throw new Error(`entitlements ${res.status}`);
|
|
63
|
+
const json = await res.json().catch(() => null);
|
|
64
|
+
const data = (json?.data ?? json);
|
|
65
|
+
if (cancelled)
|
|
66
|
+
return;
|
|
67
|
+
if (!data || typeof data !== 'object') {
|
|
68
|
+
setState({ phase: 'unknown' });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
setState({ phase: 'ready', hasProductionEnv: data.hasProductionEnv === true });
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
if (!cancelled)
|
|
75
|
+
setState({ phase: 'unknown' });
|
|
76
|
+
}
|
|
77
|
+
})();
|
|
78
|
+
return () => {
|
|
79
|
+
cancelled = true;
|
|
80
|
+
};
|
|
81
|
+
}, [authFetch, orgId]);
|
|
82
|
+
return state;
|
|
83
|
+
}
|
|
84
|
+
/** Full-page nav to the backend SSO endpoint so the browser follows its 302. */
|
|
85
|
+
function openProduction(url) {
|
|
86
|
+
window.location.href = url;
|
|
87
|
+
}
|
|
88
|
+
export function CloudOnboardingNext({ properties }) {
|
|
89
|
+
const navigate = useNavigate();
|
|
90
|
+
const state = useProductionEnvState();
|
|
91
|
+
const openUrl = properties?.openProductionUrl || DEFAULT_OPEN_PRODUCTION_URL;
|
|
92
|
+
const envsRoute = properties?.environmentsRoute || DEFAULT_ENVIRONMENTS_ROUTE;
|
|
93
|
+
// Loading — a neutral skeleton sized like the button row, so the hero doesn't
|
|
94
|
+
// jump when the real CTA lands.
|
|
95
|
+
if (state.phase === 'loading') {
|
|
96
|
+
return (_jsxs("div", { className: "flex flex-wrap items-center justify-center gap-3", "data-onboarding": "loading", children: [_jsx(Skeleton, { className: "h-11 w-44 rounded-md" }), _jsx(Skeleton, { className: "h-11 w-44 rounded-md" })] }));
|
|
97
|
+
}
|
|
98
|
+
const hint = state.phase === 'ready' && !state.hasProductionEnv
|
|
99
|
+
? {
|
|
100
|
+
en: 'Spin up your first environment — a private workspace with its own URL, database, and plan. Building happens inside it.',
|
|
101
|
+
zh: '创建你的第一个环境——一个独立的工作区,有自己的网址、数据库和套餐。应用的搭建在里面进行。',
|
|
102
|
+
}
|
|
103
|
+
: {
|
|
104
|
+
en: 'Your production environment is ready. Open it to build and run your apps — that all happens inside the environment.',
|
|
105
|
+
zh: '你的生产环境已就绪。打开它来搭建和运行应用——这些都在环境内部进行。',
|
|
106
|
+
};
|
|
107
|
+
// No production env yet → the real first step is "create", not "open".
|
|
108
|
+
const showCreatePrimary = state.phase === 'ready' && !state.hasProductionEnv;
|
|
109
|
+
return (_jsxs("div", { className: "flex flex-col items-center gap-3", "data-onboarding": state.phase, children: [_jsxs("div", { className: "flex flex-wrap items-center justify-center gap-3", children: [showCreatePrimary ? (_jsxs(Button, { size: "lg", onClick: () => navigate(envsRoute), children: [_jsx(Plus, { className: "mr-2 h-4 w-4" }), pick({ en: 'Create your environment', zh: '创建你的环境' })] })) : (_jsxs(Button, { size: "lg", onClick: () => openProduction(openUrl), children: [_jsx(Rocket, { className: "mr-2 h-4 w-4" }), pick({ en: 'Open Production', zh: '打开生产环境' })] })), _jsxs(Button, { size: "lg", variant: "secondary", onClick: () => navigate(envsRoute), children: [_jsx(Settings2, { className: "mr-2 h-4 w-4" }), pick({ en: 'Manage environments', zh: '管理环境' })] })] }), _jsx("p", { className: "max-w-xl text-center text-sm text-muted-foreground", children: pick(hint) })] }));
|
|
110
|
+
}
|
|
111
|
+
// SDUI registration — the Cloud Welcome page references this widget by type.
|
|
112
|
+
ComponentRegistry.register('cloud:onboarding-next', (props) => (_jsx(CloudOnboardingNext, { ...props })), {
|
|
113
|
+
namespace: 'app-shell',
|
|
114
|
+
label: 'Cloud Onboarding Next-Step',
|
|
115
|
+
category: 'plugin',
|
|
116
|
+
inputs: [],
|
|
117
|
+
});
|
|
@@ -32,6 +32,7 @@ import { Sparkles, ShieldAlert, X, UploadCloud, MessageSquareText } from 'lucide
|
|
|
32
32
|
import { useMetadataClient } from '../../views/metadata-admin/useMetadata';
|
|
33
33
|
import { usePublishAllDrafts } from '../../preview/usePublishAllDrafts';
|
|
34
34
|
import { resolveAiApiBase } from '../../hooks/useAiSurface';
|
|
35
|
+
import { getRuntimeConfig } from '../../runtime-config';
|
|
35
36
|
/**
|
|
36
37
|
* Which AI home CTAs to surface, driven by the live agent catalog (the single
|
|
37
38
|
* source of truth) — gated PER agent, because the community edition can be in
|
|
@@ -180,7 +181,7 @@ export function HomePage() {
|
|
|
180
181
|
return (_jsx("div", { className: "flex flex-1 items-center justify-center py-20", children: _jsx("div", { className: "text-muted-foreground", children: t('home.loading', { defaultValue: 'Loading workspace…' }) }) }));
|
|
181
182
|
}
|
|
182
183
|
if (activeApps.length === 0) {
|
|
183
|
-
return (_jsxs("div", { className: "flex flex-col flex-1", children: [_jsx(PendingDraftsBanner, { t: t }), _jsx(RecoveryPasswordReminder, { t: t }), _jsx("div", { className: "flex flex-1 items-center justify-center p-6", children: isAdmin ? (_jsxs(Empty, { children: [_jsx(EmptyTitle, { children: t('home.welcome', { defaultValue: 'Welcome to
|
|
184
|
+
return (_jsxs("div", { className: "flex flex-col flex-1", children: [_jsx(PendingDraftsBanner, { t: t }), _jsx(RecoveryPasswordReminder, { t: t }), _jsx("div", { className: "flex flex-1 items-center justify-center p-6", children: isAdmin ? (_jsxs(Empty, { children: [_jsx(EmptyTitle, { children: t('home.welcome', { product: getRuntimeConfig().branding.productName, defaultValue: 'Welcome to {{product}}' }) }), _jsx(EmptyDescription, { children: buildAvailable
|
|
184
185
|
? t('home.welcomeAdminDescription', {
|
|
185
186
|
defaultValue: 'Describe your business in one sentence — AI generates the objects, screens, APIs and agent tools. Or set things up yourself from the Administration menu on the left.',
|
|
186
187
|
})
|
|
@@ -14,7 +14,7 @@ import { useAuth } from '@object-ui/auth';
|
|
|
14
14
|
import { useObjectTranslation } from '@object-ui/i18n';
|
|
15
15
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
|
16
16
|
import { CreateWorkspaceDialog } from './CreateWorkspaceDialog';
|
|
17
|
-
import {
|
|
17
|
+
import { resolveRootUrl } from './resolveHomeUrl';
|
|
18
18
|
function getOrgInitials(name) {
|
|
19
19
|
return name
|
|
20
20
|
.split(/[\s_-]+/)
|
|
@@ -72,7 +72,11 @@ export function OrganizationsPage() {
|
|
|
72
72
|
if (org.id !== activeOrganization?.id) {
|
|
73
73
|
await switchOrganization(org.id);
|
|
74
74
|
}
|
|
75
|
-
|
|
75
|
+
// Land on the console ROOT so RootLandingRedirect resolves the right
|
|
76
|
+
// landing — a single-app workspace (e.g. a fresh cloud signup, whose only
|
|
77
|
+
// app is Cloud) goes straight INTO that app instead of the redundant
|
|
78
|
+
// workspace launcher; multi-app workspaces still fall back to /home.
|
|
79
|
+
window.location.href = resolveRootUrl();
|
|
76
80
|
}
|
|
77
81
|
catch (err) {
|
|
78
82
|
console.error('[OrganizationsPage] Failed to switch:', err);
|
|
@@ -16,3 +16,10 @@
|
|
|
16
16
|
* current SPA route.
|
|
17
17
|
*/
|
|
18
18
|
export declare function resolveHomeUrl(baseURI?: string): string;
|
|
19
|
+
/**
|
|
20
|
+
* The console ROOT URL (not `/home`). Used after switching/creating an org so
|
|
21
|
+
* the SPA's root route runs `RootLandingRedirect`, which resolves the right
|
|
22
|
+
* landing: a single-app workspace lands IN that app (skipping the redundant
|
|
23
|
+
* launcher), and only a multi-app workspace falls back to `/home`.
|
|
24
|
+
*/
|
|
25
|
+
export declare function resolveRootUrl(baseURI?: string): string;
|
|
@@ -19,9 +19,26 @@ export function resolveHomeUrl(baseURI) {
|
|
|
19
19
|
if (baseURI !== undefined) {
|
|
20
20
|
return new URL('home', baseURI).toString();
|
|
21
21
|
}
|
|
22
|
+
return new URL('home', consoleRoot()).toString();
|
|
23
|
+
}
|
|
24
|
+
/** The deployment-mounted console root (`/_console/`, `/`, …). */
|
|
25
|
+
function consoleRoot() {
|
|
22
26
|
const baseHref = document.querySelector('base')?.getAttribute('href');
|
|
23
|
-
|
|
27
|
+
return baseHref
|
|
24
28
|
? new URL(baseHref, window.location.origin)
|
|
25
29
|
: new URL('/', window.location.origin);
|
|
26
|
-
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The console ROOT URL (not `/home`). Used after switching/creating an org so
|
|
33
|
+
* the SPA's root route runs `RootLandingRedirect`, which resolves the right
|
|
34
|
+
* landing: a single-app workspace lands IN that app (skipping the redundant
|
|
35
|
+
* launcher), and only a multi-app workspace falls back to `/home`.
|
|
36
|
+
*/
|
|
37
|
+
export function resolveRootUrl(baseURI) {
|
|
38
|
+
if (baseURI !== undefined) {
|
|
39
|
+
// The mount directory of the base URI ('.' resolves to the dir), e.g.
|
|
40
|
+
// '/_console/organizations' → '/_console/', '/_console/' → '/_console/'.
|
|
41
|
+
return new URL('.', baseURI).toString();
|
|
42
|
+
}
|
|
43
|
+
return consoleRoot().toString();
|
|
27
44
|
}
|
|
@@ -39,6 +39,21 @@ export interface UseChatConversationOptions {
|
|
|
39
39
|
* conversation is created. Ignored while `activeId` is set.
|
|
40
40
|
*/
|
|
41
41
|
forceNew?: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* How a plain visit (no `activeId`, no `forceNew`) treats the cached
|
|
44
|
+
* conversation:
|
|
45
|
+
* - `'resume'` (default): re-open the last conversation. Right for the full
|
|
46
|
+
* `/ai` page and the stateful BUILD surface, where losing an in-progress
|
|
47
|
+
* build (staged drafts, the awaiting-confirm plan) is harmful.
|
|
48
|
+
* - `'fresh'`: open a clean thread instead. Used by the floating assistant's
|
|
49
|
+
* ASK/data surface, where each open should feel new. To avoid littering
|
|
50
|
+
* the history (the list endpoint keeps empty rows — there's no prune), an
|
|
51
|
+
* UNTOUCHED (zero-message) cached conversation is REUSED rather than
|
|
52
|
+
* re-created; a cached conversation that the user actually used is left in
|
|
53
|
+
* history and a fresh one is minted.
|
|
54
|
+
* Ignored when `activeId` or `forceNew` is set.
|
|
55
|
+
*/
|
|
56
|
+
resumeMode?: 'resume' | 'fresh';
|
|
42
57
|
}
|
|
43
58
|
export interface UseChatConversationReturn {
|
|
44
59
|
conversationId: string | undefined;
|
|
@@ -53,6 +68,13 @@ export interface UseChatConversationReturn {
|
|
|
53
68
|
isLoading: boolean;
|
|
54
69
|
/** Delete the current conversation + start a fresh one. */
|
|
55
70
|
reset: () => Promise<void>;
|
|
71
|
+
/**
|
|
72
|
+
* Start a NEW conversation WITHOUT deleting the current one (the floating
|
|
73
|
+
* assistant's "New chat" button). Mints a fresh row and switches to it; the
|
|
74
|
+
* prior thread stays in history (reachable from the `/ai` sidebar). Contrast
|
|
75
|
+
* with {@link reset}, which deletes the current conversation first.
|
|
76
|
+
*/
|
|
77
|
+
startNew: () => Promise<void>;
|
|
56
78
|
}
|
|
57
79
|
/**
|
|
58
80
|
* The subset of plugin-chatbot's `DraftReview` (mapMessages.ts) that the chat's
|
|
@@ -360,7 +360,7 @@ async function deleteConversation(apiBase, id) {
|
|
|
360
360
|
});
|
|
361
361
|
}
|
|
362
362
|
export function useChatConversation(options) {
|
|
363
|
-
const { userId, scope, apiBase, activeId, forceNew } = options;
|
|
363
|
+
const { userId, scope, apiBase, activeId, forceNew, resumeMode = 'resume' } = options;
|
|
364
364
|
const [conversationId, setConversationId] = useState(undefined);
|
|
365
365
|
const [initialMessages, setInitialMessages] = useState([]);
|
|
366
366
|
const [isLoading, setIsLoading] = useState(Boolean(userId));
|
|
@@ -436,15 +436,33 @@ export function useChatConversation(options) {
|
|
|
436
436
|
if (cancelled)
|
|
437
437
|
return;
|
|
438
438
|
if (existing) {
|
|
439
|
-
setConversationId(existing.id);
|
|
440
439
|
const messages = toUIMessages(existing.messages);
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
440
|
+
// 'fresh' (the floating ASK surface): only reuse the cached
|
|
441
|
+
// conversation while it's untouched — once the user has actually
|
|
442
|
+
// used it, start a clean thread instead so opening the assistant
|
|
443
|
+
// feels new. Reusing an empty conversation (rather than minting
|
|
444
|
+
// another) keeps repeated opens from littering the history with
|
|
445
|
+
// empty rows. 'resume' (default) re-opens the prior thread.
|
|
446
|
+
const reuse = resumeMode !== 'fresh' || messages.length === 0;
|
|
447
|
+
if (reuse) {
|
|
448
|
+
setConversationId(existing.id);
|
|
449
|
+
setInitialMessages(resumeMode === 'fresh'
|
|
450
|
+
? []
|
|
451
|
+
: messages.length > 0
|
|
452
|
+
? messages
|
|
453
|
+
: readMessageCache(existing.id));
|
|
454
|
+
resolvedForUserRef.current = userId;
|
|
455
|
+
resolvedScopeRef.current = scope;
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
// 'fresh' + a used conversation: fall through to create a fresh
|
|
459
|
+
// one; the used thread stays in history (writeCache below repoints
|
|
460
|
+
// the cache to the new conversation).
|
|
461
|
+
}
|
|
462
|
+
else {
|
|
463
|
+
writeCache(key, undefined);
|
|
464
|
+
writeConversationMessagesCache(cached, []);
|
|
445
465
|
}
|
|
446
|
-
writeCache(key, undefined);
|
|
447
|
-
writeConversationMessagesCache(cached, []);
|
|
448
466
|
}
|
|
449
467
|
}
|
|
450
468
|
const fresh = await createConversation(apiBase);
|
|
@@ -474,7 +492,7 @@ export function useChatConversation(options) {
|
|
|
474
492
|
// short-circuit guard, which is governed by the ref. Including it would
|
|
475
493
|
// re-run the effect after we successfully resolved an id.
|
|
476
494
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
477
|
-
}, [userId, scope, apiBase, activeId, forceNew]);
|
|
495
|
+
}, [userId, scope, apiBase, activeId, forceNew, resumeMode]);
|
|
478
496
|
const reset = useCallback(async () => {
|
|
479
497
|
if (!userId)
|
|
480
498
|
return;
|
|
@@ -504,8 +522,37 @@ export function useChatConversation(options) {
|
|
|
504
522
|
setIsLoading(false);
|
|
505
523
|
}
|
|
506
524
|
}, [conversationId, userId, scope, apiBase]);
|
|
525
|
+
const startNew = useCallback(async () => {
|
|
526
|
+
if (!userId)
|
|
527
|
+
return;
|
|
528
|
+
const key = cacheKey(userId, scope);
|
|
529
|
+
setIsLoading(true);
|
|
530
|
+
try {
|
|
531
|
+
// Mint a fresh conversation and switch to it. The current conversation is
|
|
532
|
+
// intentionally NOT deleted — it stays in history (reachable from the
|
|
533
|
+
// `/ai` sidebar), unlike reset(). The remount keyed on `conversationId`
|
|
534
|
+
// in the host clears the visible thread.
|
|
535
|
+
const fresh = await createConversation(apiBase);
|
|
536
|
+
writeCache(key, fresh.id);
|
|
537
|
+
writeConversationMessagesCache(fresh.id, []);
|
|
538
|
+
if (!mountedRef.current)
|
|
539
|
+
return;
|
|
540
|
+
setConversationId(fresh.id);
|
|
541
|
+
setInitialMessages([]);
|
|
542
|
+
resolvedForUserRef.current = userId;
|
|
543
|
+
resolvedScopeRef.current = scope;
|
|
544
|
+
}
|
|
545
|
+
catch {
|
|
546
|
+
// Keep the current conversation on failure — better than dropping the
|
|
547
|
+
// user into a broken empty state.
|
|
548
|
+
}
|
|
549
|
+
finally {
|
|
550
|
+
if (mountedRef.current)
|
|
551
|
+
setIsLoading(false);
|
|
552
|
+
}
|
|
553
|
+
}, [userId, scope, apiBase]);
|
|
507
554
|
// `resolvedScopeRef` is updated in lockstep with every `setConversationId`
|
|
508
555
|
// (same async tick), so at render time it always describes the scope the
|
|
509
556
|
// current `conversationId` was resolved under.
|
|
510
|
-
return { conversationId, conversationScope: resolvedScopeRef.current, initialMessages, isLoading, reset };
|
|
557
|
+
return { conversationId, conversationScope: resolvedScopeRef.current, initialMessages, isLoading, reset, startNew };
|
|
511
558
|
}
|
|
@@ -13,6 +13,18 @@ export function useReconcileOnError(opts) {
|
|
|
13
13
|
const [errorSuppressed, setErrorSuppressed] = useState(false);
|
|
14
14
|
const setMessagesRef = useRef(undefined);
|
|
15
15
|
const handleChatError = useCallback(async (_err) => {
|
|
16
|
+
// A request rejected BEFORE any reply streamed (429 rate-limit, 5xx, or a
|
|
17
|
+
// network failure — tagged `notSent` by sendAwareFetch) never reached the
|
|
18
|
+
// server: there is no completed turn to reconcile and NOTHING to suppress.
|
|
19
|
+
// Surfacing it is the whole point — the composer shows the error + restores
|
|
20
|
+
// the input (useObjectChat already rolled back the optimistic user bubble).
|
|
21
|
+
// Without this guard, a 429 after a few turns looked like a completed turn
|
|
22
|
+
// (the thread still ends with the PREVIOUS assistant reply) and got
|
|
23
|
+
// silently reconciled away — the reported "message vanished, no error".
|
|
24
|
+
if (_err?.notSent) {
|
|
25
|
+
setErrorSuppressed(false);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
16
28
|
const aiBase = chatApi?.replace(/\/agents\/[^/]+\/chat$/, '');
|
|
17
29
|
if (!conversationId || !aiBase) {
|
|
18
30
|
setErrorSuppressed(false);
|
package/dist/index.d.ts
CHANGED
|
@@ -58,7 +58,9 @@ export type { AppComponentRegistryEntry } from './services/componentRegistry';
|
|
|
58
58
|
import './services/builtinComponents';
|
|
59
59
|
import './console/cloud-connection/CloudConnectionPanel';
|
|
60
60
|
import './console/marketplace/InstalledListWidget';
|
|
61
|
+
import './console/home/CloudOnboardingNext';
|
|
61
62
|
export { MetadataDirectoryPage, MetadataResourceRouter, MetadataResourceListPage, MetadataResourceEditPage, MetadataResourceHistoryPage, MetadataDiagnosticsPage, MetadataQuickFind, MetadataPageShell, SchemaForm, LayeredDiff, registerMetadataResource, getMetadataResource, listMetadataResources, resolveResourceConfig, useMetadataClient, useMetadataTypes, useTypesIndex, useGlobalDiagnostics, matchesQuery, registerMetadataPreview, getMetadataPreview, listMetadataPreviewTypes, registerMetadataInspector, getMetadataInspector, listMetadataInspectorTypes, } from './views/metadata-admin';
|
|
62
63
|
export type { MetadataResourceConfig, MetadataDomain, RichMetadataTypeEntry, MetadataPreview, MetadataPreviewProps, MetadataSelection, MetadataInspector, MetadataInspectorProps, } from './views/metadata-admin';
|
|
63
64
|
export { assistantBus, useAssistant, useRegisterAssistantEditor, requestAssistantOpen, } from './assistant/assistantBus';
|
|
64
65
|
export type { AssistantSnapshot, AssistantEditorContext, AssistantEditorField, } from './assistant/assistantBus';
|
|
66
|
+
export { RemediationOverlay } from './console/RemediationOverlay';
|
package/dist/index.js
CHANGED
|
@@ -72,6 +72,8 @@ import './services/builtinComponents';
|
|
|
72
72
|
// SDUI widget for the metadata-driven Cloud Connection page (cloud ADR-0008).
|
|
73
73
|
import './console/cloud-connection/CloudConnectionPanel';
|
|
74
74
|
import './console/marketplace/InstalledListWidget';
|
|
75
|
+
// SDUI widget for the Cloud Welcome page's state-aware onboarding next-step.
|
|
76
|
+
import './console/home/CloudOnboardingNext';
|
|
75
77
|
// Phase 3c — generic metadata admin engine. Re-exported so plugins
|
|
76
78
|
// can call `registerMetadataResource()` to override the per-type
|
|
77
79
|
// list / edit / create components, and host apps can compose the
|
|
@@ -79,3 +81,4 @@ import './console/marketplace/InstalledListWidget';
|
|
|
79
81
|
export { MetadataDirectoryPage, MetadataResourceRouter, MetadataResourceListPage, MetadataResourceEditPage, MetadataResourceHistoryPage, MetadataDiagnosticsPage, MetadataQuickFind, MetadataPageShell, SchemaForm, LayeredDiff, registerMetadataResource, getMetadataResource, listMetadataResources, resolveResourceConfig, useMetadataClient, useMetadataTypes, useTypesIndex, useGlobalDiagnostics, matchesQuery, registerMetadataPreview, getMetadataPreview, listMetadataPreviewTypes, registerMetadataInspector, getMetadataInspector, listMetadataInspectorTypes, } from './views/metadata-admin';
|
|
80
82
|
// AI assistant bus — connects the metadata designers to the global chat.
|
|
81
83
|
export { assistantBus, useAssistant, useRegisterAssistantEditor, requestAssistantOpen, } from './assistant/assistantBus';
|
|
84
|
+
export { RemediationOverlay } from './console/RemediationOverlay';
|
|
@@ -14,7 +14,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
14
14
|
import React from 'react';
|
|
15
15
|
import { useReconcileOnError } from '../hooks/useReconcileOnError';
|
|
16
16
|
import { FloatingChatbot, useObjectChat, useAgents, useAiModels, useHitlInChat, resolveDefaultAgentName, uiMessagesToChatMessages, publishHealthFromResponse, agentRouteName, } from '@object-ui/plugin-chatbot';
|
|
17
|
-
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Button, ShareDialog, } from '@object-ui/components';
|
|
17
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Tabs, TabsList, TabsTrigger, Button, ShareDialog, } from '@object-ui/components';
|
|
18
18
|
import { Share2, SquarePen } from 'lucide-react';
|
|
19
19
|
import { useObjectTranslation } from '@object-ui/i18n';
|
|
20
20
|
import { toast } from 'sonner';
|
|
@@ -77,6 +77,19 @@ function buildChatLocale(language, appLabel, agentName, fallbackAgentLabel, obje
|
|
|
77
77
|
connectionWaiting: '正在等待服务器响应…',
|
|
78
78
|
connectionStalledLabel: '仍在处理中…',
|
|
79
79
|
connectionOfflineLabel: '网络已断开,正在重连…',
|
|
80
|
+
designingPlanLabel: '正在为你设计方案…',
|
|
81
|
+
designingPlanHints: [
|
|
82
|
+
'梳理需要记录的数据…',
|
|
83
|
+
'设计对象与字段…',
|
|
84
|
+
'关联相关记录…',
|
|
85
|
+
'配置关系与查找字段…',
|
|
86
|
+
'规划页面与视图…',
|
|
87
|
+
'布置表单与列表…',
|
|
88
|
+
'补充默认值与校验…',
|
|
89
|
+
'规划一个看板来跟踪…',
|
|
90
|
+
'复核整体结构是否自洽…',
|
|
91
|
+
'汇总成完整方案…',
|
|
92
|
+
],
|
|
80
93
|
toolDetailsHidden: '已隐藏工具参数和原始结果,仅保留过程摘要。',
|
|
81
94
|
copy: '复制',
|
|
82
95
|
copied: '已复制',
|
|
@@ -85,6 +98,8 @@ function buildChatLocale(language, appLabel, agentName, fallbackAgentLabel, obje
|
|
|
85
98
|
submit: '发送',
|
|
86
99
|
uploadFiles: '上传文件',
|
|
87
100
|
stopResponse: '停止生成',
|
|
101
|
+
sendFailedRateLimited: '发送过于频繁,请稍候再试。你的消息已保留在输入框中。',
|
|
102
|
+
sendFailedGeneric: '消息发送失败,请重试。你的消息已保留在输入框中。',
|
|
88
103
|
trace: '调试 trace',
|
|
89
104
|
viewTrace: '查看调试 trace',
|
|
90
105
|
},
|
|
@@ -100,10 +115,12 @@ function buildChatLocale(language, appLabel, agentName, fallbackAgentLabel, obje
|
|
|
100
115
|
planTitle: '方案预览',
|
|
101
116
|
planQuestions: '搭建前请确认',
|
|
102
117
|
planAssumptions: '假设',
|
|
118
|
+
planDeferred: '待补 / 暂未搭建',
|
|
103
119
|
planApproveHint: '回复以确认或调整该方案。',
|
|
104
120
|
planApprove: '开始搭建',
|
|
105
121
|
planAdjust: '调整方案',
|
|
106
122
|
planBuilt: '已搭建',
|
|
123
|
+
planReady: '方案已就绪。点击开始搭建,或告诉我需要调整什么。',
|
|
107
124
|
// These messages the button SENDS must match the cloud confirm gate's
|
|
108
125
|
// APPROVAL_RE (service-ai-studio confirm-gate.ts) or the agent re-proposes
|
|
109
126
|
// and "开始搭建" looks inert — the gate anchors Chinese approval on 确认 /
|
|
@@ -140,6 +157,19 @@ function buildChatLocale(language, appLabel, agentName, fallbackAgentLabel, obje
|
|
|
140
157
|
connectionWaiting: 'Waiting for server…',
|
|
141
158
|
connectionStalledLabel: 'Still working…',
|
|
142
159
|
connectionOfflineLabel: 'Connection lost — reconnecting…',
|
|
160
|
+
designingPlanLabel: 'Designing your app…',
|
|
161
|
+
designingPlanHints: [
|
|
162
|
+
'Mapping out the data you’ll track…',
|
|
163
|
+
'Shaping objects and their fields…',
|
|
164
|
+
'Connecting related records…',
|
|
165
|
+
'Setting up relationships and lookups…',
|
|
166
|
+
'Planning the screens and views…',
|
|
167
|
+
'Laying out forms and lists…',
|
|
168
|
+
'Adding sensible defaults and validations…',
|
|
169
|
+
'Sketching a dashboard to track it…',
|
|
170
|
+
'Double-checking the structure hangs together…',
|
|
171
|
+
'Pulling the plan together…',
|
|
172
|
+
],
|
|
143
173
|
toolDetailsHidden: 'Tool inputs and raw results are hidden in this view.',
|
|
144
174
|
copy: 'Copy',
|
|
145
175
|
copied: 'Copied',
|
|
@@ -148,6 +178,8 @@ function buildChatLocale(language, appLabel, agentName, fallbackAgentLabel, obje
|
|
|
148
178
|
submit: 'Submit',
|
|
149
179
|
uploadFiles: 'Upload files',
|
|
150
180
|
stopResponse: 'Stop response',
|
|
181
|
+
sendFailedRateLimited: "You're sending messages too quickly. Your message is kept below — wait a moment and try again.",
|
|
182
|
+
sendFailedGeneric: "Couldn't send your message. It's kept below — please try again.",
|
|
151
183
|
trace: 'trace',
|
|
152
184
|
viewTrace: 'View trace',
|
|
153
185
|
},
|
|
@@ -163,10 +195,12 @@ function buildChatLocale(language, appLabel, agentName, fallbackAgentLabel, obje
|
|
|
163
195
|
planTitle: 'Proposed plan',
|
|
164
196
|
planQuestions: 'Confirm before building',
|
|
165
197
|
planAssumptions: 'Assumptions',
|
|
198
|
+
planDeferred: 'Not yet built',
|
|
166
199
|
planApproveHint: 'Reply to approve or adjust this plan.',
|
|
167
200
|
planApprove: 'Build it',
|
|
168
201
|
planAdjust: 'Adjust',
|
|
169
202
|
planBuilt: 'Built',
|
|
203
|
+
planReady: 'The plan is ready. Build it now, or tell me what to adjust.',
|
|
170
204
|
planApproveMessage: 'Looks good — build it as proposed.',
|
|
171
205
|
planApproveDefaultsMessage: 'Build it with your best assumptions; use sensible defaults for the open questions.',
|
|
172
206
|
planAnswer: (question, option) => `For "${question}", go with: ${option}.`,
|
|
@@ -249,7 +283,7 @@ function resolveCurrentRouteObject(pathname, objects) {
|
|
|
249
283
|
const recordId = recordSeg && recordSeg !== 'new' ? recordSeg : undefined;
|
|
250
284
|
return { objectName: objectSeg, ...(recordId ? { recordId } : {}) };
|
|
251
285
|
}
|
|
252
|
-
function ChatbotInner({ appLabel, appName, objects, agents, agentsLoading, activeAgent, onAgentChange, showAgentPicker, chatApi, apiBase, defaultOpen = false, conversationId, initialMessages: persistedMessages, }) {
|
|
286
|
+
function ChatbotInner({ appLabel, appName, objects, agents, agentsLoading, activeAgent, onAgentChange, showAgentPicker, chatApi, apiBase, defaultOpen = false, conversationId, initialMessages: persistedMessages, onNewChat, }) {
|
|
253
287
|
const { language } = useObjectTranslation();
|
|
254
288
|
const navigate = useNavigate();
|
|
255
289
|
const location = useLocation();
|
|
@@ -349,13 +383,19 @@ function ChatbotInner({ appLabel, appName, objects, agents, agentsLoading, activ
|
|
|
349
383
|
// design: end users bound to a single agent never see it. `showAgentPicker`
|
|
350
384
|
// is true when AI development is unlocked (catalog serves both ask & build)
|
|
351
385
|
// or forced on; it still needs more than one agent to be a real choice.
|
|
352
|
-
|
|
386
|
+
//
|
|
387
|
+
// For the common 2–3 agent case (Ask/Build) render a Claude-Code-style
|
|
388
|
+
// segmented switcher so BOTH modes are visible at a glance — a dropdown hid
|
|
389
|
+
// the distinction. Fall back to the compact Select when an env exposes many
|
|
390
|
+
// custom agents and the inline pills would overflow the header.
|
|
391
|
+
const isZh = (language ?? '').toLowerCase().startsWith('zh');
|
|
392
|
+
const headerExtra = !(showAgentPicker && agents.length > 1) ? null : agents.length <= 3 ? (_jsx(Tabs, { value: activeAgent, onValueChange: onAgentChange, children: _jsx(TabsList, { className: "h-7 gap-0.5 p-0.5", "data-testid": "floating-chatbot-agent-picker", children: agents.map((agent) => (_jsx(TabsTrigger, { value: agent.name, disabled: agentsLoading, title: agent.description || undefined, className: "h-6 px-2.5 text-xs", children: localizeAgentLabel(isZh, agent.name, agent.label) }, agent.name))) }) })) : (_jsxs(Select, { value: activeAgent, onValueChange: onAgentChange, disabled: agentsLoading, children: [_jsx(SelectTrigger, { className: "h-7 w-[180px] text-xs", "data-testid": "floating-chatbot-agent-picker", children: _jsx(SelectValue, { placeholder: "Choose agent..." }) }), _jsx(SelectContent, { align: "end", children: agents.map((agent) => (_jsxs(SelectItem, { value: agent.name, className: "text-xs", children: [_jsx("span", { className: "font-medium", children: agent.label }), agent.description ? (_jsx("span", { className: "block text-muted-foreground text-[10px] truncate max-w-[220px]", children: agent.description })) : null] }, agent.name))) })] }));
|
|
353
393
|
// Share-link control. Sits to the left of the panel's built-in
|
|
354
394
|
// fullscreen / close buttons so users can mint a public link without
|
|
355
395
|
// jumping out to the full `/ai/:id` page.
|
|
356
396
|
const [shareOpen, setShareOpen] = React.useState(false);
|
|
357
397
|
const restApiBase = React.useMemo(() => apiBase.replace(/\/v1\/ai$/, '').replace(/\/ai$/, '') || '/api', [apiBase]);
|
|
358
|
-
const headerActions = (_jsxs(_Fragment, { children: [messages.length > 0 ? (_jsx(Button, { variant: "ghost", size: "icon", className: "h-7 w-7", "aria-label": locale.newChat, title: locale.newChat, "data-testid": "floating-chatbot-new", onClick:
|
|
398
|
+
const headerActions = (_jsxs(_Fragment, { children: [messages.length > 0 ? (_jsx(Button, { variant: "ghost", size: "icon", className: "h-7 w-7", "aria-label": locale.newChat, title: locale.newChat, "data-testid": "floating-chatbot-new", onClick: onNewChat, children: _jsx(SquarePen, { className: "h-4 w-4" }) })) : null, conversationId ? (_jsx(Button, { variant: "ghost", size: "icon", className: "h-7 w-7", "aria-label": locale.share, title: locale.share, "data-testid": "floating-chatbot-share", onClick: () => setShareOpen(true), children: _jsx(Share2, { className: "h-4 w-4" }) })) : null] }));
|
|
359
399
|
return (_jsxs(_Fragment, { children: [_jsx(FloatingChatbot, { onUpgrade: () => window.open(cloudPricingDeepLink(), '_blank', 'noopener,noreferrer'), floatingConfig: {
|
|
360
400
|
position: 'bottom-right',
|
|
361
401
|
defaultOpen,
|
|
@@ -450,7 +490,7 @@ function ChatbotInner({ appLabel, appName, objects, agents, agentsLoading, activ
|
|
|
450
490
|
});
|
|
451
491
|
return false;
|
|
452
492
|
}
|
|
453
|
-
}, publishDraftsLabel: locale.publishDrafts, nextStepsLabel: locale.nextSteps, planTitleLabel: locale.planTitle, planQuestionsLabel: locale.planQuestions, planAssumptionsLabel: locale.planAssumptions, planApproveHintLabel: locale.planApproveHint, planApproveLabel: locale.planApprove, planAdjustLabel: locale.planAdjust, planBuiltLabel: locale.planBuilt, planApproveMessage: locale.planApproveMessage, planApproveDefaultsMessage: locale.planApproveDefaultsMessage, planAnswerMessage: locale.planAnswer, publishedLabel: locale.published,
|
|
493
|
+
}, publishDraftsLabel: locale.publishDrafts, nextStepsLabel: locale.nextSteps, planTitleLabel: locale.planTitle, planQuestionsLabel: locale.planQuestions, planAssumptionsLabel: locale.planAssumptions, planDeferredLabel: locale.planDeferred, planApproveHintLabel: locale.planApproveHint, planApproveLabel: locale.planApprove, planAdjustLabel: locale.planAdjust, planBuiltLabel: locale.planBuilt, planReadyLabel: locale.planReady, planApproveMessage: locale.planApproveMessage, planApproveDefaultsMessage: locale.planApproveDefaultsMessage, planAnswerMessage: locale.planAnswer, publishedLabel: locale.published,
|
|
454
494
|
// Self-use "magic moment": when the plan enables it, auto-publish the
|
|
455
495
|
// drafted app the instant the agent finishes — the success path above
|
|
456
496
|
// then navigates straight to the live app, so "build" lands the user on
|
|
@@ -488,18 +528,25 @@ export default function ConsoleFloatingChatbot({ appLabel, appName, objects, api
|
|
|
488
528
|
const chatApi = activeAgent
|
|
489
529
|
? `${apiBase}/agents/${encodeURIComponent(activeAgent)}/chat`
|
|
490
530
|
: undefined;
|
|
531
|
+
// The stateful BUILD surface resumes its in-progress conversation (staged
|
|
532
|
+
// drafts + the awaiting-confirm plan would otherwise be orphaned on reload);
|
|
533
|
+
// the ASK/data surface opens a fresh thread each visit (each question is
|
|
534
|
+
// largely self-contained, and resuming stale data answers is confusing). See
|
|
535
|
+
// `resumeMode` in useChatConversation.
|
|
536
|
+
const isBuildAgent = activeAgent ? agentRouteName(activeAgent) === 'build' : false;
|
|
491
537
|
// Server-backed conversation. Scoped by agent so each agent gets its own
|
|
492
538
|
// persistent history. Hook is inert until `userId` is provided; without it
|
|
493
539
|
// the FAB continues to work in local-only mode (no persistence). Gate `userId`
|
|
494
540
|
// on the agent being resolved so the conversation binds to the right scope
|
|
495
541
|
// from the first resolve (not a scopeless one during the catalog load).
|
|
496
|
-
const { conversationId, initialMessages } = useChatConversation({
|
|
542
|
+
const { conversationId, initialMessages, startNew } = useChatConversation({
|
|
497
543
|
userId: activeAgent ? userId : undefined,
|
|
498
544
|
scope: activeAgent,
|
|
499
545
|
apiBase,
|
|
546
|
+
resumeMode: isBuildAgent ? 'resume' : 'fresh',
|
|
500
547
|
});
|
|
501
548
|
// `key` forces a clean remount whenever the chat endpoint OR the resolved
|
|
502
549
|
// conversation id changes — required because `useObjectChat` locks its mode
|
|
503
550
|
// (api vs local) and its `conversationId` on first render.
|
|
504
|
-
return (_jsx(ChatbotInner, { appLabel: appLabel, appName: appName, objects: objects, agents: agents, agentsLoading: agentsLoading, agentsError: agentsError, activeAgent: activeAgent, onAgentChange: setActiveAgent, showAgentPicker: showAgentPicker, chatApi: chatApi, apiBase: apiBase, defaultOpen: defaultOpen, conversationId: conversationId, initialMessages: initialMessages }, `${chatApi ?? 'local'}:${conversationId ?? 'pending'}`));
|
|
551
|
+
return (_jsx(ChatbotInner, { appLabel: appLabel, appName: appName, objects: objects, agents: agents, agentsLoading: agentsLoading, agentsError: agentsError, activeAgent: activeAgent, onAgentChange: setActiveAgent, showAgentPicker: showAgentPicker, chatApi: chatApi, apiBase: apiBase, defaultOpen: defaultOpen, conversationId: conversationId, initialMessages: initialMessages, onNewChat: startNew }, `${chatApi ?? 'local'}:${conversationId ?? 'pending'}`));
|
|
505
552
|
}
|
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
* Header-left organization (workspace) switcher — the standard place users
|
|
5
5
|
* expect "which org am I in / switch org" to live (Linear/Vercel/GitHub style).
|
|
6
6
|
*
|
|
7
|
-
* - Single-org users (the vast majority):
|
|
8
|
-
*
|
|
7
|
+
* - Single-org users (the vast majority): render NOTHING. With one org there is
|
|
8
|
+
* nothing to switch to and the name isn't actionable, so it's pure chrome —
|
|
9
|
+
* the product logo already carries branding / "go home". The switcher is a
|
|
10
|
+
* multi-org affordance.
|
|
9
11
|
* - Multi-org users: the active org name + a dropdown to switch orgs inline
|
|
10
12
|
* (full-page reload so the active-org context refreshes app-wide, mirroring
|
|
11
13
|
* OrganizationsPage), plus shortcuts to manage members / create a workspace.
|
|
@@ -5,8 +5,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
5
5
|
* Header-left organization (workspace) switcher — the standard place users
|
|
6
6
|
* expect "which org am I in / switch org" to live (Linear/Vercel/GitHub style).
|
|
7
7
|
*
|
|
8
|
-
* - Single-org users (the vast majority):
|
|
9
|
-
*
|
|
8
|
+
* - Single-org users (the vast majority): render NOTHING. With one org there is
|
|
9
|
+
* nothing to switch to and the name isn't actionable, so it's pure chrome —
|
|
10
|
+
* the product logo already carries branding / "go home". The switcher is a
|
|
11
|
+
* multi-org affordance.
|
|
10
12
|
* - Multi-org users: the active org name + a dropdown to switch orgs inline
|
|
11
13
|
* (full-page reload so the active-org context refreshes app-wide, mirroring
|
|
12
14
|
* OrganizationsPage), plus shortcuts to manage members / create a workspace.
|
|
@@ -18,7 +20,7 @@ import { useAuth } from '@object-ui/auth';
|
|
|
18
20
|
import { useObjectTranslation } from '@object-ui/i18n';
|
|
19
21
|
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, } from '@object-ui/components';
|
|
20
22
|
import { ChevronsUpDown, Check, Plus, Users } from 'lucide-react';
|
|
21
|
-
import {
|
|
23
|
+
import { resolveRootUrl } from '../console/organizations/resolveHomeUrl';
|
|
22
24
|
function getOrgInitials(name) {
|
|
23
25
|
return name
|
|
24
26
|
.split(/[\s_-]+/)
|
|
@@ -55,18 +57,21 @@ export function WorkspaceSwitcher() {
|
|
|
55
57
|
// nothing rather than an empty switcher.
|
|
56
58
|
if (!current)
|
|
57
59
|
return null;
|
|
58
|
-
// Single-org:
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
// Single-org: render nothing. With only one org there's nothing to switch to
|
|
61
|
+
// and the name carries no actionable meaning, so it's just noise next to the
|
|
62
|
+
// product logo. The switcher appears only once a second org exists.
|
|
63
|
+
if (orgList.length <= 1)
|
|
64
|
+
return null;
|
|
62
65
|
const handleSwitch = async (org) => {
|
|
63
66
|
if (org.id === current.id)
|
|
64
67
|
return;
|
|
65
68
|
try {
|
|
66
69
|
await switchOrganization(org.id);
|
|
67
|
-
// switchOrganization only updates state; reload to
|
|
68
|
-
// org propagates to every data scope app-wide
|
|
69
|
-
|
|
70
|
+
// switchOrganization only updates state; reload to the console root so the
|
|
71
|
+
// new active org propagates to every data scope app-wide AND
|
|
72
|
+
// RootLandingRedirect resolves the right landing (single-app workspace →
|
|
73
|
+
// that app, not the redundant launcher). Same as OrganizationsPage.
|
|
74
|
+
window.location.href = resolveRootUrl();
|
|
70
75
|
}
|
|
71
76
|
catch (err) {
|
|
72
77
|
console.error('[WorkspaceSwitcher] switch failed', err);
|