From 688544ff7d4e5135e674874935d731fc5213b49f Mon Sep 17 00:00:00 2001 From: pftom <1043269994@qq.com> Date: Tue, 23 Jun 2026 09:03:27 +0800 Subject: [PATCH] fix(merge): repair post-merge regressions after origin/main integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up fixes on top of the origin/main merge (886f925cd) addressing regressions the conflict resolution introduced. main's web suite is the oracle (100% green); resolution principle was main's engine/backend + HEAD's UI, unioned. daemon: - library-sync.ts: correct design-systems import to ./design-systems/index.js (design-systems became a directory module on main). - tests/server-bootstrap-regression: add LIBRARY_DIR to the PathDeps fixture (main-added test x HEAD-added LIBRARY_DIR field). web: - WorkspaceTabsBar: union — restore main's onboarding Search-popover close behaviour + guards, keep HEAD's library/brands nav entries. - HomeView: restore main's composer sending-state (await onSubmit, widen its return type to Promise|boolean|void, pass submitting to HomeHero). - MemorySection.test: take main's version to match main's two-loop memory component. - i18n: restore dropped settings.onboardingRoleMarketing key across types.ts and all locales. - App/BrandsTab/EntryNavRail/router/home-intent: union fixes restoring main features dropped during conflict resolution (needs_input handling, etc.). Validation: pnpm guard + full pnpm typecheck (all 23 packages) green. Known-red: EntryShell onboarding step 3 intentionally retains HEAD's "build" step rather than main's brand-extract step; 8 EntryShell.onboarding / App.onboarding-amr-e2e tests stay red pending that onboarding decision. --- apps/daemon/src/library-sync.ts | 2 +- .../tests/server-bootstrap-regression.test.ts | 1 + apps/web/src/App.tsx | 73 ++++- apps/web/src/components/BrandsTab.tsx | 12 +- apps/web/src/components/EntryNavRail.tsx | 9 + apps/web/src/components/HomeView.tsx | 259 ++++++++++-------- apps/web/src/components/WorkspaceTabsBar.tsx | 31 ++- apps/web/src/i18n/locales/ar.ts | 20 +- apps/web/src/i18n/locales/de.ts | 20 +- apps/web/src/i18n/locales/en.ts | 22 +- apps/web/src/i18n/locales/es-ES.ts | 20 +- apps/web/src/i18n/locales/fa.ts | 20 +- apps/web/src/i18n/locales/fr.ts | 20 +- apps/web/src/i18n/locales/hu.ts | 20 +- apps/web/src/i18n/locales/id.ts | 20 +- apps/web/src/i18n/locales/it.ts | 20 +- apps/web/src/i18n/locales/ja.ts | 20 +- apps/web/src/i18n/locales/ko.ts | 20 +- apps/web/src/i18n/locales/pl.ts | 20 +- apps/web/src/i18n/locales/pt-BR.ts | 20 +- apps/web/src/i18n/locales/ru.ts | 20 +- apps/web/src/i18n/locales/th.ts | 20 +- apps/web/src/i18n/locales/tr.ts | 20 +- apps/web/src/i18n/locales/uk.ts | 20 +- apps/web/src/i18n/locales/zh-CN.ts | 20 +- apps/web/src/i18n/locales/zh-TW.ts | 20 +- apps/web/src/i18n/types.ts | 16 +- apps/web/src/router.ts | 11 +- apps/web/src/runtime/home-intent.ts | 21 +- .../tests/components/MemorySection.test.tsx | 25 +- 30 files changed, 635 insertions(+), 207 deletions(-) diff --git a/apps/daemon/src/library-sync.ts b/apps/daemon/src/library-sync.ts index 6daae1a6f1..2f76b9fcbe 100644 --- a/apps/daemon/src/library-sync.ts +++ b/apps/daemon/src/library-sync.ts @@ -23,7 +23,7 @@ import { readFile, stat } from 'node:fs/promises'; import type Database from 'better-sqlite3'; import type { LibraryAssetKind, LibrarySourceKind } from '@open-design/contracts'; import { listConversations, listMessages, listProjects } from './db.js'; -import { listDesignSystems } from './design-systems.js'; +import { listDesignSystems } from './design-systems/index.js'; import { listFiles, resolveProjectDir } from './projects.js'; import { registerLibraryAsset } from './library.js'; import { findReferencedAssetByOrigin, hasDesignSystemSource } from './library-store.js'; diff --git a/apps/daemon/tests/server-bootstrap-regression.test.ts b/apps/daemon/tests/server-bootstrap-regression.test.ts index 85c833e3ac..7fbf4784ba 100644 --- a/apps/daemon/tests/server-bootstrap-regression.test.ts +++ b/apps/daemon/tests/server-bootstrap-regression.test.ts @@ -385,6 +385,7 @@ describe('bootstrap route regressions', () => { CRAFT_DIR: path.join(tempRoot, 'craft'), DESIGN_SYSTEMS_DIR: path.join(tempRoot, 'design-systems'), DESIGN_TEMPLATES_DIR: path.join(tempRoot, 'design-templates'), + LIBRARY_DIR: path.join(tempRoot, 'library'), OD_BIN: path.join(tempRoot, 'od'), PROJECT_ROOT: tempRoot, PROJECTS_DIR: path.join(tempRoot, 'projects'), diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 21de13ca1f..3daace3757 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -21,6 +21,7 @@ import { PluginDetailView } from './components/PluginDetailView'; import type { CreateInput, ImportClaudeDesignOutcome } from './components/NewProjectPanel'; import { MemoryToast } from './components/MemoryToast'; import { Toast } from './components/Toast'; +import { CenteredLoader } from './components/Loading'; import { PetOverlay, type PetTaskCenter } from './components/pet/PetOverlay'; import { buildPetTaskCenter } from './components/pet/taskCenter'; import { migrateCustomPetAtlas } from './components/pet/pets'; @@ -119,6 +120,7 @@ import type { const APP_CONFIG_CHANGED_EVENT = 'open-design:app-config-changed'; const AMR_AGENT_ID = 'amr'; const AMR_PROFILE_ENV_KEY = 'OPEN_DESIGN_AMR_PROFILE'; +const AGENT_FOCUS_REFRESH_THROTTLE_MS = 10_000; export function shouldSyncMediaProvidersOnSave( mediaProviders: AppConfig['mediaProviders'], @@ -369,6 +371,7 @@ function AppInner() { const amrModelsRef = useRef(null); const amrPollGenerationRef = useRef(0); const agentStreamRequestSeqRef = useRef(0); + const agentFocusRefreshLastRunRef = useRef(Date.now()); const [amrPollRestartToken, setAmrPollRestartToken] = useState(0); const [providerModelsCache, setProviderModelsCache] = useState< Record @@ -1046,6 +1049,12 @@ function AppInner() { reconcileFetchedProjects(list, request); }, [beginProjectListRequest, reconcileFetchedProjects]); + const refreshProjectsStrict = useCallback(async () => { + const request = beginProjectListRequest(); + const list = await listProjects({ throwOnError: true }); + reconcileFetchedProjects(list, request); + }, [beginProjectListRequest, reconcileFetchedProjects]); + const refreshDesignSystems = useCallback(async () => { const list = await fetchDesignSystems(); setDesignSystems(list); @@ -1162,6 +1171,14 @@ function AppInner() { const handleThemeChange = useCallback( (theme: AppConfig['theme']) => { const next = { ...config, theme }; + // Apply to the DOM synchronously inside the click handler so the theme + // flips instantly. Otherwise the visible switch waits on the (heavier) + // React re-render of the whole tree before the layout effect re-applies + // it — which reads as a perceptible lag after the click. + applyAppearanceToDocument({ + theme: theme ?? 'system', + accentColor: config.accentColor, + }); saveConfig(next); void syncConfigToDaemon(next); setConfig(next); @@ -1275,6 +1292,29 @@ function AppInner() { [beginAgentStreamRequest, config, isCurrentAgentStreamRequest], ); + useEffect(() => { + if (!daemonLive || agentsLoading) return; + + const refreshIfDue = () => { + if (document.visibilityState === 'hidden') return; + const now = Date.now(); + if (now - agentFocusRefreshLastRunRef.current < AGENT_FOCUS_REFRESH_THROTTLE_MS) return; + agentFocusRefreshLastRunRef.current = now; + void refreshAgents(); + }; + + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible') refreshIfDue(); + }; + + window.addEventListener('focus', refreshIfDue); + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => { + window.removeEventListener('focus', refreshIfDue); + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, [agentsLoading, daemonLive, refreshAgents]); + useEffect(() => { const handleAppConfigChanged = () => { void fetchDaemonConfig().then((daemonConfig) => { @@ -1804,17 +1844,30 @@ function AppInner() { }); }, []); - const activeProject = + const loadedActiveProject = route.kind === 'project' ? (projects.find((p) => p.id === route.projectId) ?? null) : null; + const routeProjectPlaceholder = useMemo(() => { + if (route.kind !== 'project') return null; + const now = Date.now(); + return { + id: route.projectId, + name: 'Untitled', + skillId: null, + designSystemId: null, + createdAt: now, + updatedAt: now, + }; + }, [route]); + const activeProject = loadedActiveProject ?? routeProjectPlaceholder; // Deep-linked route to a project we don't have yet (e.g. after a refresh // that finishes after the project list comes back). Fetch it in the // background so the view can render rather than bouncing to home. useEffect(() => { if (route.kind !== 'project') return; - if (activeProject) return; + if (loadedActiveProject) return; if (!projects.length && !daemonLive) return; if (projects.some((p) => p.id === route.projectId)) return; let cancelled = false; @@ -1850,7 +1903,7 @@ function AppInner() { return () => { cancelled = true; }; - }, [route, activeProject, projects, daemonLive, beginProjectListRequest, reconcileFetchedProjects, t]); + }, [route, loadedActiveProject, projects, daemonLive, beginProjectListRequest, reconcileFetchedProjects, t]); const openSettings = useCallback(( section: SettingsSection = 'execution', @@ -2033,7 +2086,18 @@ function AppInner() { // EntryView / ProjectView split so the discovery surface stays // independent of any active project. let appMain: ReactNode; - if (route.kind === 'marketplace') { + const pendingFirstRunOnboardingRoute = + route.kind === 'home' && + route.view === 'home' && + config.onboardingCompleted !== true && + !daemonConfigLoaded; + if (pendingFirstRunOnboardingRoute) { + appMain = ( +
+ +
+ ); + } else if (route.kind === 'marketplace') { appMain = ; } else if (route.kind === 'marketplace-detail') { appMain = ; @@ -2155,6 +2219,7 @@ function AppInner() { onOpenLiveArtifact={handleOpenLiveArtifact} onDeleteProject={handleDeleteProject} onRenameProject={handleRenameProject} + onProjectsRefresh={refreshProjectsStrict} onChangeDefaultDesignSystem={handleChangeDefaultDesignSystem} onCreateDesignSystem={() => navigate({ kind: 'design-system-create' })} onOpenDesignSystem={(id: string) => navigate({ kind: 'design-system-detail', designSystemId: id })} diff --git a/apps/web/src/components/BrandsTab.tsx b/apps/web/src/components/BrandsTab.tsx index 4b4360c176..ef8b55ff66 100644 --- a/apps/web/src/components/BrandsTab.tsx +++ b/apps/web/src/components/BrandsTab.tsx @@ -62,12 +62,16 @@ export function BrandsTab({ onApplyDesignSystem, onOpenProject }: BrandsTabProps if (isBrandsView) void refresh(); }, [isBrandsView, refresh]); - // While a brand is mid-extraction, poll so its card flips from `extracting` - // to the finalized preview without the user leaving and returning. Scoped to - // the active view and torn down once nothing is extracting, so a hidden tab + // While a brand is mid-extraction (or paused awaiting user input), poll so its + // card flips from `extracting` to the finalized preview — or back from + // `needs_input` once the user answers — without leaving and returning. Scoped + // to the active view and torn down once nothing is in-flight, so a hidden tab // never polls. const hasExtracting = useMemo( - () => (brands ?? []).some((b) => b.meta.status === 'extracting'), + () => + (brands ?? []).some( + (b) => b.meta.status === 'extracting' || b.meta.status === 'needs_input', + ), [brands], ); useEffect(() => { diff --git a/apps/web/src/components/EntryNavRail.tsx b/apps/web/src/components/EntryNavRail.tsx index 50f4c23a42..b0677a4b03 100644 --- a/apps/web/src/components/EntryNavRail.tsx +++ b/apps/web/src/components/EntryNavRail.tsx @@ -167,6 +167,15 @@ export function EntryNavRail({ view, onViewChange, onNewProject, open, onClose } > + selectView('brands')} + testId="entry-nav-brands" + > + + void; + onSubmit: (payload: PluginLoopSubmit) => Promise | boolean | void; onOpenProject: (id: string) => void; onViewAllProjects: () => void; onBrowseRegistry?: () => void; @@ -322,6 +322,9 @@ export function HomeView({ examplePromptInfoRef.current = info; }, []); const [error, setError] = useState(null); + // Composer in-flight guard: disables the send button, shows Sending…, and + // swallows repeat clicks across the whole async create tail. + const [sending, setSending] = useState(false); const [elevenLabsVoices, setElevenLabsVoices] = useState([]); const [elevenLabsVoicesLoading, setElevenLabsVoicesLoading] = useState(false); // Live AIHubMix image catalogue merged into the home media composer's model @@ -1610,6 +1613,9 @@ export function HomeView({ }, [plugins, chipIntentTick]); async function submit() { + // The send button disables itself while sending, but the Enter-to-send + // path lands here directly — swallow re-entry during the in-flight window. + if (sending) return; const trimmed = prompt.trim(); if (!trimmed && stagedFiles.length === 0) return; // P0 ui_click area=chat_composer element=send_button. Fires before the @@ -1634,122 +1640,146 @@ export function HomeView({ ); return; } - const defaultInputs = { prompt: trimmed }; - // The persistent picker is the single source of truth for the new project's - // design system, so every product kind (not just prototype/deck) carries - // the user's selection — the plugin's `designSystem` input is only the - // apply-template hint and is kept in sync via handleDesignSystemChange. - const submittedDesignSystemId = designSystemId; - // Composer inputs are forwarded as-is; the deferred footer/media fields are - // stripped from this set just below to form the run-facing inputs. - const submittedApplyInputs = submittedActive ? submittedActive.inputs : defaultInputs; - // Inputs forwarded to the run AND used to build the run-facing snapshot: - // drop every now-hidden footer/media setting so the first-turn - // question-form flow collects them instead of inheriting a baked-in - // default (`ratio: 16:9`, `duration: 5`, `audioType: speech`, …). The - // snapshot is resolved from these stripped inputs too — the daemon renders - // `## Plugin inputs` from `snapshot.inputs` and tells the agent not to - // re-ask about anything listed there, so leaving the deferred defaults in - // the snapshot would suppress the discovery flow even though - // `onSubmit.pluginInputs` was stripped. Stripping only removes non-required - // fields (`subject`/`style`/`aspect`/`mediaKind` stay), so the - // od-media-generation apply still validates. - const submittedPluginInputs = submittedActive - ? stripArtifactFooterInputs(submittedApplyInputs) - : defaultInputs; - const activeInputsChangedForSubmit = submittedActive - ? !inputsEqual(submittedActive.result?.appliedPlugin?.inputs ?? submittedActive.inputs, submittedPluginInputs) - : false; - if (submittedActive && (!submittedActive.result || activeInputsChangedForSubmit)) { - const result = await resolveActivePlugin(submittedActive.record, submittedPluginInputs); - if (!result) { - setError(`Failed to apply ${submittedActive.record.title}. Check the plugin parameters and try again.`); + setError(null); + // Sending covers the whole async tail — a pending plugin apply (when one + // must resolve first) and the project-creation roundtrip are both windows + // a second click could otherwise re-enter. + setSending(true); + try { + const defaultInputs = { prompt: trimmed }; + // The persistent picker is the single source of truth for the new project's + // design system, so every product kind (not just prototype/deck) carries + // the user's selection — the plugin's `designSystem` input is only the + // apply-template hint and is kept in sync via handleDesignSystemChange. + const submittedDesignSystemId = designSystemId; + // Composer inputs are forwarded as-is; the deferred footer/media fields are + // stripped from this set just below to form the run-facing inputs. + const submittedApplyInputs = submittedActive ? submittedActive.inputs : defaultInputs; + // Inputs forwarded to the run AND used to build the run-facing snapshot: + // drop every now-hidden footer/media setting so the first-turn + // question-form flow collects them instead of inheriting a baked-in + // default (`ratio: 16:9`, `duration: 5`, `audioType: speech`, …). The + // snapshot is resolved from these stripped inputs too — the daemon renders + // `## Plugin inputs` from `snapshot.inputs` and tells the agent not to + // re-ask about anything listed there, so leaving the deferred defaults in + // the snapshot would suppress the discovery flow even though + // `onSubmit.pluginInputs` was stripped. Stripping only removes non-required + // fields (`subject`/`style`/`aspect`/`mediaKind` stay), so the + // od-media-generation apply still validates. + const submittedPluginInputs = submittedActive + ? stripArtifactFooterInputs(submittedApplyInputs) + : defaultInputs; + const activeInputsChangedForSubmit = submittedActive + ? !inputsEqual(submittedActive.result?.appliedPlugin?.inputs ?? submittedActive.inputs, submittedPluginInputs) + : false; + if (submittedActive && (!submittedActive.result || activeInputsChangedForSubmit)) { + const result = await resolveActivePlugin(submittedActive.record, submittedPluginInputs); + if (!result) { + setError(`Failed to apply ${submittedActive.record.title}. Check the plugin parameters and try again.`); + return; + } + submittedActive = { ...submittedActive, result, inputs: submittedPluginInputs }; + setActive(submittedActive); + } + // Reconcile each selected context against the serialized prompt text before + // forwarding it. Inline-backed contexts (inserted as `@mention` pills) are + // only sent while their token survives in the prompt — the Lexical composer + // lets users delete a mention pill (backspace, edit), and when they do that + // plugin/MCP/connector should stop being sent. Context-only `Use` + // selections never carry a token, so they stay in the payload until the + // user explicitly clears them. + const contextPlugins = selectedPluginContexts + .filter((item) => !item.inlineBacked || mentionTokenPresent(trimmed, item.record.title)) + .map((item) => ({ + id: item.record.id, + title: item.record.title, + ...(item.record.manifest?.description + ? { description: item.record.manifest.description } + : {}), + })); + const contextMcpServers = selectedMcpContexts + .filter((item) => !item.inlineBacked || mentionTokenPresent(trimmed, item.server.label || item.server.id)) + .map((item) => ({ + id: item.server.id, + ...(item.server.label ? { label: item.server.label } : {}), + ...(item.server.transport ? { transport: item.server.transport } : {}), + ...(item.server.url ? { url: item.server.url } : {}), + ...(item.server.command ? { command: item.server.command } : {}), + })); + const contextConnectors = selectedConnectorContexts + .filter((item) => !item.inlineBacked || mentionTokenPresent(trimmed, item.connector.name)) + .map((item) => ({ + id: item.connector.id, + name: item.connector.name, + provider: item.connector.provider, + category: item.connector.category, + status: item.connector.status, + ...(item.connector.accountLabel ? { accountLabel: item.connector.accountLabel } : {}), + })); + const submittedProjectKind = + submittedActive?.projectKind ?? fallbackProjectKind ?? projectKindForSkill(activeSkill) ?? 'other'; + const submittedProjectMetadata = submittedActive?.mediaSurface + ? metadataForHomeMediaComposer(submittedActive.mediaSurface, submittedActive.inputs, promptTemplates) + : homeCreateProjectMetadata( + submittedProjectKind, + submittedActive?.inputs ?? null, + submittedActive?.projectMetadata ?? fallbackProjectMetadata ?? null, + ); + // Scenario plugins (chips / preset cards) and explicit skill picks are + // mutually exclusive routing sources — never send both (#2972). + const resolvedSkillId = submittedActive ? null : activeSkill?.id ?? null; + const routedPluginId = + sessionMode === 'design' + ? submittedActive?.record.id ?? DEFAULT_UNSELECTED_SCENARIO_PLUGIN_ID + : submittedActive?.record.id ?? null; + // The example-prompt override is a one-shot marker. Decide whether to + // send it now, but defer spending the marker until the create is + // accepted — a rejected attempt stays retryable and must resend it. + const examplePromptKey = 'od:example-prompt-used'; + const examplePromptToSend = + examplePromptInfoRef.current != null && localStorage.getItem(examplePromptKey) == null + ? examplePromptInfoRef.current + : null; + const accepted = await onSubmit({ + prompt: trimmed, + pluginId: routedPluginId, + pluginType: submittedActive?.record.marketplaceTrust ?? (routedPluginId ? 'official' : null), + skillId: resolvedSkillId, + appliedPluginSnapshotId: submittedActive?.result?.appliedPlugin?.snapshotId ?? null, + pluginTitle: submittedActive?.record.title ?? null, + taskKind: submittedActive?.result?.appliedPlugin?.taskKind ?? null, + pluginInputs: submittedPluginInputs, + projectKind: submittedProjectKind, + projectMetadata: submittedProjectMetadata, + designSystemId: submittedDesignSystemId, + contextPlugins, + contextMcpServers, + contextConnectors, + attachments: stagedFiles, + ...(workingDir ? { workingDir } : {}), + ...(workingDirToken ? { workingDirToken } : {}), + conversationMode: sessionMode, + ...(examplePromptToSend ? { examplePromptContext: examplePromptToSend } : {}), + }); + if (accepted === false) { + setError('Failed to start the run. Make sure the daemon is reachable, then try again.'); return; } - submittedActive = { ...submittedActive, result, inputs: submittedPluginInputs }; - setActive(submittedActive); + // Create accepted — now it is safe to spend the one-shot marker. + if (examplePromptToSend) localStorage.setItem(examplePromptKey, '1'); + // Only drop the staged contexts once the run actually started — a + // rejected creation keeps them so the retry sends the same payload. + setSelectedPluginContexts([]); + setSelectedMcpContexts([]); + setSelectedConnectorContexts([]); + } catch (err) { + // A submit handler that throws (instead of resolving false) lands on + // the same recovery path as a rejected creation. + console.warn('Home composer submit failed', err); + setError('Failed to start the run. Make sure the daemon is reachable, then try again.'); + } finally { + setSending(false); } - // Reconcile each selected context against the serialized prompt text before - // forwarding it. Inline-backed contexts (inserted as `@mention` pills) are - // only sent while their token survives in the prompt — the Lexical composer - // lets users delete a mention pill (backspace, edit), and when they do that - // plugin/MCP/connector should stop being sent. Context-only `Use` - // selections never carry a token, so they stay in the payload until the - // user explicitly clears them. - const contextPlugins = selectedPluginContexts - .filter((item) => !item.inlineBacked || mentionTokenPresent(trimmed, item.record.title)) - .map((item) => ({ - id: item.record.id, - title: item.record.title, - ...(item.record.manifest?.description - ? { description: item.record.manifest.description } - : {}), - })); - const contextMcpServers = selectedMcpContexts - .filter((item) => !item.inlineBacked || mentionTokenPresent(trimmed, item.server.label || item.server.id)) - .map((item) => ({ - id: item.server.id, - ...(item.server.label ? { label: item.server.label } : {}), - ...(item.server.transport ? { transport: item.server.transport } : {}), - ...(item.server.url ? { url: item.server.url } : {}), - ...(item.server.command ? { command: item.server.command } : {}), - })); - const contextConnectors = selectedConnectorContexts - .filter((item) => !item.inlineBacked || mentionTokenPresent(trimmed, item.connector.name)) - .map((item) => ({ - id: item.connector.id, - name: item.connector.name, - provider: item.connector.provider, - category: item.connector.category, - status: item.connector.status, - ...(item.connector.accountLabel ? { accountLabel: item.connector.accountLabel } : {}), - })); - const submittedProjectKind = - submittedActive?.projectKind ?? fallbackProjectKind ?? projectKindForSkill(activeSkill) ?? 'other'; - const submittedProjectMetadata = submittedActive?.mediaSurface - ? metadataForHomeMediaComposer(submittedActive.mediaSurface, submittedActive.inputs, promptTemplates) - : homeCreateProjectMetadata( - submittedProjectKind, - submittedActive?.inputs ?? null, - submittedActive?.projectMetadata ?? fallbackProjectMetadata ?? null, - ); - // Scenario plugins (chips / preset cards) and explicit skill picks are - // mutually exclusive routing sources — never send both (#2972). - const resolvedSkillId = submittedActive ? null : activeSkill?.id ?? null; - const routedPluginId = - sessionMode === 'design' - ? submittedActive?.record.id ?? DEFAULT_UNSELECTED_SCENARIO_PLUGIN_ID - : submittedActive?.record.id ?? null; - onSubmit({ - prompt: trimmed, - pluginId: routedPluginId, - pluginType: submittedActive?.record.marketplaceTrust ?? (routedPluginId ? 'official' : null), - skillId: resolvedSkillId, - appliedPluginSnapshotId: submittedActive?.result?.appliedPlugin?.snapshotId ?? null, - pluginTitle: submittedActive?.record.title ?? null, - taskKind: submittedActive?.result?.appliedPlugin?.taskKind ?? null, - pluginInputs: submittedPluginInputs, - projectKind: submittedProjectKind, - projectMetadata: submittedProjectMetadata, - designSystemId: submittedDesignSystemId, - contextPlugins, - contextMcpServers, - contextConnectors, - attachments: stagedFiles, - ...(workingDir ? { workingDir } : {}), - ...(workingDirToken ? { workingDirToken } : {}), - conversationMode: sessionMode, - ...(() => { - if (!examplePromptInfoRef.current) return {}; - const key = 'od:example-prompt-used'; - if (localStorage.getItem(key)) return {}; - localStorage.setItem(key, '1'); - return { examplePromptContext: examplePromptInfoRef.current }; - })(), - }); - setSelectedPluginContexts([]); - setSelectedMcpContexts([]); - setSelectedConnectorContexts([]); } return ( @@ -1761,6 +1791,7 @@ export function HomeView({ prompt={prompt} onPromptChange={handlePromptChange} onSubmit={submit} + submitting={sending} sessionMode={sessionMode} onSessionModeChange={setSessionMode} activePluginTitle={activeBadgeTitle} diff --git a/apps/web/src/components/WorkspaceTabsBar.tsx b/apps/web/src/components/WorkspaceTabsBar.tsx index 47777c558a..1c4f3a63f3 100644 --- a/apps/web/src/components/WorkspaceTabsBar.tsx +++ b/apps/web/src/components/WorkspaceTabsBar.tsx @@ -53,7 +53,7 @@ interface TabDragTarget { interface Props { route: Route; projects: Project[]; - // Once onboarding is finished (completed or skipped), the permanent entry + // Once onboarding is finished, the permanent entry // tab must never linger on the 'onboarding' (Welcome) view — some completion // paths navigate straight to a new project/design-system and leave the entry // tab showing Welcome in the background. This flips it back to Home. @@ -494,11 +494,15 @@ export function WorkspaceTabsBar({ route, projects, onboardingCompleted = false // Auto-close the Welcome tab once onboarding ends: rewrite any entry tab // still parked on the 'onboarding' view back to 'home'. This catches every - // finish path uniformly — Skip, last-step Continue, and the design-system - // Generate route that navigates to a fresh project while leaving the entry - // tab on Welcome in the background. + // finish path uniformly — last-step Continue and any future route that + // navigates away while leaving the entry tab on Welcome in the background. useEffect(() => { if (!onboardingCompleted) return; + // Don't rewrite the tab back to 'home' while the user is *still* on the + // onboarding route — a previously-completed user who re-opens /onboarding + // should keep the "Onboarding" tab label, not flip to "Home". The rewrite + // still fires the moment they navigate away (onboardingActive turns false). + if (onboardingActive) return; setState((current) => { if (!current.tabs.some((tab) => tab.kind === 'entry' && tab.view === 'onboarding')) { return current; @@ -512,7 +516,17 @@ export function WorkspaceTabsBar({ route, projects, onboardingCompleted = false ), }); }); - }, [onboardingCompleted]); + }, [onboardingCompleted, onboardingActive]); + + // Close the Search-tabs popover whenever onboarding becomes active. The + // trigger button is hidden during onboarding, so a popover left open across + // a route flip to /onboarding (e.g. browser back/forward, which bypasses + // activateTab/createNewTab) would otherwise float over the first-run flow + // with no visible control to dismiss it. The portal is also gated on + // !onboardingActive below so it never renders for the frame before this runs. + useEffect(() => { + if (onboardingActive) setTabsMenuOpen(false); + }, [onboardingActive]); // Scroll the active tab into view when it changes. The strip itself // is native-scrollable horizontally (see CSS), so we just nudge the @@ -976,12 +990,14 @@ export function WorkspaceTabsBar({ route, projects, onboardingCompleted = false data-tooltip="New tab" data-tooltip-placement="bottom" aria-label="New tab" + data-testid="workspace-tabs-new-tab" disabled={onboardingActive} >
+ {onboardingActive ? null : ( - {tabsMenuOpen && typeof document !== 'undefined' + )} + {tabsMenuOpen && !onboardingActive && typeof document !== 'undefined' ? createPortal(
` design system - // and extraction now starts from the design-system create wizard. Legacy - // `/brands` and `/brands/:id` deep-links redirect onto the unified tab. - return { kind: 'home', view: 'design-systems' }; + // The Brands tab shows everything inline in its preview panel; there is no + // separate detail view. A `/brands/:id` deep-link just preselects which + // brand the inline preview renders. + if (parts[1]) { + return { kind: 'home', view: 'brands', brandId: decodeURIComponent(parts[1]) }; + } + return { kind: 'home', view: 'brands' }; } if (parts[0] === 'automations' || parts[0] === 'tasks') { return { kind: 'home', view: 'tasks' }; diff --git a/apps/web/src/runtime/home-intent.ts b/apps/web/src/runtime/home-intent.ts index 7557235c63..c494db2d82 100644 --- a/apps/web/src/runtime/home-intent.ts +++ b/apps/web/src/runtime/home-intent.ts @@ -12,12 +12,21 @@ export const HOME_CHIP_INTENT_EVENT = 'od:home-chip-intent'; let pendingChipId: string | null = null; +let pendingNotice: string | null = null; + +export interface HomeChipOptions { + /** A one-shot confirmation banner HomeView shows once it consumes the chip — + * e.g. "Using Ramp Brand Kit" after "Use in new chat". Makes an otherwise + * invisible navigate+apply visibly verifiable to the user. */ + notice?: string; +} // Queue a Home composer chip to auto-select on the next Home render, then // notify any mounted HomeView. Safe to call before HomeView exists — the -// pending id survives until consumed. -export function requestHomeChip(chipId: string): void { +// pending id (and optional confirmation notice) survives until consumed. +export function requestHomeChip(chipId: string, options?: HomeChipOptions): void { pendingChipId = chipId; + pendingNotice = options?.notice ?? null; if (typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent(HOME_CHIP_INTENT_EVENT, { detail: { chipId } })); } @@ -30,6 +39,14 @@ export function consumePendingHomeChip(): string | null { return chipId; } +// Read and clear the pending confirmation notice queued alongside the chip. +// Returns null when none is queued. Consume AFTER consumePendingHomeChip. +export function consumePendingHomeNotice(): string | null { + const notice = pendingNotice; + pendingNotice = null; + return notice; +} + // Peek without consuming — lets a consumer bail early (e.g. plugins not yet // loaded) without dropping the pending intent. export function hasPendingHomeChip(): boolean { diff --git a/apps/web/tests/components/MemorySection.test.tsx b/apps/web/tests/components/MemorySection.test.tsx index 2cd5dac31b..b8e0b02eaa 100644 --- a/apps/web/tests/components/MemorySection.test.tsx +++ b/apps/web/tests/components/MemorySection.test.tsx @@ -66,6 +66,10 @@ async function openManualMemoryTab() { fireEvent.click(await screen.findByRole('tab', { name: 'Add manually' })); } +async function openAdvancedMemoryModal() { + fireEvent.click(await screen.findByRole('button', { name: 'Advanced' })); +} + // The top-level "Memories" / "How it works" tabs render their label plus a // caption, so their accessible name is composite. Match by the leading label. const howItWorksTopTab = { name: /^How it works/ } as const; @@ -203,7 +207,6 @@ describe('MemorySection', () => { await waitFor(() => { expect(screen.getByText('UI preferences')).toBeTruthy(); }); - expect(screen.getByText('✓ Memory created')).toBeTruthy(); expect(createBodies).toEqual([ { name: 'UI preferences', @@ -290,9 +293,7 @@ describe('MemorySection', () => { renderMemorySection(); - // The entry editor opened from a tree node renders inside the Add-manually - // sub-tab. - await openManualMemoryTab(); + await openAdvancedMemoryModal(); const treeSummary = await screen.findByText('Memory tree'); const treeDetails = treeSummary.closest('details')!; expect(within(treeDetails).getByText('/project')).toBeTruthy(); @@ -378,6 +379,7 @@ describe('MemorySection', () => { renderMemorySection(); + await openAdvancedMemoryModal(); const treeDetails = (await screen.findByText('Memory tree')).closest('details')!; const childRow = within(treeDetails) .getByText('Design agent goal') @@ -427,9 +429,7 @@ describe('MemorySection', () => { renderMemorySection(); - // The "✓ Index saved" flash toast renders inside the Add-manually sub-tab, - // so open it before saving the index. - await openManualMemoryTab(); + await openAdvancedMemoryModal(); fireEvent.click(await screen.findByText('MEMORY.md (index)')); const indexArea = await findMemoryIndexTextarea(); fireEvent.change(indexArea, { @@ -440,9 +440,8 @@ describe('MemorySection', () => { fireEvent.click(screen.getByRole('button', { name: 'Save index' })); await waitFor(() => { - expect(screen.getByText('✓ Index saved')).toBeTruthy(); + expect(putBodies).toEqual(['# Memory\n\n- Existing bullet\n- New bullet\n']); }); - expect(putBodies).toEqual(['# Memory\n\n- Existing bullet\n- New bullet\n']); }); it('reveals the editor after editing an existing memory entry', async () => { @@ -563,6 +562,7 @@ describe('MemorySection', () => { const savedRow = await screen.findByText('UI preferences'); const extractionRow = await screen.findByText('Remember I prefer dark mode'); await openAddMemories(); + await openAdvancedMemoryModal(); const indexSummary = screen.getByText('MEMORY.md (index)') .closest('summary') as HTMLElement; @@ -572,7 +572,8 @@ describe('MemorySection', () => { expect(extractionRow.closest('.library-card')?.className).toContain( 'memory-extraction-card', ); - expect(indexSummary.closest('.memory-advanced-section')).toBeTruthy(); + expect(indexSummary.closest('.memory-action-modal--advanced')).toBeTruthy(); + expect(indexSummary.closest('.memory-advanced-card')).toBeTruthy(); expect(indexSummary.closest('.memory-records-section')).toBeNull(); expect(screen.queryByText('Extraction history')).toBeNull(); expect(indexSummary.className).toContain('memory-details-summary'); @@ -1490,7 +1491,7 @@ describe('MemorySection', () => { fireEvent.click(screen.getByRole('button', { name: 'Save' })); await waitFor(() => { - expect(screen.getByText('✓ Memory saved')).toBeTruthy(); + expect(screen.getByText('Updated preference')).toBeTruthy(); }); expect(putBodies).toEqual([ { @@ -1501,7 +1502,6 @@ describe('MemorySection', () => { body: '- Prefer spacious layouts', }, ]); - expect(screen.getByText('Updated preference')).toBeTruthy(); }); it('keeps the expanded preview control visually distinct from delete', async () => { @@ -1706,6 +1706,7 @@ describe('MemorySection', () => { renderMemorySection(); + await openAdvancedMemoryModal(); fireEvent.click(await screen.findByText('MEMORY.md (index)')); const indexArea = await findMemoryIndexTextarea(); fireEvent.change(indexArea, {