mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-12 08:02:44 +00:00
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:
co-authored by
Claude Opus 5.5
parent
6693f4988f
commit
2878238c19
@@ -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. -->
|
||||
|
||||
Reference in New Issue
Block a user