diff --git a/frontend/src/lib/components/copilot/AskAiButton.svelte b/frontend/src/lib/components/copilot/AskAiButton.svelte index a141ee3125..95ff47fb52 100644 --- a/frontend/src/lib/components/copilot/AskAiButton.svelte +++ b/frontend/src/lib/components/copilot/AskAiButton.svelte @@ -5,6 +5,7 @@ import { AIBtnClasses } from './chat/AIButtonStyle' import { prefersSessionHandoff } from './chat/global/gate' import { startSessionWithPrompt } from '$lib/components/sessions/sessionSwitch.svelte' + import { userStore } from '$lib/stores' interface Props { label?: string initialInput?: string @@ -15,7 +16,7 @@ // The label stays short ("Ask AI") for the search bar's inline row; the hover // text is where "new AI session" fits. - const handsOffToSession = $derived(prefersSessionHandoff()) + const handsOffToSession = $derived(prefersSessionHandoff($userStore?.operator)) export function onClick() { // No item to preview here — this carries a question, not a target — so the diff --git a/frontend/src/lib/components/copilot/chat/global/gate.ts b/frontend/src/lib/components/copilot/chat/global/gate.ts index e389283d8a..506f230027 100644 --- a/frontend/src/lib/components/copilot/chat/global/gate.ts +++ b/frontend/src/lib/components/copilot/chat/global/gate.ts @@ -25,13 +25,35 @@ export function isGlobalAiEnabled(): boolean { } } +const OPERATOR_SESSIONS_KEY = 'wm_operator_ai_sessions' + +/** + * Operators get AI sessions only in a browser that opted in, until the operator experience + * has been tested; everywhere else they keep the docked chat and `/sessions` refuses them. + * Set with `localStorage.setItem('wm_operator_ai_sessions', '1')` and reload. To ship it, + * delete this and `sessionsAllowedFor`, and drop the `isOperator` argument from every caller. + */ +function operatorSessionsEnabled(): boolean { + if (typeof localStorage === 'undefined') return false + try { + return localStorage.getItem(OPERATOR_SESSIONS_KEY) === '1' + } catch { + return false + } +} + +/** Whether this user's role may use AI sessions at all. */ +export function sessionsAllowedFor(isOperator: boolean | undefined): boolean { + return !isOperator || operatorSessionsEnabled() +} + /** * Whether an AI entry point hands off to a session instead of driving the docked * chat. Deliberately the same condition as the root layout's `disableAi`, so a * caller falling back on `false` always has a mounted pane to fall back to. */ -export function prefersSessionHandoff(): boolean { - return isGlobalAiEnabled() +export function prefersSessionHandoff(isOperator: boolean | undefined): boolean { + return isGlobalAiEnabled() && sessionsAllowedFor(isOperator) } /** Clear the opt-out, then hard-reload so every gated site re-reads it. */ diff --git a/frontend/src/lib/components/flows/FlowEditor.svelte b/frontend/src/lib/components/flows/FlowEditor.svelte index 9d98d0535e..a9c3a40e8a 100644 --- a/frontend/src/lib/components/flows/FlowEditor.svelte +++ b/frontend/src/lib/components/flows/FlowEditor.svelte @@ -40,9 +40,14 @@ import FlowPanelPlacementPicker from './common/FlowPanelPlacementPicker.svelte' import { prefersSessionHandoff } from '../copilot/chat/global/gate' import { openSourceInSession } from '$lib/components/sessions/sessionSwitch.svelte' - import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + import { + useOperatingUser, + useOperatingWorkspace + } from '$lib/components/operatingWorkspace.svelte' const operatingWorkspace = useOperatingWorkspace() + const operatingUser = useOperatingUser() + const actingUser = $derived(operatingUser.current) const { flowStore, selectionManager, pathStore, opWorkspace } = getContext('FlowEditorContext') // Flow paths repeat across workspaces, and a session keeps every tab it has visited alive, so two @@ -403,7 +408,11 @@ // contain. Hand it to a session opened on that step rather than the // docked chat, which sessions leave unmounted. Sent on arrival: the // user already said what they wanted in the description field. - if (!sessionScopedManager && sessionOpen && prefersSessionHandoff()) { + if ( + !sessionScopedManager && + sessionOpen && + prefersSessionHandoff(actingUser?.operator) + ) { void openSourceInSession(sessionOpen, { previewParams: { selected: detail.moduleId }, seedPrompt: stepInstructionsPrompt(detail.moduleId, detail.instructions), diff --git a/frontend/src/lib/components/home/HomeAIChat.svelte b/frontend/src/lib/components/home/HomeAIChat.svelte index c04265dba9..503b4c2dca 100644 --- a/frontend/src/lib/components/home/HomeAIChat.svelte +++ b/frontend/src/lib/components/home/HomeAIChat.svelte @@ -156,10 +156,13 @@ // unlocked, so the far commoner unlocked workspace never pops the composer in mid-load. let runOnlyWorkspace = $derived(isRuleActive('DisableDirectDeployment')) - // The composer hands off to /sessions, so users opted out of the sessions beta don't get it - // (the prompt would be silently dropped) while the AI-independent CLI/MCP row below stays. + // The composer hands off to /sessions, which refuses operators and users opted out of the + // sessions beta — so hide it from them (the prompt would be silently dropped) while the + // AI-independent CLI/MCP row below stays. let showComposer = $derived( - prefersSessionHandoff() && !runOnlyWorkspace && !$copilotInfo.workspaceDisabled + prefersSessionHandoff($userStore?.operator) && + !runOnlyWorkspace && + !$copilotInfo.workspaceDisabled ) // The hero's margins and centered column are for the full block. The lone button row left diff --git a/frontend/src/lib/components/sessions/OpenInSessionButton.svelte b/frontend/src/lib/components/sessions/OpenInSessionButton.svelte index 01db5984d3..00b784138f 100644 --- a/frontend/src/lib/components/sessions/OpenInSessionButton.svelte +++ b/frontend/src/lib/components/sessions/OpenInSessionButton.svelte @@ -43,6 +43,10 @@ import { copilotInfo } from '$lib/aiStore' import { sendUserToast } from '$lib/toast' import { openSourceInSession } from './sessionSwitch.svelte' + import { useOperatingUser } from '$lib/components/operatingWorkspace.svelte' + + const operatingUser = useOperatingUser() + const actingUser = $derived(operatingUser.current) let { source, @@ -76,8 +80,13 @@ // SessionEditorTarget / the session wrapper); iframe preview tabs are not // the top window. const inSessionPanel = !!getContext('aiChatManager') || (BROWSER && window.self !== window.top) + // prefersSessionHandoff carries the operator clause: the sessions page refuses + // them, so an entry point on a page they can reach (Runs, the trigger lists) + // would only route them into that refusal. const show = $derived( - !inSessionPanel && !!(source?.target || source?.page) && prefersSessionHandoff() + !inSessionPanel && + !!(source?.target || source?.page) && + prefersSessionHandoff(actingUser?.operator) ) // Not $state: only read inside open() as a re-entrancy latch, never rendered. diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index 2873ed0f84..10a4d2d0ef 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -68,10 +68,7 @@ getFavoriteHref, getFavoriteLabel } from '$lib/components/sidebar/FavoriteMenu.svelte' - import { - parseSuperadminSettingsHash, - USER_SETTINGS_HASH - } from '$lib/components/sidebar/settings' + import { parseSuperadminSettingsHash, USER_SETTINGS_HASH } from '$lib/components/sidebar/settings' import { isCloudHosted } from '$lib/cloud' import { PanelLeft, @@ -111,7 +108,7 @@ import AiChatLayout from '$lib/components/copilot/chat/AiChatLayout.svelte' import SessionPicker from '$lib/components/sessions/SessionPicker.svelte' import SessionModeSwitch from '$lib/components/sessions/SessionModeSwitch.svelte' - import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate' + import { isGlobalAiEnabled, sessionsAllowedFor } from '$lib/components/copilot/chat/global/gate' import { copilotInfo } from '$lib/aiStore' import { isPageItemListPath, @@ -303,7 +300,10 @@ // Session mode is route-derived: the rail shows the sessions sidebar on the // /sessions page and the workspace navigation everywhere else. The switch // (SessionModeSwitch) just navigates in and out of that route. - let sessionMode = $derived(page.url.pathname.startsWith(base + '/sessions')) + // An operator without the opt-in (`gate.ts`) is refused by /sessions, so their sidebar is not + // handed over to a session list the page behind it declines, and they keep the docked chat. + const sessionsAllowed = $derived(sessionsAllowedFor($userStore?.operator)) + let sessionMode = $derived(page.url.pathname.startsWith(base + '/sessions') && sessionsAllowed) // Session mode points the bottom settings entry at the open session's own // workspace. An unsent draft hasn't committed one yet, so it falls back to // the family root. @@ -324,8 +324,12 @@ const globalAiEnabled = isGlobalAiEnabled() // A workspace that hid the assistant (`ai_config.copilot_disabled`) loses both entry // points: the Workspace ⇄ Sessions switch and the legacy Ask-AI button. - const sessionsSwitchShown = $derived(globalAiEnabled && !$copilotInfo.workspaceDisabled) - const askAiShown = $derived(!globalAiEnabled && !$copilotInfo.workspaceDisabled) + const sessionsSwitchShown = $derived( + globalAiEnabled && sessionsAllowed && !$copilotInfo.workspaceDisabled + ) + const askAiShown = $derived( + (!globalAiEnabled || !sessionsAllowed) && !$copilotInfo.workspaceDisabled + ) if (page.status == 404) { goto('/user/login') @@ -884,7 +888,7 @@ $effect(() => { const ws = $workspaceStore const ready = sessionState.hydrated && $usersWorkspaceStore !== undefined - if (globalAiEnabled && ready && ws) { + if (globalAiEnabled && sessionsAllowed && ready && ws) { untrack(() => restoreSessionBackups(ws)) } }) @@ -1481,9 +1485,9 @@ { const user = get(userStore) const workspace = get(workspaceStore) // Neither is known yet on a cold load of this URL, which then opens the editor. - if (!user || !workspace || !prefersSessionHandoff()) return undefined + if (!user || !workspace || !prefersSessionHandoff(user.operator)) return undefined if ((url.searchParams.get('workspace') ?? workspace) !== workspace) return undefined if (get(copilotWorkspace) !== workspace) await loadCopilot(workspace) if (get(copilotWorkspace) !== workspace || !get(copilotInfo).enabled) return undefined diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte index d4aa97b2b4..9ed2ca296a 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte @@ -529,7 +529,7 @@ policy: result.policy } const workspace = $workspaceStore - if (mode === 'ai' && prefersSessionHandoff() && workspace) { + if (mode === 'ai' && prefersSessionHandoff($userStore?.operator) && workspace) { // The session loads the app by path, so the picked setup is saved before it // opens. `new_draft` tells it the app is still the starter template. try { diff --git a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte index ef358378f2..54a93ec19f 100644 --- a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte @@ -44,7 +44,7 @@ import { withWorkspaceParam } from '$lib/components/sessions/sessionMode.svelte' import { enterSessionMode } from '$lib/components/sessions/sessionSwitch.svelte' import type { SessionPreviewTabs } from '$lib/components/sessions/sessionPreviewTabs.svelte' - import { userWorkspaces, usersWorkspaceStore, workspaceStore } from '$lib/stores' + import { userStore, userWorkspaces, usersWorkspaceStore, workspaceStore } from '$lib/stores' import { getOrCreateRuntime, getRuntime, @@ -54,7 +54,8 @@ import { markSessionRecovered } from '$lib/components/sessions/sessionRecoveryNotice.svelte' import { isGlobalAiEnabled, - clearSessionsBetaOptOut + clearSessionsBetaOptOut, + sessionsAllowedFor } from '$lib/components/copilot/chat/global/gate' import { setToolCompletionListener } from '$lib/components/copilot/chat/shared' import { registerToolDisplayActionHandler } from '$lib/components/copilot/chat/createdResourceActions.svelte' @@ -205,7 +206,7 @@ // load. `recovering` also guards re-entry: recovery mutates the session list // this effect tracks, while the URL that would stop it only updates on `goto`. $effect(() => { - if (embedded || !globalEnabled) return + if (embedded || !globalEnabled || !sessionsAllowedFor($userStore?.operator)) return if (!sessionState.hydrated || recovering) return // A deliberate delete removes the open session ahead of its own navigation. // Claiming that gap would take over the URL and tell the user the session @@ -1021,6 +1022,24 @@ }}>Open sessions + {:else if !sessionsAllowedFor($userStore?.operator)} + +
+

AI Sessions are not available for operators

+

Use the Ask AI chat instead.

+ +
{:else if !globalEnabled}