From 0c40295bec1f396abde8ef2705483f71dbb121ac Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Wed, 30 Sep 2026 04:03:24 -0700 Subject: [PATCH] revert: keep operators out of AI sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit That change is its own PR; this one is about the navigation. `prefersSessionHandoff` takes the role again and every entry point passes it, the sessions page refuses an operator arriving by URL, and the layout leaves them on the docked chat. Two clauses are new rather than restored, because the nav rework is what gives an operator a sidebar at all: the Workspace/AI Sessions switch and session mode are both hidden from them, since the page behind them declines. `askAiShown` gains the operator case for the same reason — they keep the docked pane whatever the beta gate says, so the sidebar has to offer a way into it. Co-Authored-By: Claude Opus 5 (1M context) --- .../lib/components/copilot/AskAiButton.svelte | 3 ++- .../components/copilot/chat/global/gate.ts | 5 ++-- .../lib/components/flows/FlowEditor.svelte | 13 +++++++-- .../src/lib/components/home/HomeAIChat.svelte | 9 ++++--- .../raw_apps/RawAppTemplatePicker.svelte | 2 +- .../sessions/OpenInSessionButton.svelte | 11 +++++++- .../src/routes/(root)/(logged)/+layout.svelte | 27 ++++++++++++------- .../apps_raw/edit/[...path]/+page.svelte | 2 +- .../(root)/(logged)/sessions/+page.svelte | 22 +++++++++++++-- 9 files changed, 71 insertions(+), 23 deletions(-) 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}