From c8622df762b953bfea4ba0dbc7097b123f29a288 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 4 Feb 2026 07:59:42 -0600 Subject: [PATCH] fix(app): file tree not staying in sync across projects/sessions --- packages/app/src/context/layout.tsx | 46 ++++++++ packages/app/src/pages/layout.tsx | 5 +- packages/app/src/pages/session.tsx | 159 ++++++++++++++++++---------- 3 files changed, 154 insertions(+), 56 deletions(-) diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 71f3f6cff..e2fd0a7f4 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -33,6 +33,8 @@ type SessionTabs = { type SessionView = { scroll: Record reviewOpen?: string[] + pendingMessage?: string + pendingMessageAt?: number } type TabHandoff = { @@ -128,6 +130,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( ) const MAX_SESSION_KEYS = 50 + const PENDING_MESSAGE_TTL_MS = 2 * 60 * 1000 const meta = { active: undefined as string | undefined, pruned: false } const used = new Map() @@ -555,6 +558,49 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( setStore("mobileSidebar", "opened", (x) => !x) }, }, + pendingMessage: { + set(sessionKey: string, messageID: string) { + const at = Date.now() + touch(sessionKey) + const current = store.sessionView[sessionKey] + if (!current) { + setStore("sessionView", sessionKey, { + scroll: {}, + pendingMessage: messageID, + pendingMessageAt: at, + }) + prune(meta.active ?? sessionKey) + return + } + + setStore( + "sessionView", + sessionKey, + produce((draft) => { + draft.pendingMessage = messageID + draft.pendingMessageAt = at + }), + ) + }, + consume(sessionKey: string) { + const current = store.sessionView[sessionKey] + const message = current?.pendingMessage + const at = current?.pendingMessageAt + if (!message || !at) return + + setStore( + "sessionView", + sessionKey, + produce((draft) => { + delete draft.pendingMessage + delete draft.pendingMessageAt + }), + ) + + if (Date.now() - at > PENDING_MESSAGE_TTL_MS) return + return message + }, + }, view(sessionKey: string | Accessor) { const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index c565d197f..1c5edbf2b 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1864,7 +1864,10 @@ export default function Layout(props: ParentProps) { getLabel={messageLabel} onMessageSelect={(message) => { if (!isActive()) { - sessionStorage.setItem("opencode.pendingMessage", `${props.session.id}|${message.id}`) + layout.pendingMessage.set( + `${base64Encode(props.session.directory)}/${props.session.id}`, + message.id, + ) navigate(`${props.slug}/session/${props.session.id}`) return } diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 2143cd34b..7ff4bebb4 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -76,10 +76,31 @@ import { same } from "@/utils/same" type DiffStyle = "unified" | "split" +type HandoffSession = { + prompt: string + files: Record +} + +const HANDOFF_MAX = 40 + const handoff = { - prompt: "", - terminals: [] as string[], - files: {} as Record, + session: new Map(), + terminal: new Map(), +} + +const touch = (map: Map, key: K, value: V) => { + map.delete(key) + map.set(key, value) + while (map.size > HANDOFF_MAX) { + const first = map.keys().next().value + if (first === undefined) return + map.delete(first) + } +} + +const setSessionHandoff = (key: string, patch: Partial) => { + const prev = handoff.session.get(key) ?? { prompt: "", files: {} } + touch(handoff.session, key, { ...prev, ...patch }) } interface SessionReviewTabProps { @@ -793,8 +814,10 @@ export default function Page() { const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs createEffect(() => { - if (!params.id) return - sync.session.sync(params.id) + sdk.directory + const id = params.id + if (!id) return + sync.session.sync(id) }) createEffect(() => { @@ -862,10 +885,22 @@ export default function Page() { createEffect( on( - () => params.id, + sessionKey, () => { setStore("messageId", undefined) setStore("expanded", {}) + setUi("autoCreated", false) + }, + { defer: true }, + ), + ) + + createEffect( + on( + () => params.dir, + (dir) => { + if (!dir) return + setStore("newSessionWorktree", "main") }, { defer: true }, ), @@ -1373,12 +1408,15 @@ export default function Page() { activeDiff: undefined as string | undefined, }) - const reviewScroll = () => tree.reviewScroll - const setReviewScroll = (value: HTMLDivElement | undefined) => setTree("reviewScroll", value) - const pendingDiff = () => tree.pendingDiff - const setPendingDiff = (value: string | undefined) => setTree("pendingDiff", value) - const activeDiff = () => tree.activeDiff - const setActiveDiff = (value: string | undefined) => setTree("activeDiff", value) + createEffect( + on( + sessionKey, + () => { + setTree({ reviewScroll: undefined, pendingDiff: undefined, activeDiff: undefined }) + }, + { defer: true }, + ), + ) const showAllFiles = () => { if (fileTreeTab() !== "changes") return @@ -1399,8 +1437,8 @@ export default function Page() { view={view} diffStyle={layout.review.diffStyle()} onDiffStyleChange={layout.review.setDiffStyle} - onScrollRef={setReviewScroll} - focusedFile={activeDiff()} + onScrollRef={(el) => setTree("reviewScroll", el)} + focusedFile={tree.activeDiff} onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })} comments={comments.all()} focusedComment={comments.focus()} @@ -1450,7 +1488,7 @@ export default function Page() { } const reviewDiffTop = (path: string) => { - const root = reviewScroll() + const root = tree.reviewScroll if (!root) return const id = reviewDiffId(path) @@ -1466,7 +1504,7 @@ export default function Page() { } const scrollToReviewDiff = (path: string) => { - const root = reviewScroll() + const root = tree.reviewScroll if (!root) return false const top = reviewDiffTop(path) @@ -1480,24 +1518,23 @@ export default function Page() { const focusReviewDiff = (path: string) => { const current = view().review.open() ?? [] if (!current.includes(path)) view().review.setOpen([...current, path]) - setActiveDiff(path) - setPendingDiff(path) + setTree({ activeDiff: path, pendingDiff: path }) } createEffect(() => { - const pending = pendingDiff() + const pending = tree.pendingDiff if (!pending) return - if (!reviewScroll()) return + if (!tree.reviewScroll) return if (!diffsReady()) return const attempt = (count: number) => { - if (pendingDiff() !== pending) return + if (tree.pendingDiff !== pending) return if (count > 60) { - setPendingDiff(undefined) + setTree("pendingDiff", undefined) return } - const root = reviewScroll() + const root = tree.reviewScroll if (!root) { requestAnimationFrame(() => attempt(count + 1)) return @@ -1515,7 +1552,7 @@ export default function Page() { } if (Math.abs(root.scrollTop - top) <= 1) { - setPendingDiff(undefined) + setTree("pendingDiff", undefined) return } @@ -1558,13 +1595,17 @@ export default function Page() { void sync.session.diff(id) }) + let treeDir: string | undefined createEffect(() => { + const dir = sdk.directory if (!isDesktop()) return if (!layout.fileTree.opened()) return if (sync.status === "loading") return fileTreeTab() - void file.tree.list("") + const refresh = treeDir !== dir + treeDir = dir + void (refresh ? file.tree.refresh("") : file.tree.list("")) }) const autoScroll = createAutoScroll({ @@ -1599,6 +1640,18 @@ export default function Page() { let scrollSpyFrame: number | undefined let scrollSpyTarget: HTMLDivElement | undefined + createEffect( + on( + sessionKey, + () => { + if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame) + scrollSpyFrame = undefined + scrollSpyTarget = undefined + }, + { defer: true }, + ), + ) + const anchor = (id: string) => `message-${id}` const setScrollRef = (el: HTMLDivElement | undefined) => { @@ -1713,20 +1766,14 @@ export default function Page() { window.history.replaceState(null, "", `#${anchor(id)}`) } - createEffect(() => { - const sessionID = params.id - if (!sessionID) return - const raw = sessionStorage.getItem("opencode.pendingMessage") - if (!raw) return - const parts = raw.split("|") - const pendingSessionID = parts[0] - const messageID = parts[1] - if (!pendingSessionID || !messageID) return - if (pendingSessionID !== sessionID) return - - sessionStorage.removeItem("opencode.pendingMessage") - setUi("pendingMessage", messageID) - }) + createEffect( + on(sessionKey, (key) => { + if (!params.id) return + const messageID = layout.pendingMessage.consume(key) + if (!messageID) return + setUi("pendingMessage", messageID) + }), + ) const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => { const root = scroller @@ -1940,7 +1987,7 @@ export default function Page() { createEffect(() => { if (!prompt.ready()) return - handoff.prompt = previewPrompt() + setSessionHandoff(sessionKey(), { prompt: previewPrompt() }) }) createEffect(() => { @@ -1960,20 +2007,22 @@ export default function Page() { return language.t("terminal.title") } - handoff.terminals = terminal.all().map(label) + touch(handoff.terminal, params.dir!, terminal.all().map(label)) }) createEffect(() => { if (!file.ready()) return - handoff.files = Object.fromEntries( - tabs() - .all() - .flatMap((tab) => { - const path = file.pathFromTab(tab) - if (!path) return [] - return [[path, file.selectedLines(path) ?? null] as const] - }), - ) + setSessionHandoff(sessionKey(), { + files: Object.fromEntries( + tabs() + .all() + .flatMap((tab) => { + const path = file.pathFromTab(tab) + if (!path) return [] + return [[path, file.selectedLines(path) ?? null] as const] + }), + ), + }) }) onCleanup(() => { @@ -2049,7 +2098,7 @@ export default function Page() { diffs={diffs} view={view} diffStyle="unified" - focusedFile={activeDiff()} + focusedFile={tree.activeDiff} onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })} comments={comments.all()} focusedComment={comments.focus()} @@ -2483,7 +2532,7 @@ export default function Page() { when={prompt.ready()} fallback={
- {handoff.prompt || language.t("prompt.loading")} + {handoff.session.get(sessionKey())?.prompt || language.t("prompt.loading")}
} > @@ -2734,7 +2783,7 @@ export default function Page() { const p = path() if (!p) return null if (file.ready()) return file.selectedLines(p) ?? null - return handoff.files[p] ?? null + return handoff.session.get(sessionKey())?.files[p] ?? null }) let wrap: HTMLDivElement | undefined @@ -3228,7 +3277,7 @@ export default function Page() { allowed={diffFiles()} kinds={kinds()} draggable={false} - active={activeDiff()} + active={tree.activeDiff} onFileClick={(node) => focusReviewDiff(node.path)} /> @@ -3288,7 +3337,7 @@ export default function Page() { fallback={
- + {(title) => (
{title}