feat: operators get AI sessions

`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) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-10-08 00:36:10 -07:00
co-authored by Claude Opus 5
parent ff028babe8
commit eb1ff4fbf4
9 changed files with 23 additions and 71 deletions
@@ -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
@@ -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. */
@@ -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>('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),
@@ -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
@@ -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.
@@ -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 @@
<AiChatLayout
{children}
noPadding={devOnly || menuHidden}
disableAi={globalAiEnabled && !$userStore?.operator ? true : sessionMode}
disableAi={globalAiEnabled ? true : sessionMode}
loadAiConfig={!sessionMode}
showSessionsBetaBanner={!$userStore?.operator}
showSessionsBetaBanner
sidebarWidth={railWidth}
transitionClass={sidebarTransitionClass}
isMobile={useDrawer}
@@ -26,7 +26,7 @@ async function newAppSessionHref(url: URL): Promise<string | undefined> {
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
@@ -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 {
@@ -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</Button
>
</div>
{:else if $userStore?.operator}
<!-- Operators are exempt from the sessions beta (the layout keeps their
legacy docked chat); a direct URL must not bypass that. -->
<div class="p-8 flex flex-col items-start gap-3 text-secondary text-sm">
<p class="text-primary font-medium">AI Sessions are not available for operators</p>
<p>Use the Ask AI chat instead.</p>
<Button
unifiedSize="xs"
onclick={() => {
try {
localStorage.setItem('ai-chat-open', 'true')
} catch {}
window.location.href = `${base}/`
}}
>
Open Ask AI chat
</Button>
</div>
{:else if !globalEnabled}
<!-- Direct navigation (bookmark, shared link) while the user has opted out
of the beta: offer the way back in instead of a dead end. -->