Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions packages/app-bundle/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@
"packages/app/src/context/layout-side-panel-tabs.ts": "e8426fadbdf9e7c49bf457e00ec92793475b44bcc11548e8b5ef9a9fa461be76",
"packages/app/src/context/layout-tabs.test.ts": "4d9fdbe963306f164b2f48eac3b6a28c5a703c1758bf14b2cc4796720d8fa72c",
"packages/app/src/context/layout-tabs.ts": "741506ce165f68cdb0b8f2931a2dad3e26c05880f9286c3daf04ec979bac21c5",
"packages/app/src/context/layout.tsx": "7767c8b5f64efd63390cbe99128686bec2f1da7e8f2b21530df4c3fd67d171bb",
"packages/app/src/context/layout.tsx": "9bb24aa18442cf0dba1dc749f49060de4d45e50bcd5b0e148325e8e4611fa2ca",
"packages/app/src/context/local-agent.test.ts": "a5a9d60bb4401d409218cc247c1cc06ede8b7878ddf01dc6080328f54eb9cade",
"packages/app/src/context/local-agent.ts": "0aab67e695dc3bb45a733ac0df80a0a5e14cfe29b2375b6dc3cd07fbccea33e2",
"packages/app/src/context/local.tsx": "3ab8b9fc2db082df4ba485373679f00a95d0ffe3c691a0afee1dc53db7eabd9e",
Expand Down Expand Up @@ -636,11 +636,11 @@
"packages/app/src/components/session/session-context-tab.tsx": "227243b178b517f067d9ae0ae0eec3c559beeb6681828158b0600a17e98e7f81",
"packages/app/src/components/session/session-header.tsx": "7113b3eca8ec950d4a547a2b7e2661b5e47f593653e12fca659b5b5f92cb90a6",
"packages/app/src/components/session/session-new-view.tsx": "9510a4f550a3f0d4791e98e8025666f09d70a60fb66f193e48ee61feddae5a57",
"packages/app/src/components/session/session-preview-tab.tsx": "d3a210598d181c0aa29e9f3b5db3cd691169f3ee467001313a3d9a24bee93dbe",
"packages/app/src/components/session/session-preview-tab.tsx": "c336b2c4ec398190480579bb91f2bcd6b26ddd6b2e8b8bbb9654bd38128612aa",
"packages/app/src/components/session/session-preview-tabs.test.ts": "773fc0116c5f61302eaa4a09fd5f91e6b26dee978c89e29c21714243ae3a5653",
"packages/app/src/components/session/session-preview-tabs.ts": "672d147a6f1f46a7ac2eff7aff9da6cc069c476ff918e5ff4aed3a997310e6fe",
"packages/app/src/components/session/session-preview-tree.test.ts": "6d1677caa56ec89fa5fcc8fc2f26fdcc1bc0a23217ce9c6d92888b272523c00a",
"packages/app/src/components/session/session-preview-tree.ts": "13930fcd8a8ae9df4c37ba9ea306e162fd08f0e429249ddea21d937675d3ad7e",
"packages/app/src/components/session/session-preview-tree.test.ts": "cc3b40facb9e6cbc1dcda2254abc7a47a547ffdc1c4ded90aaa1fabefbda5e54",
"packages/app/src/components/session/session-preview-tree.ts": "a0136354eb5c90b0b50e333e9fc145984b37e38861dccff05b83c92837e7d339",
"packages/app/src/components/session/session-sortable-tab.tsx": "a015277347fe679ac1afa1372202de49fc98a0930ac10682c8897f70a0300909",
"packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx": "08db0e378c3e07d243121f40c77e153bafe897e5e2ececd48a3e00786793032b",
"packages/app/src/components/session/use-context-warning.ts": "af7a6d0159a5541aa02ad4d08fd694af1cc4853fc1c0a70763bc264a634d1c53",
Expand Down Expand Up @@ -694,7 +694,7 @@
"packages/app/src/pages/session/session-panel-width.test.ts": "9482afba7fbce254cbbd21ef885e9e9620b981616681635376f466478f611155",
"packages/app/src/pages/session/session-panel-width.ts": "02ee3a02ed78db2eac47c2528055cf78aef95f389641e929ddc56c17471dcbe2",
"packages/app/src/pages/session/session-side-panel-structure.test.ts": "b2d1d007d9200d0ac4a0889e45c2dbe57a324a10cb26ae9ab8f420d3c890402b",
"packages/app/src/pages/session/session-side-panel.tsx": "b295340d9a39ba37c9e025d6360669d2a879284c4e60df795b7902c5a4cef3fc",
"packages/app/src/pages/session/session-side-panel.tsx": "d63fd6dc12f9420facd9c2e20fd01ad4895829b9463b08574bed02a661887c2b",
"packages/app/src/pages/session/terminal-panel-v2.tsx": "68dad9307f1d2abf3ff9248e451bd08005acf01ddc46b30a6d01b58f4a90dce0",
"packages/app/src/pages/session/use-amicode-commands.test.ts": "65671d054c404edd2b799b0f6591a9c055e6ecb454a674fa0bd9a7fa6f68e111",
"packages/app/src/pages/session/use-amicode-commands.tsx": "88bfdad9ae6dfd920b8e6a14ffebd2cf9e52b8f67bb9284cb358ad940643ca67",
Expand Down Expand Up @@ -873,7 +873,7 @@
"packages/ui/src/v2/components/text-shimmer-v2.tsx"
],
"exceptions": [],
"extracted_at": "2026-09-24T04:46:38.771Z",
"extracted_at": "2026-09-29T20:36:51.065Z",
"per_package": {
"packages/app": {
"A": 186,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* retained pool and are relocated between leaf slots without being recreated.
*/

import { createEffect, createSignal, For, on, onCleanup, onMount, Show, type Accessor, type JSX } from "solid-js"
import { createEffect, createSignal, For, onCleanup, onMount, Show, type Accessor, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { Portal } from "solid-js/web"
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
Expand All @@ -22,9 +22,12 @@ import {
createPreviewWorkspace,
movePreviewTab,
openPreviewPath,
previewActiveAfterClose,
previewLeafContaining,
previewLeaves,
previewMinimumExtent,
previewMissingPaths,
previewRestoreTarget,
previewTabCount,
removePreviewPath,
resizePreviewSplit,
Expand Down Expand Up @@ -96,6 +99,19 @@ export function SessionPreviewTab(props: {
* a preview tab within the pane). Composed with isSelected to form the
* full `active` signal so hide/show fires on side-panel tab switches too. */
panelVisible?: () => boolean
/** Persisted open-paths list (from the session view). Restores every open
* preview tab across remounts and editor reloads; when absent the tab keeps
* its old purely-local, non-persisted behavior. #1398 + reload persistence. */
openPaths?: Accessor<string[]>
onOpenedPathsChange?: (paths: string[]) => void
/** Update the persisted active file (previewFile). Called when the active tab
* changes — including close, so a closed tab's path is never left as the
* active file to be re-opened on the next re-instantiation. */
onActiveChange?: (path: string | null) => void
/** True once the persisted layout store has hydrated. Reconciliation of the
* persisted open-paths list is held until this flips true (the store loads
* async); reconciling earlier clobbers the list to just the active file. */
hydrated?: () => boolean
}) {
const platform = usePlatform()
const [dirtyPaths, setDirtyPaths] = createStore<Record<string, boolean>>({})
Expand All @@ -118,6 +134,15 @@ export function SessionPreviewTab(props: {

const openedPaths = () => previewLeaves(workspace().tree).flatMap((leaf) => leaf.tabs)

// Persist the current open set to the session view. Gated on hydration so a
// pre-hydration transient can't clobber the stored list; called EXPLICITLY at
// open/close (not from a reactive openedPaths watcher) so the stored list is
// always current and the restore below can never re-add a just-closed tab.
const syncOpenPaths = () => {
if (props.hydrated && !props.hydrated()) return
props.onOpenedPathsChange?.(openedPaths())
}

const openPath = (path: string) => {
const current = workspace()
if (!previewLeafContaining(current.tree, path) && previewTabCount(current.tree) === MAX_PREVIEW_TABS) {
Expand All @@ -126,16 +151,50 @@ export function SessionPreviewTab(props: {
}
setWorkspace(openPreviewPath(current, path))
setCapacityMessage(null)
syncOpenPaths()
}

createEffect(
on(
() => props.previewFile(),
(path) => {
if (path) openPath(path)
},
),
)
// The SOLE opener on mount / session re-instantiation. Restores the persisted
// open set — plus the active file, which covers a net-new open that mounted
// the Preview tab (the bridge sets previewFile before this component exists,
// and the mount message is missed) — by MERGING the target into the live
// workspace and focusing the active tab.
//
// Why this shape (each clause earns its place against a failure we hit live):
// - Signature-guarded → runs once per DISTINCT target, never on its own
// workspace writes, so it cannot loop (the freeze) and cannot re-open on a
// plain tab switch (the reopen/shift). #1288 re-instantiation is inert here.
// - Reactive on the persisted accessors → fires when they resolve, so it
// survives async store hydration AND late session-key resolution.
// - Merge-only (never removes) → a file closed via removePath (which writes
// the shrunk list + reassigns previewActive) is absent from the target and
// is never re-added.
// - There is deliberately NO separate `on(previewFile)` open effect: that was
// the reopen engine. Live opens arrive through the mount message listener;
// the first-open-that-mounts is the `active` term of the target here.
// Split-pane / zoom / scroll are NOT restored — only the flat open set (#981).
let restoredSignature = ""
createEffect(() => {
const persisted = props.openPaths?.() ?? []
const active = props.previewFile()
const target = previewRestoreTarget(persisted, active)
const signature = target.join("\u0000")
if (signature === restoredSignature) return
restoredSignature = signature
if (target.length === 0) return
const missing = previewMissingPaths(target, openedPaths())
if (missing.length > 0) {
setWorkspace((current) => {
let next = current
for (const path of missing) next = openPreviewPath(next, path)
if (active) next = openPreviewPath(next, active)
return next
})
}
// Persist a net-new active file that mounted the tab (not yet in the list).
// Deduped in the setter, so a plain re-instantiation writes nothing.
if (active && !persisted.includes(active)) syncOpenPaths()
})

onMount(() => {
const handlePreviewFile = (event: MessageEvent) => {
Expand Down Expand Up @@ -168,9 +227,16 @@ export function SessionPreviewTab(props: {
}

const removePath = (path: string) => {
const wasActive = props.previewFile()
setWorkspace((current) => removePreviewPath(current, path))
setDirtyPaths(path, false)
setHostMounts(path, undefined)
syncOpenPaths()
// If we just closed the active file, move the active pointer to a remaining
// tab (or null). Otherwise it stays pointing at the closed path and would be
// re-opened as the restore target on the next re-instantiation / tab switch.
const nextActive = previewActiveAfterClose(path, wasActive, openedPaths())
if (nextActive !== wasActive) props.onActiveChange?.(nextActive)
}

const closePath = (path: string) => {
Expand Down Expand Up @@ -474,7 +540,10 @@ export function SessionPreviewTab(props: {
>
<Tabs
value={leaf.selectedPath ?? undefined}
onChange={(path) => setWorkspace((current) => selectPreviewPath(current, leaf.id, path))}
onChange={(path) => {
setWorkspace((current) => selectPreviewPath(current, leaf.id, path))
props.onActiveChange?.(path)
}}
class="shrink-0"
classList={{ "preview-tab-strip": true }}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,13 @@ import {
createPreviewWorkspace,
movePreviewTab,
openPreviewPath,
previewActiveAfterClose,
previewLeafByID,
previewLeaves,
previewMinimumExtent,
previewMissingPaths,
previewRestoreTarget,
removePreviewPath,
setPreviewLeafZoom,
} from "./session-preview-tree"

Expand Down Expand Up @@ -89,3 +93,79 @@ describe("Preview workspace pane tree", () => {
expect(previewLeafByID(transferred.tree, "root")?.zoom).toBe(90)
})
})

// The flat list of open paths is what the session view persists and what the
// SessionPreviewTab restores from on reload/remount. These tests pin the
// round-trip invariant that restore depends on (split-pane layout is
// intentionally NOT persisted — only this flat list + the active tab).
describe("Preview open-paths persistence round-trip", () => {
const openedPaths = (workspace: ReturnType<typeof createPreviewWorkspace>) =>
previewLeaves(workspace.tree).flatMap((leaf) => leaf.tabs)

test("restores the open-paths list in order, activating the last", () => {
const paths = ["file://paper3.tex", "file://paper3.pdf"]
const restored = createPreviewWorkspace(paths)
expect(openedPaths(restored)).toEqual(paths)
const root = previewLeafByID(restored.tree, restored.focusedLeafID)
expect(root?.selectedPath).toBe("file://paper3.pdf")
})

test("an empty persisted list restores to an empty workspace", () => {
const restored = createPreviewWorkspace([])
expect(openedPaths(restored)).toEqual([])
expect(previewLeafByID(restored.tree, restored.focusedLeafID)?.selectedPath).toBeNull()
})

test("re-opening the persisted active path after restore is idempotent (no duplicate)", () => {
const restored = createPreviewWorkspace(["file://paper3.tex", "file://paper3.pdf"])
const refocused = openPreviewPath(restored, "file://paper3.tex")
expect(openedPaths(refocused)).toEqual(["file://paper3.tex", "file://paper3.pdf"])
expect(previewLeafByID(refocused.tree, refocused.focusedLeafID)?.selectedPath).toBe("file://paper3.tex")
})

test("closing a tab shrinks the persisted list", () => {
const restored = createPreviewWorkspace(["file://paper3.tex", "file://paper3.pdf"])
const afterClose = removePreviewPath(restored, "file://paper3.pdf")
expect(openedPaths(afterClose)).toEqual(["file://paper3.tex"])
})
})

// The pure restore/close decisions the persistence uses. Each test pins a
// specific regression we hit by hand across many rebuilds.
describe("preview persistence decisions", () => {
describe("previewRestoreTarget", () => {
test("folds in a net-new active file (first open that mounted the tab)", () => {
expect(previewRestoreTarget(["a"], "c")).toEqual(["a", "c"])
})
test("leaves the list unchanged when the active file is already tracked", () => {
expect(previewRestoreTarget(["a", "b"], "b")).toEqual(["a", "b"])
})
test("no active file → just the persisted list", () => {
expect(previewRestoreTarget(["a", "b"], null)).toEqual(["a", "b"])
})
})

describe("previewMissingPaths", () => {
test("restores the FULL set when only the active file is open (the 1-of-N bug)", () => {
expect(previewMissingPaths(["a", "b", "c"], ["c"])).toEqual(["a", "b"])
})
test("re-instantiation with everything already open opens nothing (no reopen)", () => {
expect(previewMissingPaths(["a", "b"], ["a", "b"])).toEqual([])
})
test("opens only the not-yet-open paths", () => {
expect(previewMissingPaths(["a", "b", "c"], ["a"])).toEqual(["b", "c"])
})
})

describe("previewActiveAfterClose", () => {
test("closing the active tab moves active to the last remaining tab", () => {
expect(previewActiveAfterClose("b", "b", ["a", "c"])).toBe("c")
})
test("closing the last/active tab clears active to null (no reopen pointer)", () => {
expect(previewActiveAfterClose("a", "a", [])).toBeNull()
})
test("closing a background tab leaves active unchanged", () => {
expect(previewActiveAfterClose("a", "b", ["b", "c"])).toBe("b")
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,36 @@ export const createPreviewWorkspace = (paths: readonly string[] = []): PreviewWo
nextPaneID: 1,
})

// ── Preview persistence decisions (pure) ─────────────────────────────────────
// These are the restore/close decisions the SessionPreviewTab persistence uses.
// Kept pure and here (not inline in the component) so the exact regressions we
// hit — "only the active file restored", "closed tab reopened", "net-new open
// lost" — are unit-testable without a reactive harness.

/** The set to restore into the workspace: the persisted open paths, plus the
* active file when it is a net-new open not yet tracked (a first open that
* mounted the Preview tab). Restoring [a,b,c] when only the active [c] is known
* must still yield all of [a,b,c] — never collapse to the active one. */
export const previewRestoreTarget = (persisted: readonly string[], active: string | null): string[] =>
active && !persisted.includes(active) ? [...persisted, active] : [...persisted]

/** Which of `target` are not yet open — the paths to merge into the workspace.
* On a plain re-instantiation (everything already open) this is empty, so the
* restore is a no-op and nothing re-opens. */
export const previewMissingPaths = (target: readonly string[], open: readonly string[]): string[] => {
const openSet = new Set(open)
return target.filter((path) => !openSet.has(path))
}

/** The active file after closing `closed`: unchanged when a background tab was
* closed; otherwise the last remaining tab, or null if none remain — so a
* closed active file is never left as the pointer that gets re-opened. */
export const previewActiveAfterClose = (
closed: string,
wasActive: string | null,
remaining: readonly string[],
): string | null => (closed === wasActive ? (remaining[remaining.length - 1] ?? null) : wasActive)

export const previewLeaves = (tree: PreviewPane): PreviewLeaf[] => {
if (tree.kind === "leaf") return [tree]
return [...previewLeaves(tree.first), ...previewLeaves(tree.second)]
Expand Down
Loading
Loading