feat: keep operator AI sessions behind a browser opt-in

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-10-09 05:09:57 -07:00
co-authored by Claude Opus 5.5
parent 6693f4988f
commit 2878238c19
9 changed files with 92 additions and 25 deletions
@@ -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
@@ -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. */
@@ -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>('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),
@@ -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
@@ -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.
@@ -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 @@
<AiChatLayout
{children}
noPadding={devOnly || menuHidden}
disableAi={globalAiEnabled ? true : sessionMode}
disableAi={globalAiEnabled && sessionsAllowed ? true : sessionMode}
loadAiConfig={!sessionMode}
showSessionsBetaBanner
showSessionsBetaBanner={sessionsAllowed}
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()) 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
@@ -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 {
@@ -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</Button
>
</div>
{:else if !sessionsAllowedFor($userStore?.operator)}
<!-- Operators without the opt-in keep the legacy docked chat (see `gate.ts`); 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. -->