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..d1dafe29ed 100644 --- a/frontend/src/lib/components/copilot/chat/global/gate.ts +++ b/frontend/src/lib/components/copilot/chat/global/gate.ts @@ -29,9 +29,10 @@ export function isGlobalAiEnabled(): boolean { * 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. + * Operators keep that pane (`/sessions` refuses them) until the operator chat ships. */ -export function prefersSessionHandoff(): boolean { - return isGlobalAiEnabled() +export function prefersSessionHandoff(isOperator: boolean | undefined): boolean { + return isGlobalAiEnabled() && !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 3dbefa4563..2e1f264b58 100644 --- a/frontend/src/lib/components/home/HomeAIChat.svelte +++ b/frontend/src/lib/components/home/HomeAIChat.svelte @@ -147,10 +147,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/raw_apps/RawAppTemplatePicker.svelte b/frontend/src/lib/components/raw_apps/RawAppTemplatePicker.svelte index 4bc993560f..0afef7744d 100644 --- a/frontend/src/lib/components/raw_apps/RawAppTemplatePicker.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppTemplatePicker.svelte @@ -266,7 +266,7 @@ const aiConfigLoaded = $derived(!!opWs && $copilotWorkspace === opWs) // Say where the button leads: the route hands this prompt to a fresh AI // session for everyone who has one, and drives the docked chat for the rest. - const handsOffToSession = $derived(prefersSessionHandoff()) + const handsOffToSession = $derived(prefersSessionHandoff($userStore?.operator)) const isAiEnabled = $derived(aiConfigLoaded && $copilotInfo.enabled) $effect(() => { 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 f16dfd259b..fdd43f849b 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -65,10 +65,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, @@ -302,7 +299,11 @@ // 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')) + // The route decides, except for an operator: /sessions refuses them, so handing their + // sidebar over to a session list would offer what the page behind it is declining. + let sessionMode = $derived( + page.url.pathname.startsWith(base + '/sessions') && !$userStore?.operator + ) // 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. @@ -323,8 +324,14 @@ 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 && !$copilotInfo.workspaceDisabled && !$userStore?.operator + ) + // The legacy pane's entry point. An operator keeps that pane whatever the beta gate says + // (`disableAi` above), so the sidebar must still offer them a way into it. + const askAiShown = $derived( + (!globalAiEnabled || $userStore?.operator) && !$copilotInfo.workspaceDisabled + ) if (page.status == 404) { goto('/user/login') @@ -903,7 +910,7 @@ $effect(() => { const ws = $workspaceStore const ready = sessionState.hydrated && $usersWorkspaceStore !== undefined - if (globalAiEnabled && ready && ws) { + if (globalAiEnabled && ready && ws && !$userStore?.operator) { untrack(() => restoreSessionBackups(ws)) } }) @@ -1679,9 +1686,9 @@ { // Falls through when the hand-off has no path to open, so the click // still reaches the legacy path (or its toast) instead of vanishing. - if (prefersSessionHandoff()) { + if (prefersSessionHandoff($userStore?.operator)) { if (await rawAppEditor?.openInSession(prompt)) return } aiChatManager.changeMode(AIMode.APP) diff --git a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte index 82fd3f4151..41ad10917c 100644 --- a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte @@ -41,7 +41,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, @@ -197,7 +197,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 || $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 @@ -910,6 +910,24 @@ }}>Open sessions + {:else if $userStore?.operator} + +
+

AI Sessions are not available for operators

+

Use the Ask AI chat instead.

+ +
{:else if !globalEnabled}