diff --git a/apps/daemon/src/routes/project/index.ts b/apps/daemon/src/routes/project/index.ts index e2cda70c054..3ded9d460b8 100644 --- a/apps/daemon/src/routes/project/index.ts +++ b/apps/daemon/src/routes/project/index.ts @@ -3402,8 +3402,16 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe } } /** @type {import('@open-design/contracts').CreateProjectResponse} */ + const createdProject = resolvedSnapshot?.ok ? getProject(db, id) ?? project : project; const body = { - project: resolvedSnapshot?.ok ? getProject(db, id) ?? project : project, + // The binding above is part of the same transaction as the project and + // seed conversation. Return that authority immediately so the Web can + // scope its very first conversation/file reads without waiting for a + // later list/detail round trip. Headerless legacy creates remain + // explicitly unbound and therefore keep the original payload shape. + project: createWorkspace.context + ? { ...createdProject, workspaceId: createWorkspace.context.workspaceId } + : createdProject, conversationId: cid, ...(resolvedSnapshot?.ok ? { appliedPluginSnapshotId: resolvedSnapshot.snapshotId } diff --git a/apps/daemon/tests/routes/workspace-projects.test.ts b/apps/daemon/tests/routes/workspace-projects.test.ts index fe9995cdd3d..0287f6b5f4a 100644 --- a/apps/daemon/tests/routes/workspace-projects.test.ts +++ b/apps/daemon/tests/routes/workspace-projects.test.ts @@ -221,6 +221,12 @@ describe('workspace project routes', () => { body: JSON.stringify({ id: projectId, name: 'Draft in A', skillId: null, designSystemId: null }), }); expect(createResp.status).toBe(200); + await expect(createResp.json()).resolves.toMatchObject({ + project: { + id: projectId, + workspaceId: workspaceA, + }, + }); const draftsA = await listInWorkspace(workspaceA, 'member-draft-a', '?view=drafts'); expect(draftsA.projects.map((item) => item.id)).toContain(projectId); diff --git a/apps/web/public/onboarding/onboarding-cloud-art.webp b/apps/web/public/onboarding/onboarding-cloud-art.webp new file mode 100644 index 00000000000..8dae0b84387 Binary files /dev/null and b/apps/web/public/onboarding/onboarding-cloud-art.webp differ diff --git a/apps/web/public/upgrade/cloud-signin-aurora.jpg b/apps/web/public/upgrade/cloud-signin-aurora.jpg new file mode 100644 index 00000000000..ddbfa03221b Binary files /dev/null and b/apps/web/public/upgrade/cloud-signin-aurora.jpg differ diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 3163a2880ed..0a1e80fdd63 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -85,7 +85,10 @@ import { listProjectRuns, type VelaLoginStatus, } from './providers/daemon'; -import { AMR_LOGIN_STATUS_EVENT } from './components/amrLoginPolling'; +import { + AMR_LOGIN_STATUS_EVENT, + amrLoginStatusEventReason, +} from './components/amrLoginPolling'; import { CollabDemoView } from './collab/CollabDemoView'; import { fetchTeamProjectsCatalog } from './collab/team-projects-catalog'; import { workspaceProjectHeaders } from './collab/workspace-identity'; @@ -132,6 +135,11 @@ import { amrBalanceGateScopesMatch, type AmrBalanceGateScope, } from './runtime/amr-balance-gate'; +import { + AMR_AUTH_RETRY_CONTINUATION_TTL_MS, + routeStillMatchesAmrAuthRetryContinuation, + type AmrAuthRetryContinuation, +} from './runtime/amr-auth-retry-continuation'; import { installFontRecovery } from './runtime/font-recovery'; import { createDesignSystemProjectFromProject, @@ -593,6 +601,12 @@ export type ProjectRouteSurfaceState = | 'materialization-failed' | 'daemon-unavailable'; +interface SettingsReturnTarget { + route: Extract; + accountGeneration: number; + identityScopeKey: string; +} + /** * The project route must never use `!activeProject` as an unbounded loading * condition. Once the initial list is complete, every absent-project path is @@ -993,6 +1007,9 @@ function AppInner() { // can't overwrite the saved state with `''` before hydration lands. const [composioConfigLoading, setComposioConfigLoading] = useState(true); const route = useRoute(); + const routeRef = useRef(route); + routeRef.current = route; + const settingsReturnTargetRef = useRef(null); const workspaceProjectView = workspaceProjectListViewForRoute(route); // Read-only mirror for the boot effect. The boot pass needs to know which // project list to seed, but it must NOT restart when that answer changes: @@ -1205,6 +1222,55 @@ function AppInner() { // globals effect below reads it; the sync effects live next to the // other AMR plumbing further down. const [amrLoginStatus, setAmrLoginStatus] = useState(null); + // Inline AMR auth can invalidate the caller identity and intentionally tear + // down ProjectView before the login poll reports success. Keep only the + // exact failed-turn continuation above that authorization lifetime; the + // fresh ProjectView must prove the same route + Workspace authority before + // it may consume this one-shot retry. + const [amrAuthRetryContinuation, setAmrAuthRetryContinuation] = + useState(null); + const amrAuthRetryContinuationRef = useRef(null); + const clearAmrAuthRetryContinuation = useCallback((expected?: AmrAuthRetryContinuation) => { + if (expected && amrAuthRetryContinuationRef.current !== expected) return; + amrAuthRetryContinuationRef.current = null; + setAmrAuthRetryContinuation(null); + }, []); + const armAmrAuthRetryContinuation = useCallback(( + input: Omit, + ) => { + const next: AmrAuthRetryContinuation = { + ...input, + accountIdAtArm: + amrLoginStatusRef.current?.loggedIn === true + ? amrLoginStatusRef.current.user?.id ?? null + : null, + createdAtMs: Date.now(), + }; + amrAuthRetryContinuationRef.current = next; + setAmrAuthRetryContinuation(next); + }, []); + const consumeAmrAuthRetryContinuation = useCallback(( + expected: AmrAuthRetryContinuation, + ): boolean => { + if (amrAuthRetryContinuationRef.current !== expected) return false; + clearAmrAuthRetryContinuation(expected); + return true; + }, [clearAmrAuthRetryContinuation]); + useEffect(() => { + if (!amrAuthRetryContinuation) return; + const remainingMs = + amrAuthRetryContinuation.createdAtMs + + AMR_AUTH_RETRY_CONTINUATION_TTL_MS + - Date.now(); + if (remainingMs <= 0) { + clearAmrAuthRetryContinuation(amrAuthRetryContinuation); + return; + } + const timeout = window.setTimeout(() => { + clearAmrAuthRetryContinuation(amrAuthRetryContinuation); + }, remainingMs); + return () => window.clearTimeout(timeout); + }, [amrAuthRetryContinuation, clearAmrAuthRetryContinuation]); // The plan that gates free-tier surfaces (today: the post-generation artifact // upsell). vela's login status is ACCOUNT-scoped, so a member whose plan is // held by the team workspace reads `free` there and used to be shown the @@ -1229,9 +1295,50 @@ function AppInner() { status: VelaLoginStatus, options: { forceModelRefresh?: boolean; restartOnSignIn?: boolean } = {}, ) => { - const wasLoggedIn = amrLoginStatusRef.current?.loggedIn === true; + const previousStatus = amrLoginStatusRef.current; + const wasLoggedIn = previousStatus?.loggedIn === true; + const pendingRetry = amrAuthRetryContinuationRef.current; + const accountChangedWhileAuthorizing = Boolean( + pendingRetry + && ( + (wasLoggedIn && status.loggedIn === false) + || ( + status.loggedIn === true + && pendingRetry.accountIdAtArm !== null + && status.user?.id !== pendingRetry.accountIdAtArm + ) + ) + ); + if (accountChangedWhileAuthorizing && pendingRetry) { + clearAmrAuthRetryContinuation(pendingRetry); + } amrLoginStatusRef.current = status; setAmrLoginStatus(status); + const currentRoute = routeRef.current; + if ( + pendingRetry + && !accountChangedWhileAuthorizing + && status.loggedIn === true + && status.user?.id + && ( + pendingRetry.accountIdAtArm === null + || pendingRetry.accountIdAtArm === status.user.id + ) + && currentRoute.kind === 'home' + && currentRoute.view === 'settings' + ) { + // The Settings page intentionally unmounts ProjectView while AMR login + // completes. Return only to the exact failed conversation carried by the + // App-owned continuation; the fresh ProjectView must still prove its + // persisted Workspace authority before ChatPane may consume the retry. + settingsReturnTargetRef.current = null; + navigate({ + kind: 'project', + projectId: pendingRetry.projectId, + conversationId: pendingRetry.conversationId, + fileName: null, + }, { replace: true }); + } if ( status.loggedIn === true && ( @@ -1241,7 +1348,7 @@ function AppInner() { ) { restartAmrPolling(); } - }, [restartAmrPolling]); + }, [clearAmrAuthRetryContinuation, restartAmrPolling]); // Tab-scope identity key, fed to WorkspaceTabsBar so it can close every open // tab down to a single fresh Home tab whenever the caller's identity @@ -1435,7 +1542,10 @@ function AppInner() { } }; void sync(); - const onStatusEvent = () => { + const onStatusEvent = (event: Event) => { + if (amrLoginStatusEventReason(event) === 'login-canceled') { + clearAmrAuthRetryContinuation(); + } void sync({}, true); }; const onReturnToApp = () => { @@ -1451,7 +1561,7 @@ function AppInner() { window.removeEventListener('focus', onReturnToApp); document.removeEventListener('visibilitychange', onReturnToApp); }; - }, [applyAmrLoginStatus, daemonLive]); + }, [applyAmrLoginStatus, clearAmrAuthRetryContinuation, daemonLive]); useEffect(() => { analytics.setUserId( @@ -2221,8 +2331,25 @@ function AppInner() { let createWorkspaceContext: WorkspaceCollabContext | null = null; let result; try { + const executionConfig = configRef.current; + const usesAmrCloud = + executionConfig.mode === 'daemon' + && executionConfig.agentId === AMR_AGENT_ID; + const isExplicitlySignedOut = + amrLoginStatusRef.current?.loggedIn === false; createWorkspaceContext = resolvedWorkspaceContextForWrite( workspaceContextStateRef.current, + { + // Local/BYOK may create without AMR Workspace authority only after + // the independent login read explicitly proves there is no AMR + // identity. An unknown or signed-in identity can still own a Team + // Workspace whose directory read is merely slow/unavailable, so + // executor selection must not silently turn that Team project into + // an unscoped Personal one. Unsupported/settled no-workspace states + // already retain their explicit compatibility behavior below. + unavailablePolicy: + !usesAmrCloud && isExplicitlySignedOut ? 'unscoped' : 'reject', + }, ); if ( input.amrGatePrecheckWitness && @@ -3162,6 +3289,40 @@ function AppInner() { ? projectRouteWorkspaceContext.context : null; projectRouteWorkspaceContextRef.current = activeProjectWorkspaceContext; + useEffect(() => { + const pending = amrAuthRetryContinuationRef.current; + if (!pending) return; + if (route.kind === 'home' && route.view === 'settings') { + // This is the one permitted non-project route: the failed-turn CTA + // deliberately opens AMR Settings and ProjectView unmounts while the + // authorization attempt is in flight. Every other route exit clears the + // continuation below. + return; + } + if (!routeStillMatchesAmrAuthRetryContinuation(pending, route)) { + clearAmrAuthRetryContinuation(pending); + return; + } + if (projectRouteWorkspaceContext.failure) { + clearAmrAuthRetryContinuation(pending); + return; + } + // A null context is the expected fail-closed refresh window. Wait for the + // fresh exact witness rather than borrowing or latching the old one. + if ( + activeProjectWorkspaceContext + && workspaceIdentityCacheKey(activeProjectWorkspaceContext) + !== pending.workspaceIdentityKey + ) { + clearAmrAuthRetryContinuation(pending); + } + }, [ + activeProjectWorkspaceContext, + amrAuthRetryContinuation, + clearAmrAuthRetryContinuation, + projectRouteWorkspaceContext.failure, + route, + ]); // Project tabs belong to the project's persisted Workspace authority, not // the shell's ambient selection. On a cold deep link the ambient context can // settle (or switch A -> B) after the exact project scope has already loaded; @@ -3366,6 +3527,7 @@ function AppInner() { opts?: { highlight?: SettingsHighlight }, ) => { if (section === 'composio' || section === 'mcpClient' || section === 'integrations') { + settingsReturnTargetRef.current = null; setIntegrationInitialTab( section === 'composio' ? 'connectors' @@ -3376,11 +3538,20 @@ function AppInner() { navigate({ kind: 'home', view: 'integrations' }); return; } + const currentRoute = routeRef.current; + settingsReturnTargetRef.current = + currentRoute.kind === 'project' && identityScopeKey !== null + ? { + route: { ...currentRoute }, + accountGeneration: currentWorkspaceAccountGeneration(), + identityScopeKey, + } + : null; setSettingsWelcome(false); setSettingsInitialSection(section); setSettingsHighlight(opts?.highlight ?? null); navigate({ kind: 'home', view: 'settings' }); - }, []); + }, [identityScopeKey]); // Entry point from the failed-run AMR nudge: open Settings on the execution // section and flag the AMR agent card for a one-shot scroll-into-view + @@ -3390,11 +3561,20 @@ function AppInner() { }, [openSettings]); const openPetSettings = useCallback(() => { + const currentRoute = routeRef.current; + settingsReturnTargetRef.current = + currentRoute.kind === 'project' && identityScopeKey !== null + ? { + route: { ...currentRoute }, + accountGeneration: currentWorkspaceAccountGeneration(), + identityScopeKey, + } + : null; setSettingsWelcome(false); setSettingsInitialSection('pet'); setSettingsHighlight(null); navigate({ kind: 'home', view: 'settings' }); - }, []); + }, [identityScopeKey]); const openMcpSettings = useCallback(() => { setIntegrationInitialTab('mcp'); @@ -3560,7 +3740,18 @@ function AppInner() { settingsDraftConfigRef.current = null; setSettingsHighlight(null); if (route.kind === 'home' && route.view === 'settings') { - navigate({ kind: 'home', view: 'home' }); + const returnTarget = settingsReturnTargetRef.current; + settingsReturnTargetRef.current = null; + const returnIdentityStillMatches = Boolean( + returnTarget + && returnTarget.accountGeneration === currentWorkspaceAccountGeneration() + && returnTarget.identityScopeKey === identityScopeKey + ); + navigate( + returnIdentityStillMatches && returnTarget + ? returnTarget.route + : { kind: 'home', view: 'home' }, + ); } }; @@ -3817,10 +4008,18 @@ function AppInner() { activeProjectWorkspaceContext, )} project={activeProject} - workspaceContextOverride={activeProjectWorkspaceContext} + workspaceContextOverride={ + activeProject.workspaceId + ? activeProjectWorkspaceContext + : undefined + } projectAuthorizationKey={ activeProjectAuthorizationKey ?? activeProject.id } + amrAuthRetryContinuation={amrAuthRetryContinuation} + onArmAmrAuthRetryContinuation={armAmrAuthRetryContinuation} + onConsumeAmrAuthRetryContinuation={consumeAmrAuthRetryContinuation} + onDiscardAmrAuthRetryContinuation={clearAmrAuthRetryContinuation} authoritativeProjectName={activeAuthoritativeProjectName} resolveAuthoritativeProjectName={resolveAuthoritativeProjectName} routeFileName={route.fileName} @@ -3961,6 +4160,11 @@ function AppInner() { diff --git a/apps/web/src/collab/useProjectWorkspaceScope.ts b/apps/web/src/collab/useProjectWorkspaceScope.ts index 7161cda785d..4ae0915f783 100644 --- a/apps/web/src/collab/useProjectWorkspaceScope.ts +++ b/apps/web/src/collab/useProjectWorkspaceScope.ts @@ -82,6 +82,27 @@ function activePersonalAdoptionWitness( return caller; } +/** + * The one run-identity branch allowed to move a truly unbound historical + * project into a Workspace. Exporting the decision lets recovery flows carry + * structured proof of this exact branch instead of guessing from an opaque + * identity cache key. + */ +export function runWorkspacePersonalAdoptionWitness( + state: ProjectWorkspaceScopeState, + caller: WorkspaceCollabContext | null, + persistedProjectWorkspaceId: string | null | undefined, +): WorkspaceCollabContext | null { + if (persistedProjectWorkspaceId != null || state.failure) return null; + if ( + state.scope?.kind !== 'unbound' + && !(state.loading && state.scope === null) + ) { + return null; + } + return activePersonalAdoptionWitness(caller); +} + /** * The workspace identity a run creation asserts to the daemon. * @@ -120,17 +141,12 @@ export function runWorkspaceIdentity( const resolved = projectWorkspaceContext(state.scope); if (resolved) return resolved; if (state.failure) return null; - const personalAdoptionWitness = activePersonalAdoptionWitness(caller); - if (state.scope?.kind === 'unbound') { - return personalAdoptionWitness; - } - if ( - state.loading - && state.scope === null - && persistedProjectWorkspaceId == null - ) { - return personalAdoptionWitness; - } + const personalAdoptionWitness = runWorkspacePersonalAdoptionWitness( + state, + caller, + persistedProjectWorkspaceId, + ); + if (personalAdoptionWitness) return personalAdoptionWitness; if ( state.loading && state.scope === null diff --git a/apps/web/src/components/AmrBalanceDialog.module.css b/apps/web/src/components/AmrBalanceDialog.module.css index 8a865f2a6d6..b5e6a90c830 100644 --- a/apps/web/src/components/AmrBalanceDialog.module.css +++ b/apps/web/src/components/AmrBalanceDialog.module.css @@ -5,11 +5,27 @@ .panel { position: relative; - width: 400px; + width: min(400px, calc(100vw - 32px)); align-items: center; text-align: center; - padding: 32px 28px 20px; + padding: 36px 32px 24px; gap: 0; + overflow: hidden; +} + +/* Bleeds to the panel's edges (negative margin cancels .panel's own padding) + * so the artwork reaches the dialog's rounded corners; .panel's overflow: + * hidden clips it to match instead of duplicating the radius here. */ +.banner { + margin: -36px -32px 24px; +} + +/* No object-fit crop — width:100% + height:auto keeps the source's native + * aspect ratio intact rather than cropping it to fit a fixed box. */ +.bannerImage { + display: block; + width: 100%; + height: auto; } .closeButton { @@ -39,29 +55,16 @@ outline-offset: 2px; } -.iconBadge { - display: grid; - place-items: center; - width: 48px; - height: 48px; - border-radius: 50%; - color: var(--accent); - background: var(--accent-tint); - /* Soft halo ring so the badge reads as a glow, not a flat chip. */ - box-shadow: 0 0 0 8px color-mix(in srgb, var(--accent-tint) 45%, transparent); - margin-bottom: 18px; -} - .title { - margin: 0 0 8px; - font-size: 17px; + margin: 0 0 10px; + font-size: 18px; font-weight: 600; letter-spacing: -0.01em; color: var(--text-strong); } .message { - margin: 0 0 18px; + margin: 0 0 24px; font-size: 13.5px; line-height: 1.6; color: var(--text-muted); @@ -71,11 +74,12 @@ .benefitsCard { width: 100%; - margin: 0 0 20px; - padding: 14px 16px; + margin: 0 0 22px; + padding: 16px 18px; text-align: start; + border: 1px solid var(--border-soft); border-radius: var(--radius); - background: var(--bg-subtle); + background: var(--bg-panel); } /* Eyebrow label naming whose advantages the list below sells. Accent, not @@ -83,10 +87,10 @@ * to the check icons and CTA without competing with the main title. */ .benefitsTitle { display: block; - margin-bottom: 10px; - font-size: 11.5px; + margin-bottom: 12px; + font-size: 11px; font-weight: 600; - letter-spacing: 0.02em; + letter-spacing: 0.04em; color: var(--accent); } @@ -96,15 +100,18 @@ padding: 0; display: flex; flex-direction: column; - gap: 10px; + gap: 12px; } .benefit { - display: flex; - /* Top-align so the check hugs the first line when an item wraps. */ - align-items: flex-start; + display: grid; + /* Fixed icon column so wrapped labels stay aligned to the text, not the + * icon — matches the row rhythm of the artifact-upgrade gate's benefit list. */ + grid-template-columns: 22px minmax(0, 1fr); + align-items: center; gap: 10px; - font-size: 13px; + font-size: 13.5px; + line-height: 1.45; color: var(--text); /* Rare dialog → a light cascade adds delight without slowing anyone down. */ opacity: 0; @@ -127,10 +134,10 @@ .benefitIcon { display: grid; place-items: center; - width: 18px; - height: 18px; + width: 22px; + height: 22px; flex: none; - border-radius: 50%; + border-radius: var(--radius-sm); color: var(--accent); background: var(--accent-tint); } @@ -138,15 +145,25 @@ .actions { display: flex; flex-direction: column; - gap: 6px; + gap: 10px; width: 100%; } .cta { width: 100%; - height: 38px; + height: 40px; font-size: 13.5px; - transition: transform 160ms cubic-bezier(0.23, 1, 0.32, 1); + box-shadow: + inset 0 1px 0 color-mix(in srgb, #fff 12%, transparent), + 0 8px 20px color-mix(in srgb, var(--accent) 20%, transparent); + transition: transform 160ms cubic-bezier(0.23, 1, 0.32, 1), box-shadow 160ms cubic-bezier(0.23, 1, 0.32, 1); +} + +.cta:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: + inset 0 1px 0 color-mix(in srgb, #fff 12%, transparent), + 0 10px 24px color-mix(in srgb, var(--accent) 26%, transparent); } .cta:active { @@ -167,18 +184,25 @@ .signInPill :global(.amr-account-control__action) { width: 100%; - height: 38px; + height: 40px; font-size: 13.5px; border-radius: var(--radius-sm); border: none; background: var(--accent); color: #fff; font-weight: 500; - transition: transform 160ms cubic-bezier(0.23, 1, 0.32, 1), background 120ms ease; + box-shadow: + inset 0 1px 0 color-mix(in srgb, #fff 12%, transparent), + 0 8px 20px color-mix(in srgb, var(--accent) 20%, transparent); + transition: transform 160ms cubic-bezier(0.23, 1, 0.32, 1), background 120ms ease, box-shadow 160ms cubic-bezier(0.23, 1, 0.32, 1); } .signInPill :global(.amr-account-control__action:hover) { background: var(--accent-hover); + transform: translateY(-1px); + box-shadow: + inset 0 1px 0 color-mix(in srgb, #fff 12%, transparent), + 0 10px 24px color-mix(in srgb, var(--accent) 26%, transparent); } .signInPill :global(.amr-account-control__action:active) { @@ -186,10 +210,14 @@ } /* Quiet text button: the bordered ghost chrome would compete with the CTA. - * Scoped under .actions to outrank the shared Button variant class. */ + * Scoped under .actions to outrank the shared Button variant class. Sits a + * size step down from the CTA so the pair reads as primary/secondary, not + * two equally-weighted buttons stacked back to back. */ .actions .later { width: 100%; + padding-top: 2px; color: var(--text-muted); + font-size: 12.5px; border-color: transparent; background: transparent; box-shadow: none; diff --git a/apps/web/src/components/AmrBalanceDialog.tsx b/apps/web/src/components/AmrBalanceDialog.tsx index 83bea044ad9..19c27e036a2 100644 --- a/apps/web/src/components/AmrBalanceDialog.tsx +++ b/apps/web/src/components/AmrBalanceDialog.tsx @@ -191,8 +191,16 @@ export function AmrBalanceDialog({ > -
- +
+

{signedOut ? t('chat.amrBalanceGate.signedOutTitle') : t('chat.amrBalanceGate.title')} diff --git a/apps/web/src/components/AssistantMessage.tsx b/apps/web/src/components/AssistantMessage.tsx index 4bd8a52c56f..65764feed36 100644 --- a/apps/web/src/components/AssistantMessage.tsx +++ b/apps/web/src/components/AssistantMessage.tsx @@ -421,9 +421,6 @@ interface Props { nextStepSkills?: SkillSummary[]; toolboxSkillNames?: Partial>; nextStepVariant?: NextStepActionsVariant; - // Quick-access pills above the next-step card — open the composer "+" menu - // on the 扩展 (plugins) or 设计百宝箱 (toolbox) flyout. - onNextStepOpenComposerPanel?: (which: 'plugins' | 'toolbox') => void; } // Props compared by reference to decide whether a memoized AssistantMessage can @@ -548,7 +545,6 @@ function AssistantMessageImpl({ nextStepSkills, toolboxSkillNames, nextStepVariant = 'default', - onNextStepOpenComposerPanel, }: Props) { const t = useT(); // Thinking text renders markdown too — its file links must route in-app @@ -1141,7 +1137,6 @@ function AssistantMessageImpl({ onShareToOpenDesign={showOpenDesignSubmission ? onShareToOpenDesign : undefined} shareToOpenDesignBusy={shareToOpenDesignBusy} variant={effectiveNextStepVariant} - onOpenComposerPanel={isLast ? onNextStepOpenComposerPanel : undefined} /> ) : null}

diff --git a/apps/web/src/components/ChatComposer.tsx b/apps/web/src/components/ChatComposer.tsx index 216aa9e19e9..24b0a3d2a43 100644 --- a/apps/web/src/components/ChatComposer.tsx +++ b/apps/web/src/components/ChatComposer.tsx @@ -545,9 +545,8 @@ export const ChatComposer = forwardRef( // behind `openDesignToolbox` until the panel subsystem is removed wholesale. const [designToolboxOpen, setDesignToolboxOpen] = useState(false); const [pluginsPanelOpen, setPluginsPanelOpen] = useState(false); - // Shared close timer for the two hover-opened standalone popovers (插件 / - // 设计百宝箱). Leaving a quick pill schedules a close; re-entering the pill - // or the popup cancels it, so the pointer can travel pill → popup freely. + // Shared close timer for the two legacy standalone popovers (插件 / + // 设计百宝箱). const panelCloseTimerRef = useRef | null>(null); function cancelComposerPanelClose() { if (panelCloseTimerRef.current) { @@ -566,15 +565,21 @@ export const ChatComposer = forwardRef( useEffect(() => () => { if (panelCloseTimerRef.current) clearTimeout(panelCloseTimerRef.current); }, []); - // The quick pill a standalone popover was opened from. Both popovers move - // focus inside themselves (the plugins pane autofocuses its search box), so - // a dismissal has to hand focus back — the pill lives in the host above the - // composer and is the only control still mounted afterwards. + // The control a standalone popover was opened from. Explicit openers are + // preferred, but imperative callers that run synchronously from a click can + // omit one: capture the active control before focus moves into the panel. const panelOpenerRef = useRef(null); + function resolveStandalonePanelOpener(opener?: HTMLElement | null): HTMLElement | null { + if (opener) return opener; + const activeElement = document.activeElement; + return activeElement instanceof HTMLElement && activeElement !== document.body + ? activeElement + : null; + } /** Close whichever standalone popover is open BECAUSE THE USER DISMISSED IT - * (Escape, backdrop) and return focus to the pill that opened it. Paths + * (Escape, backdrop) and return focus to the control that opened it. Paths * where the user picked something keep the plain setters: the composer - * takes focus there, and pulling it back to the pill would fight that. */ + * takes focus there, and pulling it back to the opener would fight that. */ function dismissStandalonePanels() { cancelComposerPanelClose(); setPluginsPanelOpen(false); @@ -603,8 +608,8 @@ export const ChatComposer = forwardRef( document.addEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey); }, [openStandalonePanel]); - // External "+"-menu open request (next-step quick pills) — nonce-keyed so - // every pill click re-opens even after the menu was dismissed. + // External "+"-menu open request — nonce-keyed so every request re-opens + // even after the menu was dismissed. const [plusMenuOpenRequest, setPlusMenuOpenRequest] = useState< { nonce: number; submenu?: PlusMenuSubmenu } | null >(null); @@ -1206,7 +1211,7 @@ export const ChatComposer = forwardRef( openDesignToolbox: (opener?: HTMLElement | null) => { cancelComposerPanelClose(); setComposerEngaged(true); - panelOpenerRef.current = opener ?? null; + panelOpenerRef.current = resolveStandalonePanelOpener(opener); // The two popovers share one anchor spot — opening one closes the // other so hover-switching between the pills swaps panels. setPluginsPanelOpen(false); @@ -1215,7 +1220,7 @@ export const ChatComposer = forwardRef( openPluginsPanel: (opener?: HTMLElement | null) => { cancelComposerPanelClose(); setComposerEngaged(true); - panelOpenerRef.current = opener ?? null; + panelOpenerRef.current = resolveStandalonePanelOpener(opener); setDesignToolboxOpen(false); setPluginsPanelOpen(true); }, @@ -3252,10 +3257,53 @@ export const ChatComposer = forwardRef( trackComposerBar({ element: 'design_system_open' }); openDesignSystemPicker(); } : undefined} - // No toolboxLabel / renderToolbox, and hidePluginsRow: 插件 and - // 设计百宝箱 left this menu — the quick pills above the input - // open their standalone popovers instead. - hidePluginsRow + // 插件 and 设计百宝箱 live inside the "+" menu (right below + // 工作目录) as hover-expand submenus. The toolbox flyout reuses + // the same DesignToolboxPanel the standalone popover renders. + toolboxLabel={t('chat.designToolbox.title')} + renderToolbox={(close) => ( + skill.id)} + activePluginId={activeAppliedPlugin?.pluginId ?? pinnedPluginId ?? null} + activeMcpServerIds={stagedMcpServers.map((server) => server.id)} + activeConnectorIds={stagedConnectors.map((connector) => connector.id)} + activeFilePaths={staged.map((item) => item.path)} + onOpened={() => trackDesignToolbox({ element: 'design_toolbox_open' })} + onPickAction={(action) => { + trackDesignToolbox({ + element: 'design_toolbox_action', + toolbox_action_id: action.id, + }); + applyDesignToolboxAction(action); + close(); + }} + onPickSkill={(skill) => { + trackDesignToolbox({ + element: 'design_toolbox_resource', + resource_kind: 'skill', + resource_id: skill.id, + }); + applyDesignToolboxSkill(skill); + close(); + }} + onPickResource={(resource) => { + trackDesignToolbox({ + element: 'design_toolbox_resource', + ...designToolboxResourceTracking(resource), + }); + applyDesignToolboxResource(resource); + close(); + }} + /> + )} /> {/* #5517: the design-system picker sits inline in the composer's icon row (palette icon) instead of the staged-context bar. */} diff --git a/apps/web/src/components/ChatPane.tsx b/apps/web/src/components/ChatPane.tsx index 25b855b4522..10e5f6bd1e8 100644 --- a/apps/web/src/components/ChatPane.tsx +++ b/apps/web/src/components/ChatPane.tsx @@ -66,9 +66,6 @@ import { DESIGN_SYSTEM_NEXT_STEP_ACTIONS, type NextStepActionsVariant, } from './NextStepActions'; -// Shared pill look for the 扩展 / 设计百宝箱 quick pills, which render above -// the composer input (moved out of the next-step card). -import nextStepStyles from './NextStepActions.module.css'; import { AmrGuidance } from './AmrGuidance'; import { AmrLoginPill } from './AmrLoginPill'; import { @@ -85,12 +82,16 @@ import { type VelaLoginStatus, } from '../providers/daemon'; import { RESUME_CONTINUE_PROMPT } from '../runtime/resume'; +import { + canConsumeAmrAuthRetryContinuation, + type AmrAuthRetryContinuation, + type AmrAuthRetryPersonalAdoptionWitness, +} from '../runtime/amr-auth-retry-continuation'; import { ChatComposer, type ChatComposerHandle, type ChatSendOutcome, type ChatSendMeta, - type ComposerStandalonePanel, } from './ChatComposer'; import type { PlaceholderScenario } from './home-hero/placeholderScenarios'; import { listDesignArtifactCandidates } from './design-files/designArtifacts'; @@ -548,6 +549,19 @@ interface Props { meta?: ChatSendMeta, ) => ChatSendOutcome | Promise; onRetry?: (assistantMessage: ChatMessage) => void; + amrAuthRetryContinuation?: AmrAuthRetryContinuation | null; + amrAuthRetryMountId?: string; + amrAuthRetryWorkspaceIdentityKey?: string; + amrAuthRetryPersonalAdoptionWitness?: AmrAuthRetryPersonalAdoptionWitness | null; + onArmAmrAuthRetryContinuation?: ( + continuation: Omit, + ) => void; + onConsumeAmrAuthRetryContinuation?: ( + continuation: AmrAuthRetryContinuation, + ) => boolean; + onDiscardAmrAuthRetryContinuation?: ( + continuation: AmrAuthRetryContinuation, + ) => void; onResumeRun?: (assistantMessage: ChatMessage) => void; onStop: () => void; // Skills available for @-mention assembly. ProjectView filters out the @@ -860,6 +874,13 @@ export function ChatPane({ onDeleteComment, onSend, onRetry, + amrAuthRetryContinuation = null, + amrAuthRetryMountId, + amrAuthRetryWorkspaceIdentityKey, + amrAuthRetryPersonalAdoptionWitness = null, + onArmAmrAuthRetryContinuation, + onConsumeAmrAuthRetryContinuation, + onDiscardAmrAuthRetryContinuation, onResumeRun, onStop, onRemoveQueuedSend, @@ -966,28 +987,12 @@ export function ChatPane({ const amrProfile = config?.agentCliEnv?.amr?.[AMR_PROFILE_ENV_KEY] ?? null; const [inlineAmrLoginStatus, setInlineAmrLoginStatus] = useState(null); + const amrAuthRetrySignedOutWitnessRef = + useRef(null); const logRef = useRef(null); - // Guards the inline AMR sign-in card so a successful login auto-retries the - // failed run exactly once (the pill's onStatusChange fires loggedIn on every - // poll). Keyed by the failed assistant's id. - const amrAuthRetriedRef = useRef(null); - // Tracks the last observed AMR login state so we retry only on a real - // signed-out -> signed-in transition. Without this, a run that keeps failing - // AMR_AUTH_REQUIRED while /status already reports signed-in would auto-retry - // forever (each retry is a new assistant id, so the id guard alone never - // converges). - const amrAuthPrevLoggedInRef = useRef(undefined); const chatLogScrollIdleTimerRef = useRef(null); const historyWrapRef = useRef(null); const composerRef = useRef(null); - // The 插件 / 设计百宝箱 quick pills. The popovers they open live inside - // ChatComposer, so the pills need both a way to report their expanded state - // and a stable identity for the popover to return focus to. - const quickPillRefs = { - plugins: useRef(null), - toolbox: useRef(null), - }; - const [openComposerPanel, setOpenComposerPanel] = useState(null); const composerSlotRef = useRef(null); const composerLayerRef = useRef(null); const pinnedTodoRef = useRef(null); @@ -1090,40 +1095,6 @@ export function ChatPane({ const handleToolboxAction = useCallback((id: DesignToolboxActionId) => { composerRef.current?.applyDesignToolboxAction(id); }, []); - // Quick pills above the composer input: 插件 and 设计百宝箱 open their own - // standalone popovers — the "+" menu no longer carries either row. They - // open on hover (with a short intent delay so a pointer merely passing - // through to the input doesn't pop a panel) as well as on click; leaving - // the pill schedules a close that hovering the popup cancels. - const pillHoverTimerRef = useRef | null>(null); - const handleNextStepOpenComposerPanel = useCallback((which: 'plugins' | 'toolbox') => { - if (pillHoverTimerRef.current) { - clearTimeout(pillHoverTimerRef.current); - pillHoverTimerRef.current = null; - } - // Hand the pill down as the popover's return-focus target: it opens a - // surface that takes focus, and it is the control the user came from. - const opener = quickPillRefs[which].current; - if (which === 'toolbox') composerRef.current?.openDesignToolbox(opener); - else composerRef.current?.openPluginsPanel(opener); - }, []); - const handleQuickPillHoverEnter = useCallback((which: 'plugins' | 'toolbox') => { - if (pillHoverTimerRef.current) clearTimeout(pillHoverTimerRef.current); - pillHoverTimerRef.current = setTimeout(() => { - pillHoverTimerRef.current = null; - handleNextStepOpenComposerPanel(which); - }, 140); - }, [handleNextStepOpenComposerPanel]); - const handleQuickPillHoverLeave = useCallback(() => { - if (pillHoverTimerRef.current) { - clearTimeout(pillHoverTimerRef.current); - pillHoverTimerRef.current = null; - } - composerRef.current?.scheduleComposerPanelClose(); - }, []); - useEffect(() => () => { - if (pillHoverTimerRef.current) clearTimeout(pillHoverTimerRef.current); - }, []); const handleNextStepPromptAction = useCallback(( prompt: string, options?: { sessionMode?: ChatSessionMode }, @@ -1310,27 +1281,133 @@ export function ChatPane({ const hasInlineAmrAuthorizeFailure = Boolean( retryAssistant && onRetry && runFailureUi?.primaryAction === 'authorize', ); + useEffect(() => { + if ( + !amrAuthRetryContinuation + || !onDiscardAmrAuthRetryContinuation + || loading + || !projectId + || !activeConversationId + || messagesConversationId !== activeConversationId + ) { + return; + } + const personalAdoptionAuthorityTransition = + amrAuthRetryContinuation.workspaceIdentityKey === 'none' + && amrAuthRetryContinuation.originMountId === amrAuthRetryMountId + && amrAuthRetryPersonalAdoptionWitness?.workspaceIdentityKey + === amrAuthRetryWorkspaceIdentityKey; + const mismatched = + amrAuthRetryContinuation.projectId !== projectId + || amrAuthRetryContinuation.conversationId !== activeConversationId + || amrAuthRetryContinuation.assistantId !== retryAssistant?.id + || ( + amrAuthRetryWorkspaceIdentityKey !== undefined + && amrAuthRetryContinuation.workspaceIdentityKey + !== amrAuthRetryWorkspaceIdentityKey + && !personalAdoptionAuthorityTransition + ); + if (mismatched) { + onDiscardAmrAuthRetryContinuation(amrAuthRetryContinuation); + } + }, [ + activeConversationId, + amrAuthRetryContinuation, + amrAuthRetryMountId, + amrAuthRetryPersonalAdoptionWitness, + amrAuthRetryWorkspaceIdentityKey, + loading, + messagesConversationId, + onDiscardAmrAuthRetryContinuation, + projectId, + retryAssistant?.id, + ]); + const consumeAmrAuthRetryIfAuthorized = useCallback((status: VelaLoginStatus | null) => { + if (status?.loggedIn === false) { + if ( + amrAuthRetryContinuation + && amrAuthRetryContinuation.workspaceIdentityKey === 'none' + && amrAuthRetryContinuation.originMountId === amrAuthRetryMountId + ) { + amrAuthRetrySignedOutWitnessRef.current = amrAuthRetryContinuation; + } + return; + } + if ( + status?.loggedIn !== true + || !amrAuthRetryContinuation + || !amrAuthRetryMountId + || !amrAuthRetryWorkspaceIdentityKey + || !projectId + || !activeConversationId + || !retryAssistant + || !onRetry + || !onConsumeAmrAuthRetryContinuation + ) { + return; + } + const originMountObservedSignedOut = + amrAuthRetrySignedOutWitnessRef.current === amrAuthRetryContinuation; + // Every continuation is consumed against the account identity returned by + // this exact status observation. An ambient shell snapshot can belong to a + // prior account during sign-out/sign-in transitions. + const loggedInAccountId = status.user?.id ?? null; + if (!canConsumeAmrAuthRetryContinuation(amrAuthRetryContinuation, { + projectId, + conversationId: activeConversationId, + assistantId: retryAssistant.id, + workspaceIdentityKey: amrAuthRetryWorkspaceIdentityKey, + mountId: amrAuthRetryMountId, + loggedInAccountId, + nowMs: Date.now(), + originMountObservedSignedOut, + personalAdoptionWitness: amrAuthRetryPersonalAdoptionWitness, + })) { + return; + } + if (onConsumeAmrAuthRetryContinuation(amrAuthRetryContinuation)) { + amrAuthRetrySignedOutWitnessRef.current = null; + onRetry(retryAssistant); + } + }, [ + activeConversationId, + amrAuthRetryContinuation, + amrAuthRetryMountId, + amrAuthRetryPersonalAdoptionWitness, + amrAuthRetryWorkspaceIdentityKey, + onConsumeAmrAuthRetryContinuation, + onRetry, + projectId, + retryAssistant, + ]); + useEffect(() => { + if (!amrAuthRetryContinuation || inlineAmrLoginStatus?.loggedIn !== true) return; + // A Settings handoff remounts the whole project surface, so there is no + // inline AmrLoginPill callback to drive consumption. The fresh pane's own + // status read may request the one-shot retry; the common guard above still + // requires the exact project, conversation, failed assistant, account, + // fresh mount and Workspace authority. + consumeAmrAuthRetryIfAuthorized(inlineAmrLoginStatus); + }, [ + amrAuthRetryContinuation, + consumeAmrAuthRetryIfAuthorized, + inlineAmrLoginStatus, + ]); + useEffect(() => { + if ( + amrAuthRetrySignedOutWitnessRef.current + && amrAuthRetrySignedOutWitnessRef.current !== amrAuthRetryContinuation + ) { + amrAuthRetrySignedOutWitnessRef.current = null; + } + }, [amrAuthRetryContinuation]); useEffect(() => { if (!hasInlineAmrAuthorizeFailure || !retryAssistant || !onRetry) return; let stopped = false; const retryIfSignedIn = async () => { const next = await refreshInlineAmrLoginStatus(); if (stopped) return; - // Retry only on a real signed-out -> signed-in transition. A null/unknown - // status is NOT treated as signed-out, so it can't fabricate a transition; - // and once signed-in we never retry again until an explicit signed-out is - // seen. Otherwise a run that keeps failing auth while /status reports - // signed-in would retry forever (each retry is a new assistant id). - if (next?.loggedIn === true) { - const wasSignedOut = amrAuthPrevLoggedInRef.current === false; - amrAuthPrevLoggedInRef.current = true; - if (wasSignedOut && amrAuthRetriedRef.current !== retryAssistant.id) { - amrAuthRetriedRef.current = retryAssistant.id; - onRetry(retryAssistant); - } - } else if (next && next.loggedIn === false) { - amrAuthPrevLoggedInRef.current = false; - } + consumeAmrAuthRetryIfAuthorized(next); }; void retryIfSignedIn(); const interval = window.setInterval(() => { @@ -1341,6 +1418,7 @@ export function ChatPane({ window.clearInterval(interval); }; }, [ + consumeAmrAuthRetryIfAuthorized, hasInlineAmrAuthorizeFailure, onRetry, refreshInlineAmrLoginStatus, @@ -2167,45 +2245,8 @@ export function ChatPane({ const composerNode = ( <> - {/* 扩展 / 设计百宝箱 quick pills: moved out of the next-step card so - they sit directly above the composer input, and travel with the - composer into its portaled fixed layer. Hidden for viewer-only - panes where the "+" menu they open is off-limits anyway. */} - {viewerOnly ? null : ( -
- - -
- )} + {/* 插件 / 设计百宝箱 live inside the composer's "+" menu (below 工作目录, + hover to expand); they no longer sit as quick pills above the input. */} { - amrAuthPrevLoggedInRef.current = false; - }} - onStatusChange={(loginStatus) => { - // Retry only on a real signed-out -> signed-in - // transition (see amrAuthPrevLoggedInRef). - if (loginStatus?.loggedIn === true) { - const wasSignedOut = - amrAuthPrevLoggedInRef.current === false; - amrAuthPrevLoggedInRef.current = true; - if ( - wasSignedOut && - amrAuthRetriedRef.current !== retryAssistant.id - ) { - amrAuthRetriedRef.current = retryAssistant.id; - onRetry(retryAssistant); - } - } else if ( - loginStatus && - loginStatus.loggedIn === false + if ( + projectId + && activeConversationId + && amrAuthRetryMountId + && amrAuthRetryWorkspaceIdentityKey + && onArmAmrAuthRetryContinuation ) { - amrAuthPrevLoggedInRef.current = false; + onArmAmrAuthRetryContinuation({ + projectId, + conversationId: activeConversationId, + assistantId: retryAssistant.id, + workspaceIdentityKey: amrAuthRetryWorkspaceIdentityKey, + originMountId: amrAuthRetryMountId, + }); } }} + onStatusChange={(loginStatus) => { + consumeAmrAuthRetryIfAuthorized(loginStatus); + }} /> ) : runFailureUi.primaryAction === 'launch-terminal-auth' ? ( -
- - {signing ? : } - - {t('entry.cloudCalloutTitle')} -
{signing ? ( <> + {headBadge}

{t('settings.amrSigningIn')}

{status?.activationUrl ? (
@@ -259,9 +232,15 @@ export function CloudSignInTip() { ) : state === 'error' ? ( -

{t('settings.amrLoginErrorCompact')}

+ <> + {headBadge} +

{t('settings.amrLoginErrorCompact')}

+ ) : ( -

{t('entry.cloudCalloutBody')}

+ <> +

{t('entry.cloudCalloutBody')}

+ {headBadge} + )} ); diff --git a/apps/web/src/components/ComposerPlusMenu.tsx b/apps/web/src/components/ComposerPlusMenu.tsx index 31d395a4e17..d25ec7af298 100644 --- a/apps/web/src/components/ComposerPlusMenu.tsx +++ b/apps/web/src/components/ComposerPlusMenu.tsx @@ -706,6 +706,94 @@ export function ComposerPlusMenu({
) : null} + {hidePluginsRow ? null : ( + openSubmenu('plugins', row)} + onClose={scheduleCloseSubmenu} + flyoutClassName={ + filteredPlugins.length > 0 ? 'plus-menu__flyout--plugins' : undefined + } + > +
+
+
+ + handleQueryChange(event.target.value)} + placeholder={t('entry.navPlugins')} + aria-label={t('entry.navPlugins')} + /> +
+
+ {filteredPlugins.length === 0 ? ( +
{t('homeHero.noPlugins')}
+ ) : ( + filteredPlugins.map((plugin) => ( + + )) + )} +
+ {onAddPlugin ? ( + <> +
+ + + ) : null} +
+ {hoveredPlugin ? ( + + ) : null} +
+ + )} + {renderToolbox ? ( + openSubmenu('toolbox', row)} + onClose={scheduleCloseSubmenu} + > + {renderToolbox(close)} + + ) : null} {LIBRARY_UI_VISIBLE && onSelectFromLibrary ? ( - )) - )} -
- {onAddPlugin ? ( - <> -
- - - ) : null} -
- {hoveredPlugin ? ( - - ) : null} - -
- )} ) : null} - {renderToolbox ? ( - openSubmenu('toolbox', row)} - onClose={scheduleCloseSubmenu} - > - {renderToolbox(close)} - - ) : null} , document.body, ) : null} diff --git a/apps/web/src/components/DesignFilesPanel.tsx b/apps/web/src/components/DesignFilesPanel.tsx index b7f38c1c5d7..5693099725c 100644 --- a/apps/web/src/components/DesignFilesPanel.tsx +++ b/apps/web/src/components/DesignFilesPanel.tsx @@ -47,7 +47,7 @@ export interface DesignFilesNavState { interface Props { projectId: string; filesRefreshKey?: number; - /** Read-only viewer of a team-shared project: withholds create/upload actions. */ + /** Read-only viewer of a team-shared project: disables project mutations. */ viewerOnly?: boolean; /** * True while a non-owner member's local mirror has not yet caught up to the @@ -1160,25 +1160,6 @@ export function DesignFilesPanel({ {t('designFiles.library.label')} ) : null} - - {/* `onPaste` is a historical prop name — the action creates a new blank - Markdown document, so it is labelled for what it does. */} - - {onCreateDesignSystemFromProject || onDuplicateProject ? (
+ {/* `onPaste` is a historical prop name — the action creates + a new blank Markdown document. */} + + {onOpenBrowser ? ( - {amrLoginError ? ( - - {amrLoginError} - - ) : null} - {/* Manual device-auth fallback, mirroring Settings' AmrLoginPill: - vela auto-opens the browser, but when that fails silently (e.g. - corp-managed hosts) the pending login otherwise looks like a - dead button — surface the activation link the status poll - already carries. */} - {cloudBusy && amrStatus?.activationUrl ? ( -
- - {amrStatus.browserOpenFailed - ? t('settings.amrActivationBrowserFailed') - : t('settings.amrActivationHint')} - - -
- ) : null} - {cloudBusy ? ( +
+
+

{t('settings.onboardingCloudTitle')}

+

{t('settings.onboardingCloudBody')}

- ) : ( -
- - - {t('settings.onboardingCloudOr')} + onAgentChange('amr'); + recordAmrEntry( + analytics.track, + 'onboarding_amr_sign_in_continue', + new Date(), + { + metricsConsent: config.telemetry?.metrics === true, + reuseExistingFrom: ['onboarding_amr_card'], + }, + ); + setStep((current) => current + 1); + return; + } + void handleCloudSignIn(); + }} + disabled={cloudBusy || amrLoginCancelPending || amrStatusResolving} + aria-busy={cloudBusy || amrStatusResolving ? true : undefined} + > + + + {cloudBusy + ? t('settings.amrSigningIn') + : amrStatusResolving + ? t('common.loading') + : amrSignedIn + ? t('settings.onboardingCloudContinue') + : t('settings.onboardingCloudSignIn')} + + + {amrLoginError ? ( + + {amrLoginError} + ) : null} + {/* Manual device-auth fallback, mirroring Settings' AmrLoginPill: + vela auto-opens the browser, but when that fails silently (e.g. + corp-managed hosts) the pending login otherwise looks like a + dead button — surface the activation link the status poll + already carries. */} + {cloudBusy && amrStatus?.activationUrl && !activationHintClosed ? ( +
+ + {amrStatus.browserOpenFailed + ? t('settings.amrActivationBrowserFailed') + : t('settings.amrActivationHint')} + +
+ + {t('settings.amrActivationOpen')} + + +
+
+ ) : null} + {cloudBusy ? ( -
- )} + ) : ( +
+ + + {t('settings.onboardingCloudOr')} + + +
+ )} +
+
+ + + © {new Date().getFullYear()} Open Design · {t('settings.onboardingCloudRights')} + +
+
+ -
- © {new Date().getFullYear()} Open Design · {t('settings.onboardingCloudRights')} -
); } diff --git a/apps/web/src/components/FileViewer.tsx b/apps/web/src/components/FileViewer.tsx index 59906aead7a..43021f0a388 100644 --- a/apps/web/src/components/FileViewer.tsx +++ b/apps/web/src/components/FileViewer.tsx @@ -7393,6 +7393,7 @@ function HtmlViewer({ const [templateSaveError, setTemplateSaveError] = useState(null); const [deployment, setDeployment] = useState(null); const [deploymentsByProvider, setDeploymentsByProvider] = useState>>({}); + const deploymentsLoadSeqRef = useRef(0); const [deployModalOpen, setDeployModalOpen] = useState(false); const [deployModalIntent, setDeployModalIntent] = useState<'deploy' | 'social-share'>('deploy'); const closeDeployModal = useCallback(() => { @@ -8681,13 +8682,14 @@ function HtmlViewer({ ]); useEffect(() => { + const requestSeq = ++deploymentsLoadSeqRef.current; let cancelled = false; setDeployResult(null); setDeployError(null); setCopiedDeployLink(null); setDeployPhase('idle'); void fetchProjectDeployments(projectId, workspaceContext).then((items) => { - if (cancelled) return; + if (cancelled || deploymentsLoadSeqRef.current !== requestSeq) return; const nextDeploymentsByProvider = deploymentMapForCurrentFile(items); const current = nextDeploymentsByProvider[deployProviderId] ?? null; setDeploymentsByProvider(nextDeploymentsByProvider); @@ -8699,6 +8701,28 @@ function HtmlViewer({ }; }, [projectId, file.name, deployProviderId, workspaceContext]); + // A retained HtmlViewer stays mounted while the user visits Design Files and + // comes back, so its initial deployment snapshot can legitimately be older + // than the Share/Export popover. Refresh on demand when that popover opens; + // the shared sequence fence prevents an older identity-load response from + // overwriting this newer snapshot. + useEffect(() => { + if (!deployMenuOpen) return; + const requestSeq = ++deploymentsLoadSeqRef.current; + let cancelled = false; + void fetchProjectDeployments(projectId, workspaceContext).then((items) => { + if (cancelled || deploymentsLoadSeqRef.current !== requestSeq) return; + const nextDeploymentsByProvider = deploymentMapForCurrentFile(items); + const current = nextDeploymentsByProvider[deployProviderId] ?? null; + setDeploymentsByProvider(nextDeploymentsByProvider); + setDeployment(current ?? null); + setDeployResult(current ?? null); + }); + return () => { + cancelled = true; + }; + }, [deployMenuOpen, projectId, file.name, deployProviderId, workspaceContext]); + const routingHtmlSource = source ?? routingSource ?? lastGoodSourceForRoutingRef.current; const passiveLargeHtmlPreview = shouldDeferPassivePreviewSource && source === null; // Detect deck-shaped HTML even when the project's skill didn't declare diff --git a/apps/web/src/components/Icon.tsx b/apps/web/src/components/Icon.tsx index aff4f1c953b..3eec97d58bc 100644 --- a/apps/web/src/components/Icon.tsx +++ b/apps/web/src/components/Icon.tsx @@ -48,10 +48,12 @@ export type IconName = | 'import' | 'info' | 'kanban' + | 'key' | 'layers-filled' | 'languages' | 'layout' | 'lightbulb' + | 'arrow-right' | 'link' | 'lock' | 'mail' @@ -79,6 +81,7 @@ export type IconName = | 'present' | 'refresh' | 'reload' + | 'robot' | 'search' | 'send' | 'settings' @@ -94,6 +97,7 @@ export type IconName = | 'terminal' | 'thumbs-down' | 'thumbs-up' + | 'translate' | 'tweaks' | 'undo' | 'redo' @@ -118,6 +122,7 @@ interface Props extends Omit, 'name'> { const REMIX_ICON: Partial> = { 'alert-triangle': 'error-warning-line', 'arrow-left': 'arrow-left-line', + 'arrow-right': 'arrow-right-line', 'arrow-up': 'arrow-up-line', artboard: 'artboard-2-line', attach: 'attachment-2', @@ -161,6 +166,7 @@ const REMIX_ICON: Partial> = { info: 'information-line', 'integrations-filled': 'puzzle-fill', kanban: 'kanban-view', + key: 'key-2-line', languages: 'translate-2', 'layers-filled': 'stack-fill', layout: 'layout-line', @@ -189,6 +195,7 @@ const REMIX_ICON: Partial> = { puzzle: 'puzzle-line', refresh: 'refresh-line', reload: 'reset-left-line', + robot: 'robot-2-line', search: 'search-line', send: 'send-plane-2-line', settings: 'settings-3-line', @@ -206,6 +213,7 @@ const REMIX_ICON: Partial> = { 'thumbs-down': 'thumb-down-line', 'thumbs-up': 'thumb-up-line', trash: 'delete-bin-line', + translate: 'translate', tweaks: 'sound-module-line', upload: 'upload-2-line', users: 'group-line', diff --git a/apps/web/src/components/LanguageMenu.tsx b/apps/web/src/components/LanguageMenu.tsx index ce30e6a9a1f..663400ed376 100644 --- a/apps/web/src/components/LanguageMenu.tsx +++ b/apps/web/src/components/LanguageMenu.tsx @@ -52,7 +52,7 @@ export function LanguageMenu({ onClick={() => setOpen((v) => !v)} title={LOCALE_LABEL[locale]} > - + {compact ? null : ( <> {LOCALE_LABEL[locale]} diff --git a/apps/web/src/components/MessageCenter.module.css b/apps/web/src/components/MessageCenter.module.css index a7507ba43da..a994d39fc68 100644 --- a/apps/web/src/components/MessageCenter.module.css +++ b/apps/web/src/components/MessageCenter.module.css @@ -78,31 +78,6 @@ line-height: 1.45; } -.close { - width: 32px; - height: 32px; - flex: 0 0 32px; - display: inline-flex; - align-items: center; - justify-content: center; - border: 1px solid var(--border-soft); - border-radius: var(--radius-sm); - background: var(--bg-subtle); - color: var(--text); - cursor: pointer; - transition: - background 140ms cubic-bezier(0.23, 1, 0.32, 1), - border-color 140ms cubic-bezier(0.23, 1, 0.32, 1), - color 140ms cubic-bezier(0.23, 1, 0.32, 1); -} - -.close:hover { - border-color: var(--border); - background: var(--bg-muted); - color: var(--text-strong); -} - -.close:focus-visible, .filter:focus-visible, .markAll:focus-visible, .syncStatus button:focus-visible, @@ -252,29 +227,54 @@ .item { flex: 0 0 auto; position: relative; - border: 1px solid var(--border-soft); - border-radius: var(--radius); - background: var(--bg); - overflow: hidden; } -.itemExpanded { - border-color: color-mix(in srgb, var(--accent) 28%, var(--border)); - background: color-mix(in srgb, var(--accent) 5%, var(--bg)); +.item:not(:last-child)::after { + content: ''; + position: absolute; + left: 3px; + right: 13px; + bottom: -4px; + height: 1px; + background: var(--border-soft); } .itemUnread { - border-color: var(--border-soft); box-shadow: inset 2px 0 0 var(--accent); } +.itemViewHint { + position: absolute; + top: 10px; + right: 13px; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 6px 12px; + border-radius: var(--radius-pill, 999px); + background: var(--accent); + color: #00ff08; + font-size: 12px; + font-weight: 700; + line-height: 1.3; + opacity: 0; + pointer-events: none; + transition: opacity 120ms; +} + +.item:hover .itemViewHint { + opacity: 1; +} + .itemSummary { width: 100%; min-height: 84px; display: flex; flex-direction: column; + align-items: flex-start; + justify-content: flex-start; gap: 6px; - padding: 12px 13px 10px 15px; + padding: 12px 13px 10px 3px; border: 0; background: transparent; color: inherit; @@ -289,7 +289,7 @@ .itemMeta { display: flex; align-items: center; - justify-content: space-between; + justify-content: flex-start; gap: 12px; color: var(--text-muted); font-size: 11.5px; @@ -338,8 +338,8 @@ .itemActions { display: flex; align-items: center; - justify-content: flex-end; - padding: 0 13px 12px 15px; + justify-content: flex-start; + padding: 0 13px 12px 3px; } .itemActions .primaryAction { diff --git a/apps/web/src/components/MessageCenter.tsx b/apps/web/src/components/MessageCenter.tsx index c595ee41707..f9f9659bb29 100644 --- a/apps/web/src/components/MessageCenter.tsx +++ b/apps/web/src/components/MessageCenter.tsx @@ -248,7 +248,7 @@ export function MessageCenter({ {unreadCount > 0 ? {unreadBadgeLabel(unreadCount)} : null} } {open ? createPortal(