mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-30 08:16:49 +00:00
fix: keep a new app's AI session unsaved until it is started (#12065)
* fix: keep a new app's AI session unsaved until it is started Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: never drop a held session while another draft is created Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: forward untilStarted through openSourceInSession overrides Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
7765913064
commit
f46b3a7505
@@ -129,6 +129,10 @@ export type Session = {
|
||||
// untouched draft never persists, so idle `+` clicks vanish on reload
|
||||
// instead of littering the sidebar.
|
||||
transient?: boolean
|
||||
// In memory only, on a transient session opened onto an item that lives on its own (a new
|
||||
// app's draft): its preview writes are not a touch, so a session the user never starts
|
||||
// vanishes on reload instead of reopening onto the item. Cleared with `transient`.
|
||||
untilStarted?: boolean
|
||||
// Epoch ms of the last write to this record — typing, renaming, archiving,
|
||||
// changing preview tabs, or catching up on new messages. Stamped by the two
|
||||
// write funnels (persistTouched, markSessionSeen), so it tracks use rather
|
||||
@@ -475,10 +479,17 @@ export function takeSessionAutoSend(sessionId: string): boolean {
|
||||
// stays in memory and vanishes on reload.
|
||||
function persistTouched(s: Session): void {
|
||||
if (s.transient) delete s.transient
|
||||
delete s.untilStarted
|
||||
s.lastActivityAt = Date.now()
|
||||
void putSession(s)
|
||||
}
|
||||
|
||||
// Preview state (tabs, collapse, width) is a touch except on an `untilStarted` session.
|
||||
function persistPreviewTouch(s: Session): void {
|
||||
if (s.transient && s.untilStarted) return
|
||||
persistTouched(s)
|
||||
}
|
||||
|
||||
// When the session was last used. Pre-dates-the-field records report their
|
||||
// creation time, which is the earliest activity they could have had.
|
||||
export function sessionLastActivityAt(s: Session): number {
|
||||
@@ -1072,8 +1083,10 @@ export function requestComposerFocus(): void {
|
||||
// drop. `draftPrompt === undefined` (never edited), not falsiness: a draft typed
|
||||
// then erased to '' still has a pending flush and is a real session. Every other
|
||||
// touch clears `transient` synchronously, so only the draft prompt needs checking.
|
||||
// An `untilStarted` session is not blank: reused it would reopen its item, and dropped
|
||||
// while on screen it would leave the page showing a session that is gone.
|
||||
function isDiscardableDraft(s: Session): boolean {
|
||||
return !!s.transient && s.draftPrompt === undefined
|
||||
return !!s.transient && s.draftPrompt === undefined && !s.untilStarted
|
||||
}
|
||||
|
||||
// Somewhere empty to put the user, for a URL naming a session this browser
|
||||
@@ -1084,6 +1097,7 @@ export function findEmptyLandingSession(): Session | undefined {
|
||||
return sessionState.sessions.find(
|
||||
(s) =>
|
||||
!!s.transient &&
|
||||
!s.untilStarted &&
|
||||
!s.archived &&
|
||||
!s.workspace_id &&
|
||||
// Falsiness, not `=== undefined`: we only navigate into the session, so a
|
||||
@@ -1194,6 +1208,7 @@ export function materializeTransient(id: string): void {
|
||||
const s = sessionState.sessions.find((x) => x.id === id)
|
||||
if (!s || !s.transient) return
|
||||
delete s.transient
|
||||
delete s.untilStarted
|
||||
void putSession(s)
|
||||
}
|
||||
|
||||
@@ -1214,6 +1229,12 @@ export function stopFollowingHome(id: string) {
|
||||
if (!s.transient) void putSession(s)
|
||||
}
|
||||
|
||||
// Keep a just-created session in memory until the user starts it (see `untilStarted`).
|
||||
export function holdSessionUntilStarted(id: string) {
|
||||
const s = sessionState.sessions.find((x) => x.id === id)
|
||||
if (s?.transient) s.untilStarted = true
|
||||
}
|
||||
|
||||
// Park a just-created session on a workspace. Deliberately does not persist a
|
||||
// still-transient draft: picking where it will live is part of creating it, not a
|
||||
// user touch, so an unused draft still vanishes on reload (see `transient`).
|
||||
@@ -1332,7 +1353,7 @@ export function setSessionTabs(id: string, tabs: SessionPreviewTab[], activeTabI
|
||||
if (!s) return
|
||||
s.previewTabs = tabs.map((t) => ({ ...t }))
|
||||
s.activePreviewTabId = activeTabId
|
||||
persistTouched(s)
|
||||
persistPreviewTouch(s)
|
||||
}
|
||||
|
||||
// Persist whether the preview panel is collapsed for this session (a touch).
|
||||
@@ -1340,7 +1361,7 @@ export function setSessionPreviewCollapsed(id: string, collapsed: boolean): void
|
||||
const s = sessionState.sessions.find((x) => x.id === id)
|
||||
if (!s || !!s.previewCollapsed === collapsed) return
|
||||
s.previewCollapsed = collapsed
|
||||
persistTouched(s)
|
||||
persistPreviewTouch(s)
|
||||
}
|
||||
|
||||
// Persist the preview split size the user dragged for this session (a touch).
|
||||
@@ -1348,7 +1369,7 @@ export function setSessionPreviewSize(id: string, size: number): void {
|
||||
const s = sessionState.sessions.find((x) => x.id === id)
|
||||
if (!s || s.previewSize === size) return
|
||||
s.previewSize = size
|
||||
persistTouched(s)
|
||||
persistPreviewTouch(s)
|
||||
}
|
||||
|
||||
// Persist the session's Use or Build choice (a touch). Plan is left out: a reload ends the
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
decideSessionLifecycle,
|
||||
findEmptyLandingSession,
|
||||
getSessionDraftAttachments,
|
||||
holdSessionUntilStarted,
|
||||
homeStarterSession,
|
||||
isForkSession,
|
||||
isTearingDownOpenSession,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
setGeneratedSessionSummary,
|
||||
setSessionDraftAttachments,
|
||||
setSessionDraftPrompt,
|
||||
setSessionTabs,
|
||||
sessionState,
|
||||
withOpenSessionTeardown,
|
||||
type Session
|
||||
@@ -489,6 +491,36 @@ describe('createSession — reuses an untouched draft, family-scoped', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a session held until started in memory, and does not reuse it', () => {
|
||||
const restore = withTwoFamilies('rootA')
|
||||
const prevCurrent = sessionState.currentSessionId
|
||||
const held = session({
|
||||
id: 'held-on-app',
|
||||
name: 'session-905',
|
||||
pending_workspace_id: 'forkA',
|
||||
transient: true
|
||||
})
|
||||
sessionState.sessions.push(held)
|
||||
let createdId: string | undefined
|
||||
try {
|
||||
holdSessionUntilStarted('held-on-app')
|
||||
const tabs = [
|
||||
{ id: 'session', url: '/apps_raw/edit/u/me/app', loc: '/apps_raw/edit/u/me/app' }
|
||||
]
|
||||
setSessionTabs('held-on-app', tabs, 'session')
|
||||
expect(held.transient).toBe(true)
|
||||
createdId = createSession().id
|
||||
expect(createdId).not.toBe('held-on-app')
|
||||
expect(sessionState.sessions).toContain(held)
|
||||
} finally {
|
||||
sessionState.sessions = sessionState.sessions.filter(
|
||||
(s) => s.id !== 'held-on-app' && s.id !== createdId
|
||||
)
|
||||
sessionState.currentSessionId = prevCurrent
|
||||
restore()
|
||||
}
|
||||
})
|
||||
|
||||
it('clears the recovery notice off the draft it reuses, so `+` is not answered with "not found"', () => {
|
||||
const restore = withTwoFamilies('rootA')
|
||||
const prevCurrent = sessionState.currentSessionId
|
||||
|
||||
@@ -3,6 +3,7 @@ import { goto } from '$lib/navigation'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import {
|
||||
createSession,
|
||||
holdSessionUntilStarted,
|
||||
selectSession,
|
||||
sessionPageHref,
|
||||
sessionInCurrentFamily,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
sessionState,
|
||||
setSessionAutoSend,
|
||||
setSessionDraftPrompt,
|
||||
setNewSessionWorkspace,
|
||||
setSessionPendingWorkspace,
|
||||
type Session,
|
||||
type SessionTarget
|
||||
@@ -23,7 +25,14 @@ import type { OpenInSessionSource } from './OpenInSessionButton.svelte'
|
||||
import type { AppDomSelectorElement } from '../copilot/chat/context'
|
||||
|
||||
// `seedDomElement` rides only with `autoSend`: it is the chip of the prompt being sent.
|
||||
type SeedOpts = { seedPrompt?: string; autoSend?: boolean; seedDomElement?: AppDomSelectorElement }
|
||||
// `untilStarted` keeps the session out of storage until the user starts it, for an item that
|
||||
// is kept on its own (a new app's draft) and so loses nothing when the session goes.
|
||||
type SeedOpts = {
|
||||
seedPrompt?: string
|
||||
autoSend?: boolean
|
||||
seedDomElement?: AppDomSelectorElement
|
||||
untilStarted?: boolean
|
||||
}
|
||||
|
||||
// The session/navigation switch turns the global rail into either the workspace
|
||||
// navigation (navigation mode) or the sessions sidebar (session mode). Session
|
||||
@@ -222,7 +231,10 @@ async function sessionHrefFor(
|
||||
// straight onto what the caller wants (resetSessionPreviewTabs also writes
|
||||
// through a live runtime if one already exists for this id).
|
||||
const session = createSession()
|
||||
if (workspaceId) setSessionPendingWorkspace(session.id, workspaceId)
|
||||
if (opts?.untilStarted) {
|
||||
holdSessionUntilStarted(session.id)
|
||||
if (workspaceId) setNewSessionWorkspace(session.id, workspaceId)
|
||||
} else if (workspaceId) setSessionPendingWorkspace(session.id, workspaceId)
|
||||
if (opts?.seedPrompt) {
|
||||
setSessionDraftPrompt(session.id, opts.seedPrompt)
|
||||
if (opts.autoSend) setSessionAutoSend(session.id, opts.seedDomElement)
|
||||
@@ -253,7 +265,8 @@ export async function openSourceInSession(
|
||||
const opts = {
|
||||
seedPrompt: overrides?.seedPrompt ?? source.seedPrompt,
|
||||
autoSend: overrides?.autoSend ?? source.autoSend,
|
||||
seedDomElement: overrides?.seedDomElement
|
||||
seedDomElement: overrides?.seedDomElement,
|
||||
untilStarted: overrides?.untilStarted
|
||||
}
|
||||
if (source.target) {
|
||||
await openEditorInSession(
|
||||
|
||||
@@ -576,7 +576,12 @@
|
||||
if (saved.state === 'failed') {
|
||||
throw new Error(saved.failureMessage ?? 'the app could not be saved')
|
||||
}
|
||||
await openEditorInSession({ kind: 'raw_app', path }, workspace, { new_draft: 'true' })
|
||||
await openEditorInSession(
|
||||
{ kind: 'raw_app', path },
|
||||
workspace,
|
||||
{ new_draft: 'true' },
|
||||
{ untilStarted: true }
|
||||
)
|
||||
return
|
||||
} catch (e) {
|
||||
sendUserToast(`Could not open the AI session: ${e?.body ?? e?.message ?? e}`, true)
|
||||
|
||||
Reference in New Issue
Block a user