From 7948de16129970ab01286fbbd7ba7a5e5dcf7be9 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Thu, 5 Mar 2026 14:41:12 +0800 Subject: [PATCH] app: prefer using useLocation instead of window.location (#15989) --- bun.lock | 1 + .../app/src/pages/layout/sidebar-items.tsx | 34 ++++++++--------- .../src/pages/session/message-id-from-hash.ts | 6 +++ .../session/use-session-hash-scroll.test.ts | 2 +- .../pages/session/use-session-hash-scroll.ts | 38 +++++++++---------- packages/app/src/utils/notification-click.ts | 5 ++- packages/ui/package.json | 1 + packages/ui/src/components/message-part.tsx | 5 ++- 8 files changed, 49 insertions(+), 43 deletions(-) create mode 100644 packages/app/src/pages/session/message-id-from-hash.ts diff --git a/bun.lock b/bun.lock index c3b108a0e..d93b56329 100644 --- a/bun.lock +++ b/bun.lock @@ -484,6 +484,7 @@ "@solid-primitives/media": "2.3.3", "@solid-primitives/resize-observer": "2.1.3", "@solidjs/meta": "catalog:", + "@solidjs/router": "catalog:", "dompurify": "3.3.1", "fuzzysort": "catalog:", "katex": "0.16.27", diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index 0aaabc03b..e991d8225 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -1,10 +1,4 @@ -import { A, useNavigate, useParams } from "@solidjs/router" -import { useGlobalSync } from "@/context/global-sync" -import { useLanguage } from "@/context/language" -import { useLayout, type LocalProject, getAvatarColors } from "@/context/layout" -import { useNotification } from "@/context/notification" -import { usePermission } from "@/context/permission" -import { base64Encode } from "@opencode-ai/util/encode" +import type { Message, Session, TextPart, UserMessage } from "@opencode-ai/sdk/v2/client" import { Avatar } from "@opencode-ai/ui/avatar" import { HoverCard } from "@opencode-ai/ui/hover-card" import { Icon } from "@opencode-ai/ui/icon" @@ -12,12 +6,18 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { MessageNav } from "@opencode-ai/ui/message-nav" import { Spinner } from "@opencode-ai/ui/spinner" import { Tooltip } from "@opencode-ai/ui/tooltip" +import { base64Encode } from "@opencode-ai/util/encode" import { getFilename } from "@opencode-ai/util/path" -import { type Message, type Session, type TextPart, type UserMessage } from "@opencode-ai/sdk/v2/client" -import { For, Match, Show, Switch, createMemo, onCleanup, type Accessor, type JSX } from "solid-js" +import { A, useNavigate, useParams } from "@solidjs/router" +import { type Accessor, createMemo, For, type JSX, Match, onCleanup, Show, Switch } from "solid-js" +import { useGlobalSync } from "@/context/global-sync" +import { useLanguage } from "@/context/language" +import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout" +import { useNotification } from "@/context/notification" +import { usePermission } from "@/context/permission" import { agentColor } from "@/utils/agent" -import { hasProjectPermissions } from "./helpers" import { sessionPermissionRequest } from "../session/composer/session-request-tree" +import { hasProjectPermissions } from "./helpers" const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" @@ -231,7 +231,9 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed()) const isActive = createMemo(() => props.session.id === params.id) - const hoverPrefetch = { current: undefined as ReturnType | undefined } + const hoverPrefetch = { + current: undefined as ReturnType | undefined, + } const cancelHoverPrefetch = () => { if (hoverPrefetch.current === undefined) return clearTimeout(hoverPrefetch.current) @@ -300,17 +302,15 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { setHoverSession={props.setHoverSession} messageLabel={messageLabel} onMessageSelect={(message) => { - if (!isActive()) { + if (!isActive()) layout.pendingMessage.set(`${base64Encode(props.session.directory)}/${props.session.id}`, message.id) - navigate(`${props.slug}/session/${props.session.id}`) - return - } - window.history.replaceState(null, "", `#message-${message.id}`) - window.dispatchEvent(new HashChangeEvent("hashchange")) + + navigate(`${props.slug}/session/${props.session.id}#message-${message.id}`) }} trigger={item} /> +
{ + const value = hash.startsWith("#") ? hash.slice(1) : hash + const match = value.match(/^message-(.+)$/) + if (!match) return + return match[1] +} diff --git a/packages/app/src/pages/session/use-session-hash-scroll.test.ts b/packages/app/src/pages/session/use-session-hash-scroll.test.ts index 844f5451e..7f3389baa 100644 --- a/packages/app/src/pages/session/use-session-hash-scroll.test.ts +++ b/packages/app/src/pages/session/use-session-hash-scroll.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { messageIdFromHash } from "./use-session-hash-scroll" +import { messageIdFromHash } from "./message-id-from-hash" describe("messageIdFromHash", () => { test("parses hash with leading #", () => { diff --git a/packages/app/src/pages/session/use-session-hash-scroll.ts b/packages/app/src/pages/session/use-session-hash-scroll.ts index 473409fd9..20e88a3ea 100644 --- a/packages/app/src/pages/session/use-session-hash-scroll.ts +++ b/packages/app/src/pages/session/use-session-hash-scroll.ts @@ -1,12 +1,9 @@ -import { createEffect, createMemo, onCleanup, onMount } from "solid-js" -import { UserMessage } from "@opencode-ai/sdk/v2" +import type { UserMessage } from "@opencode-ai/sdk/v2" +import { useLocation, useNavigate } from "@solidjs/router" +import { createEffect, createMemo, onMount } from "solid-js" +import { messageIdFromHash } from "./message-id-from-hash" -export const messageIdFromHash = (hash: string) => { - const value = hash.startsWith("#") ? hash.slice(1) : hash - const match = value.match(/^message-(.+)$/) - if (!match) return - return match[1] -} +export { messageIdFromHash } from "./message-id-from-hash" export const useSessionHashScroll = (input: { sessionKey: () => string @@ -30,13 +27,18 @@ export const useSessionHashScroll = (input: { const messageIndex = createMemo(() => new Map(visibleUserMessages().map((m, i) => [m.id, i]))) let pendingKey = "" + const location = useLocation() + const navigate = useNavigate() + const clearMessageHash = () => { - if (!window.location.hash) return - window.history.replaceState(null, "", window.location.href.replace(/#.*$/, "")) + if (!location.hash) return + navigate(location.pathname + location.search, { replace: true }) } const updateHash = (id: string) => { - window.history.replaceState(null, "", `#${input.anchor(id)}`) + navigate(location.pathname + location.search + `#${input.anchor(id)}`, { + replace: true, + }) } const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => { @@ -53,6 +55,7 @@ export const useSessionHashScroll = (input: { } const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => { + console.log({ message, behavior }) if (input.currentMessageId() !== message.id) input.setActiveMessage(message) const index = messageIndex().get(message.id) ?? -1 @@ -100,7 +103,7 @@ export const useSessionHashScroll = (input: { } const applyHash = (behavior: ScrollBehavior) => { - const hash = window.location.hash.slice(1) + const hash = location.hash.slice(1) if (!hash) { input.autoScroll.forceScrollToBottom() const el = input.scroller() @@ -132,6 +135,7 @@ export const useSessionHashScroll = (input: { } createEffect(() => { + location.hash if (!input.sessionID() || !input.messagesReady()) return requestAnimationFrame(() => applyHash("auto")) }) @@ -155,7 +159,7 @@ export const useSessionHashScroll = (input: { } } - if (!targetId) targetId = messageIdFromHash(window.location.hash) + if (!targetId) targetId = messageIdFromHash(location.hash) if (!targetId) return if (input.currentMessageId() === targetId) return @@ -171,14 +175,6 @@ export const useSessionHashScroll = (input: { if (typeof window !== "undefined" && "scrollRestoration" in window.history) { window.history.scrollRestoration = "manual" } - - const handler = () => { - if (!input.sessionID() || !input.messagesReady()) return - requestAnimationFrame(() => applyHash("auto")) - } - - window.addEventListener("hashchange", handler) - onCleanup(() => window.removeEventListener("hashchange", handler)) }) return { diff --git a/packages/app/src/utils/notification-click.ts b/packages/app/src/utils/notification-click.ts index 94086c595..316b27820 100644 --- a/packages/app/src/utils/notification-click.ts +++ b/packages/app/src/utils/notification-click.ts @@ -7,6 +7,7 @@ export const setNavigate = (fn: (href: string) => void) => { export const handleNotificationClick = (href?: string) => { window.focus() if (!href) return - if (nav) nav(href) - else window.location.assign(href) + if (nav) return nav(href) + console.warn("notification-click: navigate function not set, falling back to window.location.assign") + window.location.assign(href) } diff --git a/packages/ui/package.json b/packages/ui/package.json index 52420009f..85197f30f 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -51,6 +51,7 @@ "@solid-primitives/media": "2.3.3", "@solid-primitives/resize-observer": "2.1.3", "@solidjs/meta": "catalog:", + "@solidjs/router": "catalog:", "dompurify": "3.3.1", "fuzzysort": "catalog:", "katex": "0.16.27", diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index aecdbc8e4..766060f1b 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -52,6 +52,7 @@ import { TextShimmer } from "./text-shimmer" import { AnimatedCountList } from "./tool-count-summary" import { ToolStatusTitle } from "./tool-status-title" import { animate } from "motion" +import { useLocation } from "@solidjs/router" function ShellSubmessage(props: { text: string; animate?: boolean }) { let widthRef: HTMLSpanElement | undefined @@ -1471,6 +1472,7 @@ ToolRegistry.register({ render(props) { const data = useData() const i18n = useI18n() + const location = useLocation() const childSessionId = () => props.metadata.sessionId as string | undefined const title = createMemo(() => i18n.t("ui.tool.agent", { type: props.input.subagent_type || props.tool })) const description = createMemo(() => { @@ -1487,8 +1489,7 @@ ToolRegistry.register({ const direct = data.sessionHref?.(sessionId) if (direct) return direct - if (typeof window === "undefined") return - const path = window.location.pathname + const path = location.pathname const idx = path.indexOf("/session") if (idx === -1) return return `${path.slice(0, idx)}/session/${sessionId}`