summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-01-19 14:23:59 -0600
committerAdam <[email protected]>2026-01-19 14:59:46 -0600
commitecc51ddb4e8a04495da45126a706a7effee5bf8d (patch)
treee94d52ac29523d5e4d884432ea587bcbcdbc49e7
parent769c97af086e5edf0efb431e902eceb54dc668cb (diff)
downloadopencode-ecc51ddb4e8a04495da45126a706a7effee5bf8d.tar.gz
opencode-ecc51ddb4e8a04495da45126a706a7effee5bf8d.zip
fix(app): hash nav
-rw-r--r--packages/app/src/pages/layout.tsx5
-rw-r--r--packages/app/src/pages/session.tsx135
2 files changed, 112 insertions, 28 deletions
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index 2f3b39d86..a8f9b162f 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -1429,10 +1429,11 @@ export default function Layout(props: ParentProps) {
getLabel={messageLabel}
onMessageSelect={(message) => {
if (!isActive()) {
- navigate(`${props.slug}/session/${props.session.id}#message-${message.id}`)
+ sessionStorage.setItem("opencode.pendingMessage", `${props.session.id}|${message.id}`)
+ navigate(`${props.slug}/session/${props.session.id}`)
return
}
- window.location.hash = `message-${message.id}`
+ window.history.replaceState(null, "", `#message-${message.id}`)
window.dispatchEvent(new HashChangeEvent("hashchange"))
}}
size="normal"
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index 31f9eac9c..fdb9f268c 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -1,4 +1,4 @@
-import { For, onCleanup, onMount, Show, Match, Switch, createMemo, createEffect, on } from "solid-js"
+import { For, onCleanup, onMount, Show, Match, Switch, createMemo, createEffect, on, createSignal } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Dynamic } from "solid-js/web"
@@ -167,6 +167,7 @@ export default function Page() {
const sdk = useSDK()
const prompt = usePrompt()
const permission = usePermission()
+ const [pendingMessage, setPendingMessage] = createSignal<string | undefined>(undefined)
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey()))
const view = createMemo(() => layout.view(sessionKey()))
@@ -943,17 +944,30 @@ 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")
+ setPendingMessage(messageID)
+ })
+
const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
const root = scroller
- if (!root) {
- el.scrollIntoView({ behavior, block: "start" })
- return
- }
+ if (!root) return false
const a = el.getBoundingClientRect()
const b = root.getBoundingClientRect()
const top = a.top - b.top + root.scrollTop
root.scrollTo({ top, behavior })
+ return true
}
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
@@ -967,7 +981,15 @@ export default function Page() {
requestAnimationFrame(() => {
const el = document.getElementById(anchor(message.id))
- if (el) scrollToElement(el, behavior)
+ if (!el) {
+ requestAnimationFrame(() => {
+ const next = document.getElementById(anchor(message.id))
+ if (!next) return
+ scrollToElement(next, behavior)
+ })
+ return
+ }
+ scrollToElement(el, behavior)
})
updateHash(message.id)
@@ -975,10 +997,57 @@ export default function Page() {
}
const el = document.getElementById(anchor(message.id))
- if (el) scrollToElement(el, behavior)
+ if (!el) {
+ updateHash(message.id)
+ requestAnimationFrame(() => {
+ const next = document.getElementById(anchor(message.id))
+ if (!next) return
+ if (!scrollToElement(next, behavior)) return
+ })
+ return
+ }
+ if (scrollToElement(el, behavior)) {
+ updateHash(message.id)
+ return
+ }
+
+ requestAnimationFrame(() => {
+ const next = document.getElementById(anchor(message.id))
+ if (!next) return
+ if (!scrollToElement(next, behavior)) return
+ })
updateHash(message.id)
}
+ const applyHash = (behavior: ScrollBehavior) => {
+ const hash = window.location.hash.slice(1)
+ if (!hash) {
+ autoScroll.forceScrollToBottom()
+ return
+ }
+
+ const match = hash.match(/^message-(.+)$/)
+ if (match) {
+ const msg = visibleUserMessages().find((m) => m.id === match[1])
+ if (msg) {
+ scrollToMessage(msg, behavior)
+ return
+ }
+
+ // If we have a message hash but the message isn't loaded/rendered yet,
+ // don't fall back to "bottom". We'll retry once messages arrive.
+ return
+ }
+
+ const target = document.getElementById(hash)
+ if (target) {
+ scrollToElement(target, behavior)
+ return
+ }
+
+ autoScroll.forceScrollToBottom()
+ }
+
const getActiveMessageId = (container: HTMLDivElement) => {
const cutoff = container.scrollTop + 100
const nodes = container.querySelectorAll<HTMLElement>("[data-message-id]")
@@ -1019,29 +1088,43 @@ export default function Page() {
if (!sessionID || !ready) return
requestAnimationFrame(() => {
- const hash = window.location.hash.slice(1)
- if (!hash) {
- autoScroll.forceScrollToBottom()
- return
- }
+ applyHash("auto")
+ })
+ })
- const hashTarget = document.getElementById(hash)
- if (hashTarget) {
- scrollToElement(hashTarget, "auto")
- return
- }
+ // Retry message navigation once the target message is actually loaded.
+ createEffect(() => {
+ const sessionID = params.id
+ const ready = messagesReady()
+ if (!sessionID || !ready) return
+ // dependencies
+ visibleUserMessages().length
+ store.turnStart
+
+ const targetId = pendingMessage() ?? (() => {
+ const hash = window.location.hash.slice(1)
const match = hash.match(/^message-(.+)$/)
- if (match) {
- const msg = visibleUserMessages().find((m) => m.id === match[1])
- if (msg) {
- scrollToMessage(msg, "auto")
- return
- }
- }
+ if (!match) return undefined
+ return match[1]
+ })()
+ if (!targetId) return
+ if (store.messageId === targetId) return
+
+ const msg = visibleUserMessages().find((m) => m.id === targetId)
+ if (!msg) return
+ if (pendingMessage() === targetId) setPendingMessage(undefined)
+ requestAnimationFrame(() => scrollToMessage(msg, "auto"))
+ })
- autoScroll.forceScrollToBottom()
- })
+ createEffect(() => {
+ const sessionID = params.id
+ const ready = messagesReady()
+ if (!sessionID || !ready) return
+
+ const handler = () => requestAnimationFrame(() => applyHash("auto"))
+ window.addEventListener("hashchange", handler)
+ onCleanup(() => window.removeEventListener("hashchange", handler))
})
createEffect(() => {