mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-05 16:03:47 +00:00
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:
co-authored by
Claude Opus 5
Nathan A. Ferch
parent
95b6bbd46a
commit
d3747d6255
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user