Skip to content

[Bug]: Focusing a collapsed project folder's new-thread action overprints its hidden-thread count #35482

Closed

Description

@coygeek

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/web

Steps to reproduce

  1. From the top-level checkout of the commit under Version or commit, with locked dependencies and Playwright Chromium installed, run sh repro.sh (below; optionally OUT=<dir> to keep the row screenshots). The script writes a temporary browser test under apps/web/src/components, gives it private HOME, TMPDIR and T3CODE_HOME directories, runs it with vp test run --project browser from apps/web with env -u NODE_ENV, and removes the test, screenshots and private state on exit. It exits 0 only when both the folder overlap and the working thread-card control below are observed.
  2. The test mounts the production AppRoot, router and sidebar with index.css loaded. Only the server connection and its data come from the repository's existing fake environment, which supplies one project ("render") and three threads; a server setting starts new drafts in local mode so the real new-thread callback can finish. It enables Boring sidebar, opens one thread and clicks the folder to collapse it. The open thread stays visible and the folder shows a hidden-thread count of 2.
  3. With the pointer away from the row, press Tab from the collapsed folder button. Focus moves to New thread in render. The pen icon and the count are drawn in the same place. Press Tab again: the action hides and the count reads normally.
  4. Click the folder's new-thread action. A new empty draft opens and the composer takes focus, which clears the overlap (count now 3). Without typing, click the same action again and move the pointer away. The already-open draft is reused without navigation, focus stays on the action, and the icon overprints the count of 3. Tab away clears it.
  5. Working control: the script switches to the normal sidebar and uses native Tab to reach a thread card's actions. The card's status label goes to opacity 0 while its actions go to opacity 1, and blurring restores status 1 and actions 0.

In the app: turn on Settings → Labs → Boring sidebar (group by project), collapse a project folder that hides at least one thread, and press Tab until the folder's pen button has focus.

Reproduction script: repro.sh
#!/bin/sh
# Run from the top-level checkout with locked dependencies and Playwright Chromium installed:
#   sh repro.sh            (optionally OUT=<dir> to keep the four row screenshots)
# Writes a temporary browser test under apps/web/src/components, runs it from apps/web,
# and removes the test, screenshots and private state on exit. Exits 0 only when the
# folder overlap and the working thread-card control are both observed.
set -eu
CHECKOUT="${CHECKOUT:-$(pwd)}"
CHECKOUT=$(cd "$CHECKOUT" && pwd -P)
fixture=$(mktemp -d "${TMPDIR:-/tmp}/focus-fixture.XXXXXX")
TEST_REL=src/components/tmpFolderFocusRepro.browser.test.tsx
TEST_ABS="$CHECKOUT/apps/web/$TEST_REL"
[ ! -e "$TEST_ABS" ] || { echo 'Refusing to overwrite existing test'; exit 1; }
evidence="$CHECKOUT/apps/web/src/components/tmp-repro-evidence"
[ ! -e "$evidence" ] || { echo 'Refusing to overwrite evidence directory'; exit 1; }
mkdir -p "$evidence"
trap 'rm -f "$TEST_ABS"; rm -rf "$evidence" "$fixture"' EXIT INT TERM
mkdir -p "$fixture/user-home" "$fixture/tmp" "$fixture/t3"
# Reuse installed browser binaries read-only; runtime profile and state are private.
if [ -n "${PLAYWRIGHT_BROWSERS_PATH:-}" ]; then browsers=$PLAYWRIGHT_BROWSERS_PATH
elif [ -d "$HOME/Library/Caches/ms-playwright" ]; then browsers="$HOME/Library/Caches/ms-playwright"
else browsers="$HOME/.cache/ms-playwright"; fi
cat > "$fixture/test.tsx" <<'TS'
import "~/index.css";
import { createMemoryHistory } from "@tanstack/react-router";
import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
import type * as Context from "effect/Context";
import { expect, it, vi } from "vite-plus/test";
import { commands, page, userEvent } from "vite-plus/test/browser";
import { AppRoot } from "~/AppRoot";
import "~/components/ChatView";
import { __setClientSettingsForTests, persistClientSettingsPatch } from "~/hooks/useSettings";
import { getRouter } from "~/router";
import { ENVIRONMENT_ID, installFakeEnvironment, threadWithHistory, type FakeDriver } from "../../test/fakeEnvironment";
import { act, render } from "../../test/render";
vi.mock("../../../../packages/client-runtime/src/connection/driver.ts", async (importOriginal) => {
  const [actual, { fakeDriver }, Layer] = await Promise.all([
    importOriginal<{ readonly ConnectionDriver: Context.Key<unknown, FakeDriver> }>(),
    import("../../test/fakeEnvironment"), import("effect/Layer"),
  ]);
  const [Effect, Stream] = await Promise.all([import("effect/Effect"),import("effect/Stream")]);
  // Ordinary server setting: start drafts in local mode, so no unresolved project-file RPC can hold the click.
  const localConfig = (config: any) => ({...config, settings:{...config.settings, defaultThreadEnvMode:"local"}});
  const configuredDriver = {...fakeDriver, connect:(entry: any) => Effect.map(fakeDriver.connect(entry),lease => ({...lease,session:{...lease.session,
    initialConfig:Effect.map(lease.session.initialConfig,localConfig),
    subscribeServerConfig:() => Stream.map(lease.session.subscribeServerConfig(),event => event.type === "snapshot" ? {...event,config:localConfig(event.config)} : event),
  }}))};
  return { ...actual, layer: Layer.succeed(actual.ConnectionDriver, configuredDriver) };
});
vi.mock(import("~/rpc/requestLatencyState"), async (original) => ({ ...(await original()), trackRpcRequestSent: () => {} }));
function rect(el: Element) {
  const r = el.getBoundingClientRect();
  return { left:r.left, right:r.right, top:r.top, bottom:r.bottom };
}
function folderState(row: Element, count: Element, action: HTMLElement) {
  const c = rect(count), a = rect(action);
  const hit = document.elementFromPoint((c.left+c.right)/2, (c.top+c.bottom)/2);
  return {
    focusedIsAction: document.activeElement === action,
    focusVisible: action.matches(":focus-visible"), rowHovered: row.matches(":hover"),
    countText: count.textContent?.trim(), countVisibility: getComputedStyle(count).visibility,
    actionOpacity: getComputedStyle(action.parentElement!).opacity,
    countBox:c, actionBox:a,
    intersects:c.left<a.right && a.left<c.right && c.top<a.bottom && a.top<c.bottom,
    hitIsAction: hit === action || action.contains(hit),
  };
}
it("production sidebar: keyboard folder overlap beside a working thread card control", async () => {
  await page.viewport(1400,900);
  const threads = ["active", "hidden-one", "hidden-two"].map(name => threadWithHistory(name,1));
  using _environment = installFakeEnvironment(threads);
  __setClientSettingsForTests({ ...DEFAULT_CLIENT_SETTINGS, boringSidebarEnabled:true, onboardingCompletedAt:"2026-01-01T00:00:00.000Z" });
  const router = getRouter(createMemoryHistory({initialEntries:[`/${ENVIRONMENT_ID}/${threads[0]!.id}`]}));
  await router.load();
  await using screen = await render(<AppRoot router={router}/>, {router:false});
  const folder = page.getByRole("button",{name:/^render/});
  await expect.element(folder).toHaveAttribute("aria-expanded","true");
  await userEvent.click(folder);
  await expect.element(folder).toHaveAttribute("aria-expanded","false");
  const row = folder.element().closest("li")!;
  const count = row.querySelector("span.tabular-nums")!;
  const action = row.querySelector<HTMLElement>("[data-sidebar-action-for]")!;
  await commands.resetPointer();
  const initial = folderState(row,count,action);
  console.log("ENVIRONMENT",JSON.stringify({userAgent:navigator.userAgent,platform:navigator.platform}));
  await page.screenshot({element:row,path:EVIDENCE_PATH+"/folder-rest.png"});
  console.log("FOLDER_REST",JSON.stringify(initial));
  expect(initial.countText).toBe("2"); expect(initial.countVisibility).toBe("visible"); expect(initial.actionOpacity).toBe("0");
  // The folder was focused by its collapse click. Native Tab reaches its production action.
  await userEvent.tab();
  const keyboard = folderState(row,count,action);
  console.log("FOLDER_TAB",JSON.stringify(keyboard));
  expect(keyboard.focusedIsAction).toBe(true); expect(keyboard.focusVisible).toBe(true);
  expect(keyboard.rowHovered).toBe(false); expect(keyboard.countVisibility).toBe("visible");
  expect(keyboard.actionOpacity).toBe("1"); expect(keyboard.intersects).toBe(true); expect(keyboard.hitIsAction).toBe(true);
  await page.screenshot({element:row,path:EVIDENCE_PATH+"/folder-tab.png"});
  await userEvent.tab();
  const away = folderState(row,count,action);
  console.log("FOLDER_TAB_AWAY",JSON.stringify(away));
  expect(away.focusedIsAction).toBe(false); expect(away.countVisibility).toBe("visible"); expect(away.actionOpacity).toBe("0");
  // Real production callback, including route/composer effects; do not replace it with a no-op.
  await userEvent.hover(action);
  await userEvent.click(action);
  await commands.resetPointer();
  console.log("FOLDER_CLICK_IMMEDIATE",JSON.stringify(folderState(row,count,action)));
  await new Promise(resolve => setTimeout(resolve,1000));
  console.log("FOLDER_CLICK_SETTLED",JSON.stringify({...folderState(row,count,action), activeTag:document.activeElement?.tagName, activeLabel:document.activeElement?.getAttribute("aria-label")}));
  await vi.waitFor(() => expect(router.state.location.pathname.startsWith("/draft/")).toBe(true));
  console.log("NEW_THREAD_NAVIGATED",true);
  await page.screenshot({element:row,path:EVIDENCE_PATH+"/folder-click.png"});
  // Reusing the already-open empty draft returns without navigation or composer focus.
  await userEvent.hover(action);
  await userEvent.click(action);
  await commands.resetPointer();
  await new Promise(resolve => setTimeout(resolve,1000));
  const repeated = folderState(row,count,action);
  console.log("FOLDER_CLICK_REUSE_DRAFT",JSON.stringify(repeated));
  expect(repeated.focusedIsAction).toBe(true); expect(repeated.focusVisible).toBe(false);
  expect(repeated.countVisibility).toBe("visible"); expect(repeated.actionOpacity).toBe("1"); expect(repeated.intersects).toBe(true);
  await page.screenshot({element:row,path:EVIDENCE_PATH+"/folder-repeat-click.png"});
  await userEvent.tab();
  const repeatedAway = folderState(row,count,action);
  console.log("FOLDER_REUSE_TAB_AWAY",JSON.stringify(repeatedAway));
  expect(repeatedAway.actionOpacity).toBe("0"); expect(repeatedAway.countVisibility).toBe("visible");
  // Switch to the real thread card variant fixed in #4574, without replacing the row.
  await act(() => persistClientSettingsPatch({boringSidebarEnabled:false},async () => {}));
  await expect.element(page.getByText("Thread active",{exact:true}).first()).toBeVisible();
  const slot = await vi.waitFor(() => {
    const el = screen.container.querySelector<HTMLElement>('[class*="group/sidebar-status-slot"]');
    expect(el).not.toBeNull(); return el!;
  });
  const status = slot.firstElementChild!;
  const controls = slot.lastElementChild!;
  const threadAction = controls.querySelector<HTMLElement>("button")!;
  expect(threadAction).not.toBeNull();
  await commands.resetPointer();
  console.log("THREAD_REST",JSON.stringify({statusOpacity:getComputedStyle(status).opacity, controlsOpacity:getComputedStyle(controls).opacity}));
  expect(getComputedStyle(status).opacity).toBe("1"); expect(getComputedStyle(controls).opacity).toBe("0");
  // Set the native Tab starting point at the row; keyboard (not synthetic focus) enters the action.
  const threadRow = slot.closest('[data-sidebar-row]') ?? slot.closest('li')!.querySelector('[data-sidebar-row]')!;
  await act(() => (threadRow as HTMLElement).focus());
  for (let n=0; n<5 && !controls.contains(document.activeElement); n++) await userEvent.tab();
  console.log("THREAD_TAB",JSON.stringify({focusInsideControls:controls.contains(document.activeElement),focusVisible:document.activeElement?.matches(":focus-visible"),statusOpacity:getComputedStyle(status).opacity,controlsOpacity:getComputedStyle(controls).opacity}));
  expect(controls.contains(document.activeElement)).toBe(true);
  expect(getComputedStyle(status).opacity).toBe("0"); expect(getComputedStyle(controls).opacity).toBe("1");
  await act(() => (document.activeElement as HTMLElement).blur());
  console.log("THREAD_BLUR",JSON.stringify({statusOpacity:getComputedStyle(status).opacity,controlsOpacity:getComputedStyle(controls).opacity}));
  expect(getComputedStyle(status).opacity).toBe("1"); expect(getComputedStyle(controls).opacity).toBe("0");
},60000);
TS
python3 - "$fixture/test.tsx" "$TEST_ABS" "$evidence" <<'PYCODE'
import sys,json
s=open(sys.argv[1]).read()
s=s.replace('import "~/index.css";', 'import "~/index.css";\nconst EVIDENCE_PATH='+json.dumps(sys.argv[3])+';')
open(sys.argv[2],'w').write(s)
PYCODE
cd "$CHECKOUT/apps/web"
set +e
env -u NODE_ENV HOME="$fixture/user-home" TMPDIR="$fixture/tmp" T3CODE_HOME="$fixture/t3" PLAYWRIGHT_BROWSERS_PATH="$browsers" PATH="$CHECKOUT/node_modules/.bin:$PATH" vp test run --project browser --silent=false "$TEST_REL"

result=$?
set -e
if [ -n "${OUT:-}" ]; then for png in "$evidence"/*.png; do [ ! -f "$png" ] || cp "$png" "$OUT/"; done; fi
exit "$result"

Expected behavior

When the new-thread action appears in a collapsed folder's count position, the count and the action should stay visually distinct, as they do on hover. The folder header's own source describes a trailing count on closed folders and says the action "Sits over the count, which hides on hover" (SidebarProjectFolderHeader.tsx lines 34-37 and lines 87-101), and the user guide describes the closed-folder count and the pen button (docs/user/thread-sidebar.md lines 152-157). Keyboard focus is a supported way to reach the pen button, so it should get the same separation.

Actual behavior

Summary. In the web sidebar with Boring sidebar (group by project) on, a collapsed project folder shows a count of hidden threads, and its "New thread in " pen action sits over that count. Hovering the folder hides the count while it shows the action, but keyboard focus on the action only shows the action: the count stays visible underneath, so the pen icon overprints the numeral. Tab focus reaches this state directly. A mouse click also leaves it when the click reuses that project's already-open empty draft, because focus stays on the action. Moving focus away restores the normal count display. Thread creation still works; the effect is a hard-to-read count while the action keeps focus.

In Chromium with the production stylesheet, Tab focus makes the action fully opaque while the count keeps visibility: visible. The count's rectangle (x 207.4–215, y 116–132) lies inside the action button's (x 199–223, y 112–136). The screenshots show the pen icon drawn over the numeral; the button is transparent, so parts of the numeral remain visible under the icon rather than being concealed. Moving focus away restores the count. The first new-draft click moved focus to the composer and cleared the overlap; the second click, reusing the open empty draft, left the action focused (:focus-visible false, row not hovered) with the same overlap.

Evidence

  • Expected source: apps/web/src/components/sidebar/SidebarProjectFolderHeader.tsx lines 34-37 (closed folder's trailing count) and line 100 (the action sits over the count, which hides on hover); docs/user/thread-sidebar.md lines 152-157 (closed-folder count and pen button).
  • Failure source: the measurements and screenshots from the script above; in SidebarProjectFolderHeader.tsx the count hides only on group-hover/project-folder or coarse pointers (line 88) while the overlaid action also becomes visible on focus-within (line 101). The production caller passes hidden threads and the real new-thread callback (Sidebar.tsx lines 3455-3466), and reusing an open empty draft returns without navigating (useHandleNewThread.ts lines 335-341).
  • Evidence provenance: observed
  • Local verification: reproduced
  • Reproduction completeness: complete

The script above was rerun from a clean checkout at the commit under Version or commit: it exited 0 (1 test passed) and left git status --porcelain empty. The asymmetric visibility conditions are a hypothesis from reading the source; the fix may take another approach. The thread card's status slot (SidebarThreadRow.tsx around line 1480) hides its status label on :focus-visible inside the slot, and the script observed that control working.

Related

#4574 fixed status/action focus overlap for thread-card Snooze/Settle controls and states no rule for every sidebar row; #4826 is a closed, unmerged thread-card proposal. Open #33219 changes folder-header file drops, and open #31097, #33057 and #33862 touch folder features; none changes these count/action visibility classes. None covers the folder header.

Restoration check

With the script's input, after Tab moves focus to New thread in render on the collapsed folder, the count is not drawn under the visible action (for example, the count is hidden or the two no longer share pixels), and the same holds after the second click that reuses the open empty draft; Tab away still restores the readable count. Controls keep passing: at rest the count is visible and the action hidden, hover still swaps them, the pen action still opens a new draft, and the thread card's status/action swap on keyboard focus still works. The script's defect assertions then fail, as intended.

Impact

Cosmetic issue

P3 under triage: the observed effect is a cosmetic overprint of the hidden-thread count by the pen icon while the action has focus; thread creation works, no data is lost, the folder's status dot still signals attention, and moving focus restores the count.

Version or commit

d9805b2, rerun from a clean checkout. Upstream main at 55081a1 has a byte-identical SidebarProjectFolderHeader.tsx; a browser rerun on that head was not completed because the available dependency install lacks its newly required gt-react package, so current-main runtime behavior is unobserved.

Environment

macOS arm64, Node v26.12.1, locked Vite+ 1.4.0, Vitest 5.0.5 and Playwright 1.64.1, headless Chromium 156.0.8078.4 at a 1400×900 viewport, browser test run from apps/web with env -u NODE_ENV. The production web UI was rendered against the repository's in-process fake environment; other browser engines and the desktop Electron app were not run.

Logs or stack traces

Script output, condensed:

FOLDER_REST: count=2, countVisibility=visible, actionOpacity=0
FOLDER_TAB: focusedIsAction=true, focusVisible=true, rowHovered=false, countVisibility=visible, actionOpacity=1, intersects=true
FOLDER_TAB_AWAY: countVisibility=visible, actionOpacity=0
FOLDER_CLICK_SETTLED: focusedIsAction=false, count=3, actionOpacity=0, activeLabel="Message New thread"
NEW_THREAD_NAVIGATED true
FOLDER_CLICK_REUSE_DRAFT: focusedIsAction=true, focusVisible=false, rowHovered=false, count=3, countVisibility=visible, actionOpacity=1, intersects=true
FOLDER_REUSE_TAB_AWAY: countVisibility=visible, actionOpacity=0
THREAD_REST {"statusOpacity":"1","controlsOpacity":"0"}
THREAD_TAB {"focusInsideControls":true,"focusVisible":true,"statusOpacity":"0","controlsOpacity":"1"}
THREAD_BLUR {"statusOpacity":"1","controlsOpacity":"0"}
      Tests  1 passed (1)

Screenshots, recordings, or supporting files

Row screenshots from the script, enlarged 3× (nearest neighbor).

Collapsed folder at rest: hidden-thread count 2 with no action shown

Tab focus on the new-thread action: the pen icon is drawn over the count 2

Second click reusing the open empty draft: the action keeps focus and overprints the count 3

Workaround

Press Tab to move focus off the new-thread action; this was observed to hide the action and restore the count in both the keyboard and reused-draft click cases.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions