summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-02-04 07:59:42 -0600
committerAdam <[email protected]>2026-02-04 07:59:46 -0600
commitc8622df762b953bfea4ba0dbc7097b123f29a288 (patch)
tree970eeafe7029cea79a25afa65e8a058845da33f1 /packages
parentc277ee8cbf7ff3ca5a86947d974c2b72f88398d4 (diff)
downloadopencode-c8622df762b953bfea4ba0dbc7097b123f29a288.tar.gz
opencode-c8622df762b953bfea4ba0dbc7097b123f29a288.zip
fix(app): file tree not staying in sync across projects/sessions
Diffstat (limited to 'packages')
-rw-r--r--packages/app/src/context/layout.tsx46
-rw-r--r--packages/app/src/pages/layout.tsx5
-rw-r--r--packages/app/src/pages/session.tsx159
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<string, SessionScroll>
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<string, number>()
@@ -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<string>) {
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<string, SelectedLineRange | null>
+}
+
+const HANDOFF_MAX = 40
+
const handoff = {
- prompt: "",
- terminals: [] as string[],
- files: {} as Record<string, SelectedLineRange | null>,
+ session: new Map<string, HandoffSession>(),
+ terminal: new Map<string, string[]>(),
+}
+
+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 {
@@ -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={
<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>
}
>
@@ -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)}
/>
</Show>
@@ -3288,7 +3337,7 @@ export default function Page() {
fallback={
<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">
- <For each={handoff.terminals}>
+ <For each={handoff.terminal.get(params.dir!) ?? []}>
{(title) => (
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
{title}