Visitar URL original
New timeline: reply footer (model / duration / tok/s, copy) never appears - clipped by the virtualized row · Issue #63741 · anomalyco/opencode · GitHub
Skip to content

New timeline: reply footer (model / duration / tok/s, copy) never appears - clipped by the virtualized row #63741

Open

Description

@jackxiao17

Description

With Settings > Experimental > New timeline on, the reply footer never appears. Hovering the last text of a finished reply shows nothing: no agent/model, no turn duration, no tok/s, and no copy button. Turning the experiment off brings the footer back on the same turn, so the hover and the data are fine in the current timeline.

2.3.0 lists "Restored message footers in the New timeline" (#63513), so I checked the 2.3.0 app source to see why it still does not show.

Root cause:

  • packages/session-ui/src/timeline-next/timeline-next.css takes the footer out of flow: [data-timeline=next] [data-component=text-part]:has(> [data-slot=text-part-copy-wrapper]) { position: relative } and ... > [data-slot=text-part-copy-wrapper] { position: absolute; top: 100%; height: 28px; padding-top: 8px }.
  • packages/app/src/pages/session/timeline/message-timeline.tsx, VirtualTimelineRow, renders every virtual row with height: item.size and overflow: "clip" / overflow-clip-margin: "0.5px".
  • item.size is measured from in-flow content, so it excludes the absolutely positioned footer. The footer sits 8-36px below the text part, outside its row, and the row clips it, so it can never be hovered or painted.

#63513 fixed the 8px hover gap but not this clipping. Its testing notes say it was not checked in the desktop app (the story used for measurement has no virtualizer wrapper).

Plugins

oc-session-stats (local GUI plugin); this footer is app UI, not plugin UI.

OpenCode version

2.3.0 (Desktop, Windows)

Steps to reproduce

  1. Settings > Experimental > enable New timeline.
  2. Open a session and hover the last text of a finished reply.
  3. Nothing appears (no footer, no copy button).
  4. Disable New timeline and hover the same text: the footer appears as before.

Screenshot

N/A - the missing element is the subject.

Operating System

Windows 10 IoT Enterprise LTSC 21H2 (10.0.19044)

Terminal

N/A - OpenCode Desktop

Activity

  1. opencodejr commented on Nov 13, 2026

    @opencodejr

    Seen with a simulated model or a mock server, not a real provider; a maintainer should confirm.

    Thanks for the detailed report. I could reproduce this.

    I used the web app on 2.3.0, which shares the timeline UI with Desktop, in headless Chromium on Linux. A local mock provider supplied a finished reply because no real provider was available here.

    • New timeline off: hovering the reply shows the footer (agent · model · duration · tok/s and the copy button).
    • New timeline on: hovering the same reply shows nothing.
      • The footer is absolutely positioned 0–28px below the text part.
      • The virtual row it sits in has overflow: clip and ends exactly where the footer starts.
      • Hit-testing the footer's position lands outside the footer, so it can't be hovered or painted.

    This matches the cause you described. The row clipping is set in packages/app/src/session/timeline/virtualizer.tsx.

    It still happens on the latest dev (77b26fdeb), run from source: same measurements. The screenshots below show both timelines on 2.3.0, and the script below reproduces it.

    Screenshot

    Screenshot

    Reproduction script (repro.ts)
    // Reproduces #63741 in the OpenCode web app (same packages/app UI as Desktop).
    // Usage: bun repro.ts "<pairing link from `opencode pair`>"
    // Needs: a project with one finished assistant reply, chromium at /usr/bin/chromium, playwright-core.
    import { chromium, type Page } from "./pw/node_modules/playwright-core"
    const link = process.argv[2]
    const b = await chromium.launch({ executablePath: "/usr/bin/chromium", args: ["--no-sandbox"] })
    const p = await b.newPage({ viewport: { width: 1400, height: 900 } })
    // For the dev check (vite app + server from source), pass the server's basic-auth password in SERVER_AUTH.
    if (process.env.SERVER_AUTH) {
      const [origin, pw] = process.env.SERVER_AUTH.split("|")
      await p.route(origin + "/**", (r) =>
        r.continue({ headers: { ...r.request().headers(), authorization: "Basic " + btoa("opencode:" + pw) } }))
    }
    await p.goto(link); await p.waitForTimeout(4000)
    
    async function openSession() {
      await p.getByText("Here is the finished reply").first().click()
      await p.waitForTimeout(3000)
    }
    async function setNewTimeline(on: boolean) {
      await p.goto(new URL("/", p.url()).href); await p.waitForTimeout(2500)
      await p.getByText("Settings", { exact: true }).first().click(); await p.waitForTimeout(1500)
      await p.getByText("Experimental", { exact: true }).first().click(); await p.waitForTimeout(1000)
      const row = p.locator("text=New timeline").first()
      const sw = row.locator("xpath=ancestor::*[.//*[@role='switch' or @type='checkbox']][1]").locator("[role=switch], input[type=checkbox]").first()
      const state = await sw.evaluate((e: any) => e.getAttribute("aria-checked") ?? String(e.checked))
      console.log("New timeline switch before:", state)
      if ((state === "true") !== on) await sw.click({ force: true })
      await p.waitForTimeout(500)
      console.log("New timeline switch after:", await sw.evaluate((e: any) => e.getAttribute("aria-checked") ?? String(e.checked)))
      await p.keyboard.press("Escape"); await p.waitForTimeout(800)
    }
    async function probe(label: string) {
      const text = p.locator("[data-component=text-part]").filter({ hasText: "Here is the finished reply" }).last()
      await text.hover(); await p.waitForTimeout(800)
      const info = await p.evaluate(() => {
        const parts = [...document.querySelectorAll("[data-component=text-part]")].filter(e => e.textContent!.includes("Here is the finished reply"))
        const tp = parts[parts.length - 1] as HTMLElement
        const w = tp?.querySelector(":scope > [data-slot=text-part-copy-wrapper]") as HTMLElement | null
        const r = (e: Element | null) => e ? (({ top, bottom, height }) => ({ top: Math.round(top), bottom: Math.round(bottom), height: Math.round(height) }))(e.getBoundingClientRect()) : null
        let row: HTMLElement | null = tp; while (row && !(row.style && row.style.overflow === "clip")) row = row.parentElement
        let hit: string | null = null, insideFooter = false
        if (w) { const wr = w.getBoundingClientRect(); const el = document.elementFromPoint(wr.left + 20, wr.top + 18); insideFooter = !!el && w.contains(el); hit = el ? el.tagName + (el.getAttribute("data-slot") ? "[" + el.getAttribute("data-slot") + "]" : "") : null }
        return {
          timeline: tp?.closest("[data-timeline]")?.getAttribute("data-timeline") ?? "current",
          textPart: r(tp), footer: r(w), footerPosition: w ? getComputedStyle(w).position : null,
          clippingRow: row ? { ...r(row), style: row.getAttribute("style") } : null,
          elementAtFooterPoint: hit, pointerReachesFooter: insideFooter,
        }
      })
      console.log(label, JSON.stringify(info, null, 1))
      await p.screenshot({ path: `/workspace/repro/${label}.png` })
    }
    await setNewTimeline(false); await openSession(); await probe("current-timeline")
    await setNewTimeline(true); await openSession(); await probe("new-timeline")
    await b.close()

    Run it: bun repro.ts

  2. himanshuhs9011 commented on Nov 13, 2026

    @himanshuhs9011

    Hi, I’m on Windows 11 and I can reproduce this on Desktop 2.3.0.

    With New timeline on, the footer and copy button don’t show when I hover a reply. With it off, they show normally.
    I’d like to work on a fix for this. Could I take it?

  3. jackxiao17 commented on Nov 13, 2026

    @jackxiao17
    Author

    Thanks. My original report is from a real provider on Desktop 2.3.0 (Windows 10), not a simulated model, and @himanshuhs9011 reproduced it on Windows 11 as well.

    @himanshuhs9011 please go ahead and take it; happy to test a build once there's a PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions