@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.
@@ -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 ObjectUI' }) }), _jsx(EmptyDescription, { children: buildAvailable
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 { resolveHomeUrl } from './resolveHomeUrl';
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
- window.location.href = resolveHomeUrl();
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
- const root = baseHref
27
+ return baseHref
24
28
  ? new URL(baseHref, window.location.origin)
25
29
  : new URL('/', window.location.origin);
26
- return new URL('home', root).toString();
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
- setInitialMessages(messages.length > 0 ? messages : readMessageCache(existing.id));
442
- resolvedForUserRef.current = userId;
443
- resolvedScopeRef.current = scope;
444
- return;
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
- const headerExtra = showAgentPicker && agents.length > 1 ? (_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))) })] })) : null;
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: clear, 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] }));
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): just the org name, NO dropdown. There
8
- * is nothing to switch to, so a one-item menu would be pure friction.
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): just the org name, NO dropdown. There
9
- * is nothing to switch to, so a one-item menu would be pure friction.
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 { resolveHomeUrl } from '../console/organizations/resolveHomeUrl';
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: static label, no dropdown.
59
- if (orgList.length <= 1) {
60
- return (_jsxs("span", { className: "ml-2 hidden max-w-[12rem] items-center gap-1.5 sm:inline-flex", "data-testid": "workspace-name", children: [_jsx(OrgBadge, { name: current.name }), _jsx("span", { className: "truncate text-sm font-medium text-foreground/80", children: current.name })] }));
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 home so the new active
68
- // org propagates to every data scope app-wide (same as OrganizationsPage).
69
- window.location.href = resolveHomeUrl();
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);