anomalyco / anomalyco/opencode
Web UI: `/server/:serverKey/session/:id` navigates itself to a different session ~1s after load
@Hona is already working on this.
Since Aug 23, 2026.
- Dominant language
- TypeScript
- Stars
- 209k
- Forks
- 27.5k
- PR merge metrics
- PR metrics pending
Description
Description
The error: in opencode serve or opencode web, if I have multiple sessions open on the in-page tabs, refreshing a few times results in the active tab jumping to a different session than what was loaded, or jumping to a previously-closed session.
Environment
- opencode 1.18.21 (
opencode serve, web UI). Code references below are against tagv1.18.21, commit826d9ad46a. - Browser: Safari 26.x on macOS 26.
- Reached through a local TLS-terminating reverse proxy on
https://localhost:4097over an SSH tunnel. newLayoutDesignsistruein the profile's persistedsettings.v3.
The proxy injects a client script; it is ruled out. Its only navigation-related code is a location.reload() on a proxy-restart lifecycle event, plus history patches that call the native method unmodified and only re-scan DOM docks.
Expected vs actual
Expected: loading a URL that names a session id opens that session and stays there.
Actual: the app paints the requested session, then about 1–1.5 seconds later navigates itself to a different session — the project's "current" session — discarding the id in the URL. From the user's side this looks like "an old session I closed pops back up when I refresh".
Measurements
- URL sampled after load: still the requested session at t+0.3s and t+0.7s; changed by t+1.5s. So this is an async post-paint navigation, not a route guard.
- 5 consecutive loads: 5/5 hijacked. An earlier 8-reload run hijacked every load of the
/server/form. - The intermediate URL is the directory form
/<base64(directory)>/session/<otherId>, which is then normalised back to/server/<b64>/session/<otherId>. The two forms alternate across reloads while the session id stays wrong. - Clearing persisted
lastProjectSessiondid not stop it. The value was rewritten to the same wrong session with a freshat, so the target is fetched from the server rather than restored from storage. - Clearing
lastProjectSessionand removing that session from persistedtabsdid not stop it either. - Loading the directory form of the very same session does not hijack — stable at t+10s.
- Pre-seeding
lastProjectSession[<directory>] = { directory, id: <id from URL>, at: Date.now() }before load makes the hijack a no-op, 3/3. - The profile has no
opencode.window.browser.dat:tabs.closedkey at all.
Mechanism
All references are packages/app/src/pages/layout.tsx unless stated.
1. Autoselect arms because the route has no :dir. initialDirectory comes from params.dir (:125):
const initialDirectory = decode64(params.dir)
and gates the store flag (:148-149):
const [state, setState] = createStore({
autoselect: !initialDirectory,
/server/:serverKey/session/:id carries no :dir, so autoselect is true. The effect that would disarm it (:305-312) cannot run, because it returns on the same missing parameter:
createEffect(() => {
if (!state.autoselect) return
const dir = params.dir
if (!dir) return
const directory = decode64(dir)
if (!directory) return
setState("autoselect", false)
})
2. An unattended resource opens a project once persistence is ready (:539-555). This is the ~1s delay:
const [autoselecting] = createResource(async () => {
await ready.promise
await layout.ready.promise
if (!untrack(() => state.autoselect)) return
const list = layout.projects.list()
const last = server.projects.last()
if (list.length === 0) {
if (!last) return
await openProject(last, true)
} else {
const next = list.find((project) => project.worktree === last) ?? list[0]
if (!next) return
await openProject(next.worktree, true)
}
})
3. openProject navigates (:1247-1250):
function openProject(directory: string, navigate = true) {
layout.projects.open(directory)
if (navigate) return navigateToProject(directory)
}
4. navigateToProject (:1163) picks a session with no reference to params.id. Three fallbacks, in order — the pin (:1206-1212), the newest synced root session (:1214-1220), then a server fetch (:1222-1237):
const projectSession = store.lastProjectSession[root]
if (projectSession?.id) {
await refreshDirs(projectSession.directory)
const opened = await openSession(projectSession)
if (opened) return
clearLastProjectSession(root)
}
const latest = latestRootSession(
dirs.map((item) => serverSync().child(item, { bootstrap: false })[0]),
Date.now(),
)
if (latest && (await openSession(latest))) {
return
}
const fetched = latestRootSession(
await Promise.all(
dirs.map(async (item) => ({
path: { directory: item },
session: await listAllSessions(serverSDK().api.session, {
directory: item,
parentID: null,
order: "desc",
}).catch(() => []),
})),
),
Date.now(),
)
The third branch is why clearing storage does not help: the target is re-fetched from the server and the pin rewritten.
5. openSession (:1187-1204) writes the pin and navigates to the directory form:
setStore("lastProjectSession", root, { directory: target.directory, id: target.id, at: Date.now() })
navigateWithSidebarReset(`/${base64Encode(target.directory)}/session/${target.id}`)
6. The new layout normalises that back to the /server/ form, preserving the wrong id. app.tsx:648-665:
function NewLayoutLegacySessionRedirect() {
const server = useServer()
const tabs = useTabs()
const params = useParams<{ id: string }>()
return (
<Show when={tabs.ready()}>
<Navigate
href={sessionHref(
legacySessionServer(
tabs.store.filter((item) => item.type === "session"),
params.id,
server.key,
),
params.id,
)}
/>
</Show>
)
}
with the same normalisation inside LegacyTargetSessionRoute at app.tsx:82-90. This is the observed alternation between the two URL forms.
Why the legacy layout is involved at all
LegacyLayout — i.e. pages/layout.tsx, the only file that defines navigateToProject — is mounted only through LegacyServerLayout (app.tsx:177-183, :362-368), which is the branch selected when newLayoutDesigns() is false:
function LegacyServerScopedShell(props: ServerScopedShellProps) {
return (
<ServerScopedProviders directory={props.directory} serverScoped={props.serverScoped}>
<LegacyLayout>{props.children}</LegacyLayout>
</ServerScopedProviders>
)
}
The hijack demonstrably runs on a profile where that setting is true, so the legacy branch is evidently mounted at some point during startup. A plausible route is that the whole router is keyed on the memo (app.tsx:588):
<Show when={useSettings().general.newLayoutDesigns().toString()} keyed>
and the memo returns the legacy default until a persistence resource settles (context/settings.tsx:263-265):
const newLayoutDesigns = createMemo(() => {
if (layoutUpgrade()) return true
if (!ready() && !oldInterfaceRetired()) return legacyNewLayoutDesignsDefault
Stated as the inference it is: what was proved is that the legacy code path ran. Which tick mounted it was not instrumented, so treat the ready()-flip explanation as a hypothesis rather than a measurement.
Ruled out
- Injected proxy client. Audited: its only navigation-related code is a
location.reload()on a proxy-restart lifecycle event; its history patches call the native method unmodified and only re-scan DOM docks. - Closed-tab stack /
reopenClosedTab. The profile has noopencode.window.browser.dat:tabs.closedkey at all. - Persisted tab list. Removing the offending session from
tabs(together with clearinglastProjectSession) did not stop it. - Persisted
lastProjectSession. Clearing it did not stop it; it was rewritten from a server fetch with a freshat.
Secondary observation
lastProjectSession pointed at a session the user had not opened for some time, while they worked in a different session all day. So under the new layout it appears not to be kept current by ordinary in-app session switching, which is what makes the restored target arbitrary from the user's point of view. This is an observation about the resulting value, not a proven claim about which write paths do or do not run.
Suggested direction
The autoselect path should not override a session id that the URL already names. Whatever form the fix takes, /server/:serverKey/session/:id carries an explicit session, and the !params.dir test currently used to arm autoselect does not distinguish "no session requested" from "a session was requested under a route with no :dir segment".
Plugins
none
OpenCode version
1.18.21
Steps to reproduce
- Run
opencode serveand open the web UI withnewLayoutDesignsenabled. - Have at least two sessions in one project, and make the project's most recent / last-opened session (
lastProjectSession) something other than the one you are about to open. - Navigate to
/server/<base64(serverURL)>/session/<sessionId>for the other session. - Do nothing. Watch
location.pathname.
The URL still names the requested session immediately after paint, then changes to another session id.
The directory form of the same session, /<base64(directory)>/session/<sessionId>, does not reproduce it.
Screenshot and/or share link
No response
Operating System
macOS 26.6
Terminal
Safari 26.x
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.