anomalyco / anomalyco/opencode

Web UI: `/server/:serverKey/session/:id` navigates itself to a different session ~1s after load

Open
#44,289 1 comment 0 reactions 1 assignee View on GitHub

@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 tag v1.18.21, commit 826d9ad46a.
  • Browser: Safari 26.x on macOS 26.
  • Reached through a local TLS-terminating reverse proxy on https://localhost:4097 over an SSH tunnel.
  • newLayoutDesigns is true in the profile's persisted settings.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 lastProjectSession did not stop it. The value was rewritten to the same wrong session with a fresh at, so the target is fetched from the server rather than restored from storage.
  • Clearing lastProjectSession and removing that session from persisted tabs did 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.closed key 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 no opencode.window.browser.dat:tabs.closed key at all.
  • Persisted tab list. Removing the offending session from tabs (together with clearing lastProjectSession) did not stop it.
  • Persisted lastProjectSession. Clearing it did not stop it; it was rewritten from a server fetch with a fresh at.

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
  1. Run opencode serve and open the web UI with newLayoutDesigns enabled.
  2. 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.
  3. Navigate to /server/<base64(serverURL)>/session/<sessionId> for the other session.
  4. 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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.