fix(app): file tree not staying in sync across projects/sessions

This commit is contained in:
Adam
2026-02-04 07:59:42 -06:00
parent c277ee8cbf
commit c8622df762
3 changed files with 154 additions and 56 deletions

View File

@@ -33,6 +33,8 @@ type SessionTabs = {
type SessionView = { type SessionView = {
scroll: Record<string, SessionScroll> scroll: Record<string, SessionScroll>
reviewOpen?: string[] reviewOpen?: string[]
pendingMessage?: string
pendingMessageAt?: number
} }
type TabHandoff = { type TabHandoff = {
@@ -128,6 +130,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
) )
const MAX_SESSION_KEYS = 50 const MAX_SESSION_KEYS = 50
const PENDING_MESSAGE_TTL_MS = 2 * 60 * 1000
const meta = { active: undefined as string | undefined, pruned: false } const meta = { active: undefined as string | undefined, pruned: false }
const used = new Map<string, number>() const used = new Map<string, number>()
@@ -555,6 +558,49 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setStore("mobileSidebar", "opened", (x) => !x) 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<string>) { view(sessionKey: string | Accessor<string>) {
const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey

View File

@@ -1864,7 +1864,10 @@ export default function Layout(props: ParentProps) {
getLabel={messageLabel} getLabel={messageLabel}
onMessageSelect={(message) => { onMessageSelect={(message) => {
if (!isActive()) { 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}`) navigate(`${props.slug}/session/${props.session.id}`)
return return
} }

View File

@@ -76,10 +76,31 @@ import { same } from "@/utils/same"
type DiffStyle = "unified" | "split" type DiffStyle = "unified" | "split"
type HandoffSession = {
prompt: string
files: Record<string, SelectedLineRange | null>
}
const HANDOFF_MAX = 40
const handoff = { const handoff = {
prompt: "", session: new Map<string, HandoffSession>(),
terminals: [] as string[], terminal: new Map<string, string[]>(),
files: {} as Record<string, SelectedLineRange | null>, }
const touch = <K, V>(map: Map<K, V>, 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<HandoffSession>) => {
const prev = handoff.session.get(key) ?? { prompt: "", files: {} }
touch(handoff.session, key, { ...prev, ...patch })
} }
interface SessionReviewTabProps { interface SessionReviewTabProps {
@@ -793,8 +814,10 @@ export default function Page() {
const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs
createEffect(() => { createEffect(() => {
if (!params.id) return sdk.directory
sync.session.sync(params.id) const id = params.id
if (!id) return
sync.session.sync(id)
}) })
createEffect(() => { createEffect(() => {
@@ -862,10 +885,22 @@ export default function Page() {
createEffect( createEffect(
on( on(
() => params.id, sessionKey,
() => { () => {
setStore("messageId", undefined) setStore("messageId", undefined)
setStore("expanded", {}) setStore("expanded", {})
setUi("autoCreated", false)
},
{ defer: true },
),
)
createEffect(
on(
() => params.dir,
(dir) => {
if (!dir) return
setStore("newSessionWorktree", "main")
}, },
{ defer: true }, { defer: true },
), ),
@@ -1373,12 +1408,15 @@ export default function Page() {
activeDiff: undefined as string | undefined, activeDiff: undefined as string | undefined,
}) })
const reviewScroll = () => tree.reviewScroll createEffect(
const setReviewScroll = (value: HTMLDivElement | undefined) => setTree("reviewScroll", value) on(
const pendingDiff = () => tree.pendingDiff sessionKey,
const setPendingDiff = (value: string | undefined) => setTree("pendingDiff", value) () => {
const activeDiff = () => tree.activeDiff setTree({ reviewScroll: undefined, pendingDiff: undefined, activeDiff: undefined })
const setActiveDiff = (value: string | undefined) => setTree("activeDiff", value) },
{ defer: true },
),
)
const showAllFiles = () => { const showAllFiles = () => {
if (fileTreeTab() !== "changes") return if (fileTreeTab() !== "changes") return
@@ -1399,8 +1437,8 @@ export default function Page() {
view={view} view={view}
diffStyle={layout.review.diffStyle()} diffStyle={layout.review.diffStyle()}
onDiffStyleChange={layout.review.setDiffStyle} onDiffStyleChange={layout.review.setDiffStyle}
onScrollRef={setReviewScroll} onScrollRef={(el) => setTree("reviewScroll", el)}
focusedFile={activeDiff()} focusedFile={tree.activeDiff}
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })} onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
comments={comments.all()} comments={comments.all()}
focusedComment={comments.focus()} focusedComment={comments.focus()}
@@ -1450,7 +1488,7 @@ export default function Page() {
} }
const reviewDiffTop = (path: string) => { const reviewDiffTop = (path: string) => {
const root = reviewScroll() const root = tree.reviewScroll
if (!root) return if (!root) return
const id = reviewDiffId(path) const id = reviewDiffId(path)
@@ -1466,7 +1504,7 @@ export default function Page() {
} }
const scrollToReviewDiff = (path: string) => { const scrollToReviewDiff = (path: string) => {
const root = reviewScroll() const root = tree.reviewScroll
if (!root) return false if (!root) return false
const top = reviewDiffTop(path) const top = reviewDiffTop(path)
@@ -1480,24 +1518,23 @@ export default function Page() {
const focusReviewDiff = (path: string) => { const focusReviewDiff = (path: string) => {
const current = view().review.open() ?? [] const current = view().review.open() ?? []
if (!current.includes(path)) view().review.setOpen([...current, path]) if (!current.includes(path)) view().review.setOpen([...current, path])
setActiveDiff(path) setTree({ activeDiff: path, pendingDiff: path })
setPendingDiff(path)
} }
createEffect(() => { createEffect(() => {
const pending = pendingDiff() const pending = tree.pendingDiff
if (!pending) return if (!pending) return
if (!reviewScroll()) return if (!tree.reviewScroll) return
if (!diffsReady()) return if (!diffsReady()) return
const attempt = (count: number) => { const attempt = (count: number) => {
if (pendingDiff() !== pending) return if (tree.pendingDiff !== pending) return
if (count > 60) { if (count > 60) {
setPendingDiff(undefined) setTree("pendingDiff", undefined)
return return
} }
const root = reviewScroll() const root = tree.reviewScroll
if (!root) { if (!root) {
requestAnimationFrame(() => attempt(count + 1)) requestAnimationFrame(() => attempt(count + 1))
return return
@@ -1515,7 +1552,7 @@ export default function Page() {
} }
if (Math.abs(root.scrollTop - top) <= 1) { if (Math.abs(root.scrollTop - top) <= 1) {
setPendingDiff(undefined) setTree("pendingDiff", undefined)
return return
} }
@@ -1558,13 +1595,17 @@ export default function Page() {
void sync.session.diff(id) void sync.session.diff(id)
}) })
let treeDir: string | undefined
createEffect(() => { createEffect(() => {
const dir = sdk.directory
if (!isDesktop()) return if (!isDesktop()) return
if (!layout.fileTree.opened()) return if (!layout.fileTree.opened()) return
if (sync.status === "loading") return if (sync.status === "loading") return
fileTreeTab() fileTreeTab()
void file.tree.list("") const refresh = treeDir !== dir
treeDir = dir
void (refresh ? file.tree.refresh("") : file.tree.list(""))
}) })
const autoScroll = createAutoScroll({ const autoScroll = createAutoScroll({
@@ -1599,6 +1640,18 @@ export default function Page() {
let scrollSpyFrame: number | undefined let scrollSpyFrame: number | undefined
let scrollSpyTarget: HTMLDivElement | 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 anchor = (id: string) => `message-${id}`
const setScrollRef = (el: HTMLDivElement | undefined) => { const setScrollRef = (el: HTMLDivElement | undefined) => {
@@ -1713,20 +1766,14 @@ export default function Page() {
window.history.replaceState(null, "", `#${anchor(id)}`) window.history.replaceState(null, "", `#${anchor(id)}`)
} }
createEffect(() => { createEffect(
const sessionID = params.id on(sessionKey, (key) => {
if (!sessionID) return if (!params.id) return
const raw = sessionStorage.getItem("opencode.pendingMessage") const messageID = layout.pendingMessage.consume(key)
if (!raw) return if (!messageID) return
const parts = raw.split("|") setUi("pendingMessage", messageID)
const pendingSessionID = parts[0] }),
const messageID = parts[1] )
if (!pendingSessionID || !messageID) return
if (pendingSessionID !== sessionID) return
sessionStorage.removeItem("opencode.pendingMessage")
setUi("pendingMessage", messageID)
})
const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => { const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
const root = scroller const root = scroller
@@ -1940,7 +1987,7 @@ export default function Page() {
createEffect(() => { createEffect(() => {
if (!prompt.ready()) return if (!prompt.ready()) return
handoff.prompt = previewPrompt() setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
}) })
createEffect(() => { createEffect(() => {
@@ -1960,20 +2007,22 @@ export default function Page() {
return language.t("terminal.title") return language.t("terminal.title")
} }
handoff.terminals = terminal.all().map(label) touch(handoff.terminal, params.dir!, terminal.all().map(label))
}) })
createEffect(() => { createEffect(() => {
if (!file.ready()) return if (!file.ready()) return
handoff.files = Object.fromEntries( setSessionHandoff(sessionKey(), {
tabs() files: Object.fromEntries(
.all() tabs()
.flatMap((tab) => { .all()
const path = file.pathFromTab(tab) .flatMap((tab) => {
if (!path) return [] const path = file.pathFromTab(tab)
return [[path, file.selectedLines(path) ?? null] as const] if (!path) return []
}), return [[path, file.selectedLines(path) ?? null] as const]
) }),
),
})
}) })
onCleanup(() => { onCleanup(() => {
@@ -2049,7 +2098,7 @@ export default function Page() {
diffs={diffs} diffs={diffs}
view={view} view={view}
diffStyle="unified" diffStyle="unified"
focusedFile={activeDiff()} focusedFile={tree.activeDiff}
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })} onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
comments={comments.all()} comments={comments.all()}
focusedComment={comments.focus()} focusedComment={comments.focus()}
@@ -2483,7 +2532,7 @@ export default function Page() {
when={prompt.ready()} when={prompt.ready()}
fallback={ fallback={
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none"> <div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
{handoff.prompt || language.t("prompt.loading")} {handoff.session.get(sessionKey())?.prompt || language.t("prompt.loading")}
</div> </div>
} }
> >
@@ -2734,7 +2783,7 @@ export default function Page() {
const p = path() const p = path()
if (!p) return null if (!p) return null
if (file.ready()) return file.selectedLines(p) ?? 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 let wrap: HTMLDivElement | undefined
@@ -3228,7 +3277,7 @@ export default function Page() {
allowed={diffFiles()} allowed={diffFiles()}
kinds={kinds()} kinds={kinds()}
draggable={false} draggable={false}
active={activeDiff()} active={tree.activeDiff}
onFileClick={(node) => focusReviewDiff(node.path)} onFileClick={(node) => focusReviewDiff(node.path)}
/> />
</Show> </Show>
@@ -3288,7 +3337,7 @@ export default function Page() {
fallback={ fallback={
<div class="flex flex-col h-full pointer-events-none"> <div class="flex flex-col h-full pointer-events-none">
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weak-base bg-background-stronger overflow-hidden"> <div class="h-10 flex items-center gap-2 px-2 border-b border-border-weak-base bg-background-stronger overflow-hidden">
<For each={handoff.terminals}> <For each={handoff.terminal.get(params.dir!) ?? []}>
{(title) => ( {(title) => (
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40"> <div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
{title} {title}