fix(sessions): keep "View runs" inside the session, and record released cells only where they are read

The schedule editor's "View runs" anchor navigated the top-level document, which
from a session tab means leaving the session — and onto the navigation workspace,
not the acting one. A host that is a page of its own now takes the destination
instead and re-points its tab at the Runs page, like the way back to the list.

The released-cell record is scoped to the kinds whose settle reads it back:
elsewhere it retained a whole flow or app draft, or a variable's decrypted secret
that the release is otherwise the end of.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4
This commit is contained in:
Diego Imbert
2026-09-06 21:04:12 +02:00
co-authored by Claude Opus 5
parent 3581a0e380
commit d6388514ca
4 changed files with 39 additions and 10 deletions
@@ -22,6 +22,7 @@
import type { EntityEditorKind } from './previewRouter'
import type { EntityToolEffect } from './previewReload'
import { withMenuHidden } from './sessionMode.svelte'
import { pageHref, RUNS_PATH } from './previewPaths'
import ArtifactViewer from '../copilot/chat/artifacts/ArtifactViewer.svelte'
import { setOverlayHost } from '../common/overlayHost.svelte'
@@ -193,6 +194,21 @@
: undefined
)
// "View runs" from a hosted editor: the Runs page is a preview page like any
// other, so it belongs in the preview rather than in the top-level document the
// anchor would navigate — and the frame gives it the acting workspace. Re-points
// this tab, like the way back to the list: the runs are where the editor was.
const viewRuns = $derived(
runtime
? (query: string) =>
runtime.previewTabs.navigate({
type: 'page',
href: `${pageHref(RUNS_PATH)}?${query}`,
label: 'Runs'
})
: undefined
)
// What an editor reports is about an item, not about this tab: other warm
// sessions can hold the same one, and by the time the write it awaited returns
// this tab may hold something else. The page resolves both by matching on the
@@ -413,6 +429,7 @@
onBack={backToList}
onRemoved={(from, ws) => reportRemoved?.('trigger_schedule', from, ws)}
onSavedTo={(to, from, ws) => reportSaved?.('trigger_schedule', to, from, ws)}
onViewRuns={viewRuns}
/>
{/await}
{:else if slot.entityKind === 'resource'}
@@ -10,7 +10,8 @@
workspaceId,
onBack,
onRemoved,
onSavedTo
onSavedTo,
onViewRuns
}: {
/** The schedule this tab edits (the row its location deep-links). */
path: string
@@ -29,6 +30,8 @@
* ACTIVE PREVIEW and the draft key all do — and a plain save moves none but
* leaves the others holding a baseline the deploy has replaced. */
onSavedTo?: (newPath: string, fromPath: string, fromWorkspace: string) => void
/** Show this schedule's runs, given the Runs page's filter query. */
onViewRuns?: (query: string) => void
} = $props()
// Captured at init, so it must read the current prop rather than close over it.
@@ -70,6 +73,7 @@
useDrawer={false}
showDraftBanner
{onRemoved}
{onViewRuns}
onUpdate={(saved: string | undefined, from: string, fromWs: string, keptEdit: boolean) => {
if (from === path && !keptEdit) generation++
// A draft-only schedule opens with its path editable (saving CREATEs), so
@@ -76,7 +76,12 @@
// The schedule is gone — a draft-only one whose draft the banner discarded —
// with the workspace and path it was showing. A host addressing it by those
// (a session tab) has to stop showing it.
onRemoved = undefined
onRemoved = undefined,
// Where "View runs" goes, given the filter query for this schedule. A host
// mounted inside a page of its own (a session tab) takes it: the plain
// anchor navigates the whole app, which from there means leaving the session
// — and lands on the navigation workspace rather than the acting one.
onViewRuns = undefined
} = $props()
let optionTabSelected:
@@ -893,13 +898,15 @@
>
{#snippet extra()}
{#if !drawerLoading && edit}
{@const runsQuery = `schedule_path=${path}&job_trigger_kind=schedule&show_future_jobs=true`}
<div class="mr-12 flex flex-row gap-3">
<Button
size="sm"
variant="default"
startIcon={{ icon: List }}
disabled={!allowSchedule || pathError != '' || emptyString(script_path)}
href={`${base}/runs/?schedule_path=${path}&job_trigger_kind=schedule&show_future_jobs=true`}
href={onViewRuns ? undefined : `${base}/runs/?${runsQuery}`}
on:click={() => onViewRuns?.(runsQuery)}
>
View runs
</Button>
+8 -7
View File
@@ -143,13 +143,12 @@ function noteMarker(set: Set<string>, key: string): void {
}
}
/**
* What a cell held when its last holder let go. An editor stays editable while
* its save is in flight, so a tab closed in that window releases a cell that may
* carry an edit newer than the write — and once released there is no handle left
* to read it from. Same cap and reason as the marker sets; taken by whoever asks
* (`settleDraftAfterWrite`), and dropped when the key is acquired again, since a
* live entry answers for itself.
* What a cell held when its last holder let go: an editor stays editable while its
* save is in flight, so a tab left in that window releases a cell carrying an edit
* newer than the write. Only for the kinds whose settle reads it back — elsewhere
* it would retain a whole flow or app draft, or a variable's decrypted secret.
*/
const RELEASE_RECORDED_KINDS: readonly UserDraftItemKind[] = ['resource', 'variable']
const releasedValues = new Map<string, unknown>()
/** Read once: what it describes is settled by the caller that reads it. */
function takeReleasedValue<V>(
@@ -1060,7 +1059,9 @@ function releaseEntry(mk: string): void {
// only here, once, at refcount 0. This is what lets multiple holders (warm
// session previews + the nav editor) share the entry and drop in any order.
if (entry.count <= 0) {
noteReleasedValue(mk, snapshotDraftValue(entry.state.val))
if (RELEASE_RECORDED_KINDS.includes(entry.itemKind)) {
noteReleasedValue(mk, snapshotDraftValue(entry.state.val))
}
// The live entry was authoritative while mounted; once gone, drop any
// cached write for this key so a later read falls back to the server
// rather than a value the editor may have changed in the meantime.