feat(sessions): offer the item you came from when starting a new session (#10940)

* fix: connect to dev server instead of localhost

* fix: derive WebSocket scheme from location.protocol

Mirror the protocol-aware pattern used by initSqlWebSocket in dev.ts
so the WebSocket connects over wss:// when the dev server is reached
through an HTTPS proxy/tunnel, avoiding mixed-content blocking.

* refactor: drop now-unused port parameter of wmillTsDev

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HsfdN82yP88qyQ3h8Lwv2v

* feat(sessions): offer the item you came from when starting a new session

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VkrstcgtV4AC4jZRHVzFdm

* docs(sessions): state the new-session seed latch's real lifetime

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VkrstcgtV4AC4jZRHVzFdm

* fix(sessions): let Enter act on the focused answer of the new-session offer

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VkrstcgtV4AC4jZRHVzFdm

* feat(sessions): start on the item instead of resuming a stale session from the rail

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VkrstcgtV4AC4jZRHVzFdm

* fix(sessions): hand the rail's item entry through the editor's own hand-off

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VkrstcgtV4AC4jZRHVzFdm

* fix(sessions): snap the rail toggle back when a session switch does not navigate

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VkrstcgtV4AC4jZRHVzFdm

---------

Co-authored-by: Nathan A. Ferch <nf+github@marginal.net>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-09-02 17:17:58 +02:00
committed by GitHub
co-authored by Claude Opus 5 Nathan A. Ferch
parent 95b6bbd46a
commit d3747d6255
5 changed files with 374 additions and 26 deletions
@@ -2,8 +2,9 @@
import { Building, MessagesSquare } from 'lucide-svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import { enterSessionMode, exitSessionMode } from './sessionSwitch.svelte'
import { enterSessionModeFromNav, exitSessionMode } from './sessionSwitch.svelte'
import { goto } from '$lib/navigation'
import { sendUserToast } from '$lib/toast'
import { page } from '$app/state'
import { base } from '$lib/base'
@@ -17,11 +18,25 @@
onToggle
}: { mode: 'nav' | 'session'; isCollapsed?: boolean; onToggle?: () => void } = $props()
// The group's highlighted side. Melt moves it on click, before the navigation
// that would change `mode`, so it is derived from the route (which wins once
// a switch navigates) and pushed back when one does not, or the rail would
// read "AI Sessions" on an editor page with the clicked side inert until
// "Workspace" was pressed first.
let selected: string | string[] | null | undefined = $derived(mode)
function onSelected(next: 'nav' | 'session') {
if (next === mode) return
onToggle?.()
if (next === 'session') void enterSessionMode()
else void exitSessionMode()
if (next === 'session') {
// An editor whose draft could not be persisted keeps the user on the
// page, as its own "Open in AI session" button does, rather than open a
// session on an older draft than the one on screen.
void enterSessionModeFromNav().catch((e) => {
selected = mode
sendUserToast(e instanceof Error ? e.message : String(e), true)
})
} else void exitSessionMode()
}
// Pressing the already-active "Workspace" side goes home, so the toggle doubles
@@ -41,7 +56,7 @@
child of the group's track — so the buttons fill the rail width only if those
wrappers grow. `[&>*]:flex-1` makes every direct child split the track evenly. -->
<ToggleButtonGroup
selected={mode}
bind:selected
{onSelected}
tabListClass={isCollapsed ? 'flex-col w-full [&>*]:w-full' : 'w-full [&>*]:flex-1'}
>
@@ -1,5 +1,6 @@
<script lang="ts">
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import Modal from '$lib/components/common/modal/Modal.svelte'
import Checkbox from '$lib/components/common/checkbox/Checkbox.svelte'
import { Badge, Button } from '$lib/components/common'
import {
@@ -22,6 +23,7 @@
import { twMerge } from 'tailwind-merge'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import { goto } from '$lib/navigation'
import { takeNewSessionSeed, type NewSessionSeed } from './sessionSwitch.svelte'
import { useLocalStorageValue } from '$lib/svelte5Utils.svelte'
import { slide } from 'svelte/transition'
import {
@@ -414,17 +416,52 @@
}
async function createAndOpen() {
const fresh = createSession()
// A new session opened from a Windmill page adopts that page as its first
// preview tab. Skip when already on the sessions page (nothing meaningful to
// capture) so the preview starts empty until the chat opens something.
// preview tab.
if (!onSessionsPage) {
const url = page.url.pathname + page.url.search
resetSessionPreviewTabs(fresh.id, url)
await createAndOpenWith(page.url.pathname + page.url.search)
return
}
// On the sessions page there is nothing meaningful on screen to capture,
// except the item the user just left to get here: that one is offered (once)
// before the session is created, and answerSeedOffer picks up from there.
const seed = takeNewSessionSeed()
if (seed) {
seedOffer = seed
seedOfferOpen = true
return
}
await createAndOpenWith(undefined)
}
// `previewUrl` undefined leaves the preview empty until the chat opens something.
async function createAndOpenWith(previewUrl: string | undefined) {
const fresh = createSession()
if (previewUrl) resetSessionPreviewTabs(fresh.id, previewUrl)
await activate(fresh)
}
// The item createAndOpen is asking about. Closing the dialog any other way than
// answering (Escape, the corner X, the backdrop) creates nothing: the click was
// met with a question, not a session. Kept after the dialog closes, since the
// title reads it through the close fade; `seedOfferOpen` alone gates rendering.
let seedOffer = $state<NewSessionSeed | undefined>(undefined)
let seedOfferOpen = $state(false)
// Focus moves onto the primary answer as the dialog opens. Enter is left to
// the focused button (the dialog does not bind it), and the "New session"
// button that opened the dialog would otherwise keep focus and answer Enter
// with a second, unasked session underneath.
let keepButton: Button | undefined = $state(undefined)
$effect(() => {
if (seedOfferOpen) keepButton?.focus()
})
async function answerSeedOffer(keep: boolean) {
const offer = seedOffer
seedOfferOpen = false
if (!offer) return
await createAndOpenWith(keep ? offer.url : undefined)
}
// The `+` on a workspace group header: a new session parked on that group's
// workspace rather than the one currently open.
async function createAndOpenIn(workspaceId: string) {
@@ -1300,3 +1337,30 @@
{/if}
</div>
</ConfirmationModal>
<!-- Two answers of equal standing, so Enter is left to whichever button has
focus rather than bound to one of them by the dialog. -->
<Modal
bind:open={seedOfferOpen}
kind="X"
enterConfirms={false}
title="Keep this {seedOffer?.route.kind ?? 'item'} in the new session?"
description="You came here from {seedOffer?.route.itemPath ?? ''}."
>
<p class="text-sm text-secondary">
Keeping it opens it in the preview, so the chat starts with it as context.
</p>
<div class="flex justify-end gap-2 mt-4">
<Button variant="default" unifiedSize="sm" onClick={() => answerSeedOffer(false)}>
Start empty
</Button>
<Button
bind:this={keepButton}
variant="accent"
unifiedSize="sm"
onClick={() => answerSeedOffer(true)}
>
Keep in preview
</Button>
</div>
</Modal>
@@ -1,5 +1,6 @@
import { getContext, setContext } from 'svelte'
import { getContext, onDestroy, setContext } from 'svelte'
import type { OpenInSessionSource } from './OpenInSessionButton.svelte'
import type { PreviewItemRoute } from './previewPaths'
// The "Open in AI session" hand-off, published by the component that owns the
// item being edited (FlowBuilder, RawAppEditor) for AI entry points too deep in
@@ -14,10 +15,43 @@ export type OpenInSessionHandoff = {
source: (opts?: { moduleId?: string }) => OpenInSessionSource | undefined
}
// The hand-offs of every editor currently mounted. The navigation rail's "AI
// Sessions" switch sits above every page, out of reach of the context, and
// looks the editor of the item it is leaving up here instead.
const mounted = new Set<OpenInSessionHandoff>()
export function setOpenInSessionHandoff(handoff: OpenInSessionHandoff): void {
setContext(KEY, handoff)
onDestroy(registerMountedOpenInSessionHandoff(handoff))
}
/** Count `handoff` as mounted until the returned unregister runs. Split from
* setOpenInSessionHandoff so the registry can be driven outside a component. */
export function registerMountedOpenInSessionHandoff(handoff: OpenInSessionHandoff): () => void {
mounted.add(handoff)
return () => {
mounted.delete(handoff)
}
}
export function getOpenInSessionHandoff(): OpenInSessionHandoff | undefined {
return getContext<OpenInSessionHandoff | undefined>(KEY)
}
/** The mounted editor's hand-off for the item `route` names, or undefined when
* no editor on screen publishes one for it (a legacy app, a detail page).
* Matched on the item rather than taken as "the latest registered": a script
* editor mounted in a flow's drawer registers too, and the rail wants the
* page's own item. */
export function findMountedOpenInSessionSource(
route: PreviewItemRoute
): OpenInSessionSource | undefined {
const kind = route.kind === 'app' ? (route.raw_app ? 'raw_app' : undefined) : route.kind
if (!kind) return undefined
for (const handoff of mounted) {
const source = handoff.source()
const target = source?.target
if (target && target.kind === kind && target.path === route.itemPath) return source
}
return undefined
}
@@ -5,13 +5,17 @@ import {
createSession,
selectSession,
sessionInCurrentFamily,
sessionLastActivityAt,
sessionState,
setSessionAutoSend,
setSessionDraftPrompt,
setSessionPendingWorkspace,
type Session,
type SessionTarget
} from './sessionState.svelte'
import { sessionTargetHref, withPreviewParams } from './sessionMode.svelte'
import { parsePreviewItemRoute, type PreviewItemRoute } from './previewPaths'
import { findMountedOpenInSessionSource } from './openInSessionContext'
// Type-only: erased at compile time, so the component graph stays out of this
// navigation seam (see the dynamic import in openEditorInSession).
import type { OpenInSessionSource } from './OpenInSessionButton.svelte'
@@ -27,33 +31,100 @@ import type { OpenInSessionSource } from './OpenInSessionButton.svelte'
// is safe. Plain module state: it is read only inside click handlers, never
// rendered, so it needs no reactivity.
let lastNavRoute = '/'
// Whether `lastNavRoute` has been offered as a new session's starting point since
// it was remembered (see takeNewSessionSeed).
let navRouteOffered = false
export function rememberNavRoute(pathnameWithSearch: string): void {
lastNavRoute = pathnameWithSearch
navRouteOffered = false
}
// Enter session mode: open the active session if one is selected, else the most
// recent non-archived session, else spin up a fresh one — then route to it.
// Restore candidates are scoped to the active workspace family: reviving a
// session from another family would pull that family's scope (sidebar list,
// "Acting on" workspace) into the one the user is actually in.
/** An item editor the user reached session mode from, as the in-app href a new
* session's preview can open, plus the item it edits (for naming it). */
export type NewSessionSeed = { url: string; route: PreviewItemRoute }
// The item (flow, script, app) the user came to session mode from, or undefined
// when they came from anywhere else. A "New session" asked for after arriving
// from an item is usually a session about that item, but the arrival route
// (`enterSessionMode`) resumes whatever session was open, so the picker offers
// the item back. Offered once per remembered route: the first "New session" of
// a visit gets the question, however long the visit has run and whatever was
// done in between; later ones start empty without asking, and a dismissed offer
// is not repeated until the user leaves for another page and comes back.
export function takeNewSessionSeed(): NewSessionSeed | undefined {
if (navRouteOffered) return undefined
navRouteOffered = true
const route = parsePreviewItemRoute(lastNavRoute)
return route ? { url: lastNavRoute, route } : undefined
}
// The session entering session mode resumes: the active one if selected, else
// the most recent non-archived one. Scoped to the active workspace family:
// reviving a session from another family would pull that family's scope
// (sidebar list, "Acting on" workspace) into the one the user is actually in.
function resumableSession(): Session | undefined {
const current = sessionState.currentSessionId
? sessionState.sessions.find((s) => s.id === sessionState.currentSessionId)
: undefined
return (
(current && sessionInCurrentFamily(current) ? current : undefined) ??
sessionState.sessions.find((s) => !s.archived && sessionInCurrentFamily(s))
)
}
// Enter session mode: resume the session `resumableSession` picks, else spin up
// a fresh one — then route to it.
// `replace` swaps the current history entry instead of pushing — for the
// sessions page's family reconcile, where Back must not return to the
// redirected-away URL just to bounce here again.
export async function enterSessionMode(opts?: { replace?: boolean }): Promise<void> {
const current = sessionState.currentSessionId
? sessionState.sessions.find((s) => s.id === sessionState.currentSessionId)
: undefined
const target =
(current && sessionInCurrentFamily(current) ? current : undefined) ??
sessionState.sessions.find((s) => !s.archived && sessionInCurrentFamily(s)) ??
createSession()
const target = resumableSession() ?? createSession()
selectSession(target.id)
await goto(`/sessions?session_name=${encodeURIComponent(target.name)}`, {
replaceState: opts?.replace ?? false
})
}
// How long the resumable session may have sat idle before entering from an item
// editor starts a session on that item instead. Long enough that stepping out to
// the editor in the middle of a conversation comes back to the same chat; short
// enough that a session left since the previous day is not taken for the task
// the user is now on.
const RESUME_IDLE_LIMIT_MS = 60 * 60 * 1000
// Enter session mode from the navigation rail. Coming from an item editor with
// no session to resume, or one idle past RESUME_IDLE_LIMIT_MS, opens a fresh
// session on that item straight away: a session that old is rarely what a visit
// from a flow or app is about, and landing in it would only lead to "New
// session" and the offer takeNewSessionSeed makes. Anything else resumes as
// enterSessionMode does. Rejects when the editor could not persist its draft,
// so the caller can stay on the page and say so.
export async function enterSessionModeFromNav(): Promise<void> {
const route = parsePreviewItemRoute(lastNavRoute)
if (route) {
const resumable = resumableSession()
if (!resumable || Date.now() - sessionLastActivityAt(resumable) > RESUME_IDLE_LIMIT_MS) {
// The editor's own hand-off is what its "Open in AI session" button uses:
// it persists the draft the preview loads (an edit still inside the
// autosave debounce, the row of a never-saved new item) and names the
// workspace the item lives in. Only an item with no such editor on
// screen (a legacy app, a detail page) is opened by route, as last
// persisted, in the workspace the route was scoped to.
const source = findMountedOpenInSessionSource(route)
if (source) await openSourceInSession(source)
else await openPageInSession(lastNavRoute, workspaceParamOf(lastNavRoute))
return
}
}
await enterSessionMode()
}
function workspaceParamOf(pathnameWithSearch: string): string | undefined {
const query = pathnameWithSearch.split('?')[1]
return query ? (new URLSearchParams(query).get('workspace') ?? undefined) : undefined
}
// Exit session mode: back to the last navigation route (home as a fallback).
export async function exitSessionMode(): Promise<void> {
let target = lastNavRoute || '/'
@@ -90,9 +161,9 @@ export async function openEditorInSession(
)
}
// Open a fresh AI session showing a workspace page (Runs, a trigger list) in its
// preview. A page is not an editable item, so callers hand over the in-app href
// they want the tab to load rather than a SessionTarget.
// Open a fresh AI session showing an in-app href in its preview: a workspace
// page (Runs, a trigger list), which is not an editable item and so has no
// SessionTarget, or a location captured as the user left it.
export async function openPageInSession(
href: string,
workspaceId?: string,
@@ -121,6 +192,9 @@ async function openInSession(
// node-run unit tests.
const { resetSessionPreviewTabs } = await import('./sessionRuntime.svelte')
resetSessionPreviewTabs(session.id, url)
// Hand-offs seed the page they leave, so the arrival has opened the item
// itself and "New session" need not offer it again.
navRouteOffered = true
}
selectSession(session.id)
await goto(`/sessions?session_name=${encodeURIComponent(session.name)}`)
@@ -2,8 +2,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { get } from 'svelte/store'
import {
enterSessionMode,
enterSessionModeFromNav,
openSourceInSession,
startSessionWithPrompt
rememberNavRoute,
startSessionWithPrompt,
takeNewSessionSeed
} from './sessionSwitch.svelte'
import {
peekSessionAutoSend,
@@ -20,6 +23,8 @@ import { goto } from '$lib/navigation'
// monaco (hence that import being dynamic in the first place) and cannot load
// under node.
vi.mock('./sessionRuntime.svelte', () => ({ resetSessionPreviewTabs: vi.fn() }))
import { resetSessionPreviewTabs } from './sessionRuntime.svelte'
import { registerMountedOpenInSessionHandoff } from './openInSessionContext'
function session(over: Partial<Session> = {}): Session {
return { id: 's1', name: 'sess', createdAt: 0, ...over }
@@ -248,3 +253,159 @@ describe('startSessionWithPrompt', () => {
}
})
})
describe('takeNewSessionSeed', () => {
it('offers the item editor the user came from, once per arrival', () => {
rememberNavRoute('/flows/edit/u/me/my_flow?workspace=ws')
expect(takeNewSessionSeed()).toEqual({
url: '/flows/edit/u/me/my_flow?workspace=ws',
route: { kind: 'flow', raw_app: false, itemPath: 'u/me/my_flow' }
})
// A dismissed or taken offer is not repeated until the user leaves again.
expect(takeNewSessionSeed()).toBeUndefined()
rememberNavRoute('/apps_raw/edit/f/team/dashboard?workspace=ws')
expect(takeNewSessionSeed()?.route).toEqual({
kind: 'app',
raw_app: true,
itemPath: 'f/team/dashboard'
})
})
it('offers nothing for a non-item page', () => {
rememberNavRoute('/runs?workspace=ws')
expect(takeNewSessionSeed()).toBeUndefined()
})
})
describe('enterSessionModeFromNav', () => {
const HOUR = 60 * 60 * 1000
beforeEach(() => {
vi.mocked(goto).mockClear()
vi.mocked(resetSessionPreviewTabs).mockClear()
})
// One session in the active family (rootA), as the one the rail would resume.
// Restores everything the test touched, the module-level nav route included,
// and drops whatever session the call under test created.
function withResumable(over: Partial<Session>): { restore: () => void } {
const restoreFamilies = withTwoFamilies('rootA')
const prevCurrent = sessionState.currentSessionId
const before = new Set(sessionState.sessions.map((s) => s.id))
const s = session({ workspace_id: 'rootA', ...over })
sessionState.sessions.push(s)
sessionState.currentSessionId = s.id
return {
restore: () => {
sessionState.sessions = sessionState.sessions.filter((x) => before.has(x.id))
sessionState.currentSessionId = prevCurrent
rememberNavRoute('/')
restoreFamilies()
}
}
}
it('resumes a recently active session even when coming from an item', async () => {
const { restore } = withResumable({
id: 'nav-recent',
name: 'session-921',
lastActivityAt: Date.now() - HOUR / 2
})
try {
rememberNavRoute('/flows/edit/u/me/f?workspace=rootA')
await enterSessionModeFromNav()
expect(sessionState.currentSessionId).toBe('nav-recent')
expect(resetSessionPreviewTabs).not.toHaveBeenCalled()
} finally {
restore()
}
})
it('starts a session on the item, by route, instead of resuming one idle for hours', async () => {
const { restore } = withResumable({
id: 'nav-stale',
name: 'session-922',
lastActivityAt: Date.now() - 3 * HOUR
})
try {
rememberNavRoute('/flows/edit/u/me/f?workspace=forkA')
await enterSessionModeFromNav()
const createdId = sessionState.currentSessionId
expect(createdId).not.toBe('nav-stale')
expect(resetSessionPreviewTabs).toHaveBeenCalledWith(
createdId,
'/flows/edit/u/me/f?workspace=forkA'
)
// The route's workspace, not createSession's pick for the active one.
const created = sessionState.sessions.find((s) => s.id === createdId)
expect(created?.pending_workspace_id).toBe('forkA')
// The arrival opened the item itself, so "New session" does not offer it.
expect(takeNewSessionSeed()).toBeUndefined()
} finally {
restore()
}
})
// The editor's hand-off is what its own "Open in AI session" button uses: its
// beforeOpen persists the draft the preview loads, and it names the item's
// workspace. The rail must take it over opening the route as last persisted.
it('hands off through the mounted editor of the item, running its beforeOpen first', async () => {
const { restore } = withResumable({
id: 'nav-stale-editor',
name: 'session-924',
lastActivityAt: Date.now() - 3 * HOUR
})
const order: string[] = []
vi.mocked(goto).mockImplementation((async () => {
order.push('goto')
}) as never)
// A script editor mounted alongside (a flow's drawer) must not be taken
// for the page's own item.
const unregisterScript = registerMountedOpenInSessionHandoff({
source: () => ({ target: { kind: 'script', path: 'u/me/s' }, workspaceId: 'rootB' })
})
const unregisterFlow = registerMountedOpenInSessionHandoff({
source: () => ({
target: { kind: 'flow', path: 'u/me/f' },
workspaceId: 'forkA',
beforeOpen: () => {
order.push('beforeOpen')
}
})
})
try {
rememberNavRoute('/flows/edit/u/me/f?workspace=rootA')
await enterSessionModeFromNav()
const createdId = sessionState.currentSessionId
expect(createdId).not.toBe('nav-stale-editor')
expect(order).toEqual(['beforeOpen', 'goto'])
expect(resetSessionPreviewTabs).toHaveBeenCalledWith(
createdId,
expect.stringMatching(/\/flows\/edit\/u\/me\/f$/)
)
const created = sessionState.sessions.find((s) => s.id === createdId)
expect(created?.pending_workspace_id).toBe('forkA')
} finally {
unregisterFlow()
unregisterScript()
vi.mocked(goto).mockReset()
vi.mocked(goto).mockResolvedValue(undefined as never)
restore()
}
})
it('resumes a stale session when coming from a non-item page', async () => {
const { restore } = withResumable({
id: 'nav-stale-runs',
name: 'session-923',
lastActivityAt: Date.now() - 3 * HOUR
})
try {
rememberNavRoute('/runs?workspace=rootA')
await enterSessionModeFromNav()
expect(sessionState.currentSessionId).toBe('nav-stale-runs')
expect(resetSessionPreviewTabs).not.toHaveBeenCalled()
} finally {
restore()
}
})
})