From eb1ff4fbf4f8997ec6b90da6db21cccfe733df02 Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Wed, 30 Sep 2026 04:04:23 -0700 Subject: [PATCH] feat: operators get AI sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `prefersSessionHandoff` stops taking the role, so every AI entry point an operator can reach — Ask AI, the home composer, Open in session, the flow editor's hand-off, the raw-app template picker — opens a session for them like anyone else. The sessions page drops its refusal, and the layout stops keeping them on the docked chat. In the sidebar they now get the Workspace/AI Sessions switch and session mode, and lose the Ask AI entry that stood in for them, since the pane behind it is no longer the one they are sent to. 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 +++---- .../sessions/OpenInSessionButton.svelte | 11 +------- .../src/routes/(root)/(logged)/+layout.svelte | 27 +++++++------------ .../(root)/(logged)/apps_raw/add/+page.ts | 2 +- .../apps_raw/edit/[...path]/+page.svelte | 2 +- .../(root)/(logged)/sessions/+page.svelte | 22 ++------------- 9 files changed, 23 insertions(+), 71 deletions(-) diff --git a/frontend/src/lib/components/copilot/AskAiButton.svelte b/frontend/src/lib/components/copilot/AskAiButton.svelte index 95ff47fb52..a141ee3125 100644 --- a/frontend/src/lib/components/copilot/AskAiButton.svelte +++ b/frontend/src/lib/components/copilot/AskAiButton.svelte @@ -5,7 +5,6 @@ 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 @@ -16,7 +15,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($userStore?.operator)) + const handsOffToSession = $derived(prefersSessionHandoff()) 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 d1dafe29ed..e389283d8a 100644 --- a/frontend/src/lib/components/copilot/chat/global/gate.ts +++ b/frontend/src/lib/components/copilot/chat/global/gate.ts @@ -29,10 +29,9 @@ 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(isOperator: boolean | undefined): boolean { - return isGlobalAiEnabled() && !isOperator +export function prefersSessionHandoff(): boolean { + return isGlobalAiEnabled() } /** 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 a9c3a40e8a..9d98d0535e 100644 --- a/frontend/src/lib/components/flows/FlowEditor.svelte +++ b/frontend/src/lib/components/flows/FlowEditor.svelte @@ -40,14 +40,9 @@ import FlowPanelPlacementPicker from './common/FlowPanelPlacementPicker.svelte' import { prefersSessionHandoff } from '../copilot/chat/global/gate' import { openSourceInSession } from '$lib/components/sessions/sessionSwitch.svelte' - import { - useOperatingUser, - useOperatingWorkspace - } from '$lib/components/operatingWorkspace.svelte' + import { 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 @@ -408,11 +403,7 @@ // 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(actingUser?.operator) - ) { + if (!sessionScopedManager && sessionOpen && prefersSessionHandoff()) { 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 62479b70d1..77eaa8dc07 100644 --- a/frontend/src/lib/components/home/HomeAIChat.svelte +++ b/frontend/src/lib/components/home/HomeAIChat.svelte @@ -137,13 +137,10 @@ // 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, 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. + // 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. let showComposer = $derived( - prefersSessionHandoff($userStore?.operator) && - !runOnlyWorkspace && - !$copilotInfo.workspaceDisabled + prefersSessionHandoff() && !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 00b784138f..01db5984d3 100644 --- a/frontend/src/lib/components/sessions/OpenInSessionButton.svelte +++ b/frontend/src/lib/components/sessions/OpenInSessionButton.svelte @@ -43,10 +43,6 @@ 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, @@ -80,13 +76,8 @@ // 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(actingUser?.operator) + !inSessionPanel && !!(source?.target || source?.page) && prefersSessionHandoff() ) // 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 2403299e71..2873ed0f84 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -68,7 +68,10 @@ 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, @@ -300,11 +303,7 @@ // 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. - // 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 - ) + let sessionMode = $derived(page.url.pathname.startsWith(base + '/sessions')) // 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. @@ -325,14 +324,8 @@ 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 && !$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 - ) + const sessionsSwitchShown = $derived(globalAiEnabled && !$copilotInfo.workspaceDisabled) + const askAiShown = $derived(!globalAiEnabled && !$copilotInfo.workspaceDisabled) if (page.status == 404) { goto('/user/login') @@ -891,7 +884,7 @@ $effect(() => { const ws = $workspaceStore const ready = sessionState.hydrated && $usersWorkspaceStore !== undefined - if (globalAiEnabled && ready && ws && !$userStore?.operator) { + if (globalAiEnabled && ready && ws) { untrack(() => restoreSessionBackups(ws)) } }) @@ -1488,9 +1481,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(user.operator)) return undefined + if (!user || !workspace || !prefersSessionHandoff()) 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 9ed2ca296a..d4aa97b2b4 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($userStore?.operator) && workspace) { + if (mode === 'ai' && prefersSessionHandoff() && 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 97ed4b95b6..ef358378f2 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 { userStore, userWorkspaces, usersWorkspaceStore, workspaceStore } from '$lib/stores' + import { userWorkspaces, usersWorkspaceStore, workspaceStore } from '$lib/stores' import { getOrCreateRuntime, getRuntime, @@ -205,7 +205,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 || $userStore?.operator) return + if (embedded || !globalEnabled) 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,24 +1021,6 @@ }}>Open sessions - {:else if $userStore?.operator} - -
-

AI Sessions are not available for operators

-

Use the Ask AI chat instead.

- -
{:else if !globalEnabled}