Repository navigation
Description
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
From the top-level checkout of the commit under Version or commit, with locked dependencies and Playwright Chromium installed, run sh repro.sh (optionally with OUT=<dir> to keep the 1366×768 screenshots). The script writes a temporary browser test beside apps/web/src/components/ChatView.tsx, gives it private HOME, TMPDIR and T3CODE_HOME directories, runs each viewport case in its own vp test run --project browser process from apps/web with env -u NODE_ENV, and removes the test, screenshots and private state on exit. It exits 0 only when the stash visibly overflows into the editor while the slash control stays inside the layer.
The test mounts the production AppRoot with the real router, ChatView, ChatComposer, editor, stylesheet, stash menu, command menu and terminal drawer; only the environment transport and request-latency notice are replaced, using the repository's existing fake environment, which also supplies 20 enabled skills so the slash list is tall. It seeds 20 text stash entries through the production prompt-stash store and a ten-line draft that makes the composer 266px tall with a 208px editor, opens a 320px bottom terminal (400px in one case), clicks the real Stash badge, measures the layer, stash surface and editor, hit-tests a point inside the editor, scrolls to the last stash entry and restores it with the pointer. It then remounts the same composer with a / trigger as the working control and checks that composer and terminal heights match. No component, CSS or bounding rectangle is mocked and no provider turn is sent.
In the app: open a chat, save several prompts to Stash, leave a multiline draft in the composer, open the bottom terminal, and click the Stash badge on a laptop-sized window.
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 1366x768 screenshots)
# Writes a temporary browser test under apps/web/src/components, runs each viewport case
# in its own process, and removes the test, screenshots and private state on exit.
set -eu
checkout=$(pwd)
fixture=$(mktemp -d "${TMPDIR:-/tmp}/stash-layer-proof.XXXXXX")
name="verifyStashLayer$$"
test_file="$checkout/apps/web/src/components/$name.browser.test.tsx"
cleanup() {
rm -f "$test_file" "$checkout/apps/web/src/components/$name-stash.png" "$checkout/apps/web/src/components/$name-slash.png"
rm -rf "$fixture"
}
trap cleanup EXIT HUP INT TERM
mkdir -p "$fixture/user-home" "$fixture/tmp" "$fixture/t3-home"
if [ -n "${PLAYWRIGHT_BROWSERS_PATH:-}" ]; then browser_cache=$PLAYWRIGHT_BROWSERS_PATH
elif [ -d "$HOME/Library/Caches/ms-playwright" ]; then browser_cache="$HOME/Library/Caches/ms-playwright"
else browser_cache="$HOME/.cache/ms-playwright"; fi
cat > "$fixture/fixture.browser.test.tsx" <<'TSX'
import "~/index.css";
import { createMemoryHistory } from "@tanstack/react-router";
import { scopeThreadRef } from "@t3tools/client-runtime/environment";
import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
import type * as Context from "effect/Context";
import { expect, it, vi } from "vite-plus/test";
import { page } from "vite-plus/test/browser";
import { AppRoot } from "~/AppRoot";
import "~/components/ChatView";
import { useComposerDraftStore } from "~/composerDraftStore";
import { usePromptStashStore } from "~/promptStashStore";
import { useTerminalUiStateStore } from "~/terminalUiStateStore";
import { __setClientSettingsForTests } from "~/hooks/useSettings";
import { getRouter } from "~/router";
import { ENVIRONMENT_ID, installFakeEnvironment, threadWithHistory, type FakeDriver } from "../../test/fakeEnvironment";
import { render } from "../../test/render";
vi.mock("../../../../packages/client-runtime/src/connection/driver.ts", async (importOriginal) => {
const [actual, { fakeDriver }, Layer, Effect, Stream] = await Promise.all([
importOriginal<{ readonly ConnectionDriver: Context.Key<unknown, FakeDriver> }>(),
import("../../test/fakeEnvironment"), import("effect/Layer"), import("effect/Effect"), import("effect/Stream"),
]);
const withSkills = (config) => ({...config, providers: config.providers.map(provider => ({...provider,
skills: Array.from({length:20},(_,i)=>({name:`layout-skill-${i}`,description:`Layout skill ${i}`,path:`/workspace/render/.agents/skills/layout-${i}/SKILL.md`,enabled:true}))
}))});
const driver = {...fakeDriver, connect: (...args) => Effect.map(fakeDriver.connect(...args), lease => ({...lease, session: {...lease.session,
initialConfig: Effect.map(lease.session.initialConfig, withSkills),
subscribeServerConfig: () => Stream.map(lease.session.subscribeServerConfig(), event => ({...event,config:withSkills(event.config)}))
}}))};
return { ...actual, layer: Layer.succeed(actual.ConnectionDriver, driver) };
});
vi.mock(import("~/rpc/requestLatencyState"), async (original) => ({ ...(await original()), trackRpcRequestSent: () => {} }));
function rect(selector: string) {
const el = document.querySelector<HTMLElement>(selector);
expect(el, selector).not.toBeNull();
const r = el!.getBoundingClientRect();
return { top: r.top, bottom: r.bottom, height: r.height, left: r.left, right: r.right };
}
function measure(kind: string, viewport: string) {
const layer = rect('[data-composer-drawer-layer="true"]');
const content = rect(kind === "stash" ? '[data-composer-stash-drawer="true"]' : '[data-composer-command-drawer="true"]');
const composer = rect('[data-chat-composer-main-surface="true"]');
const editor = rect('[data-testid="composer-editor"]');
const terminal = rect('[data-thread-terminal-drawer]');
const x = (editor.left + editor.right) / 2;
const y = Math.max(editor.top + 8, Math.min(content.bottom - 8, editor.bottom - 8));
const hit = document.elementFromPoint(x, y);
const data = { kind, viewport, composer, editor, terminal, layer, content,
spill: Math.round(content.bottom - layer.bottom),
editorOverlap: Math.round(Math.max(0, Math.min(content.bottom, editor.bottom) - Math.max(content.top, editor.top))),
pointerHitsStashInsideEditor: !!hit?.closest('[data-composer-stash-drawer="true"]'),
bottomPastViewport: Math.max(0, Math.round(content.bottom - window.innerHeight)),
visibleStashRows: [...document.querySelectorAll('[data-stash-entry]')].filter(el => { const r=el.getBoundingClientRect(); return r.bottom>content.top && r.top<content.bottom; }).length,
};
console.log('MEASURE ' + JSON.stringify(data));
return data;
}
for (const [width, height, terminalHeight] of [[1440,900,320],[1366,768,320],[1280,720,320],[1024,600,320],[1440,900,400],[1920,1080,320]] as const) {
it(`production composer ${width}x${height} terminal=${terminalHeight}`, async () => {
await page.viewport(width, height);
const thread = threadWithHistory(`stash-${width}-${height}-${terminalHeight}`, 1);
using _environment = installFakeEnvironment([thread]);
__setClientSettingsForTests({ ...DEFAULT_CLIENT_SETTINGS, onboardingCompletedAt: "2026-01-01T00:00:00.000Z", composerRichTextEnabled: false });
const ref = scopeThreadRef(ENVIRONMENT_ID, thread.id);
const draft = Array.from({length: 10}, (_, i) => `Draft line ${i+1}: preserve these instructions while choosing a stashed prompt.`).join('\n');
useComposerDraftStore.getState().setPrompt(ref, draft);
usePromptStashStore.setState({entries: []});
for (let i=0;i<20;i++) usePromptStashStore.getState().stashEntry({id:`entry-${i}`,createdAt:"2026-10-01T00:00:00.000Z",prompt:`Saved prompt ${i}: investigate the UI layout`,attachments:[],droppedImageNames:[]});
useTerminalUiStateStore.getState().setTerminalHeight(ref, terminalHeight);
useTerminalUiStateStore.getState().setTerminalOpen(ref, true);
const router = getRouter(createMemoryHistory({initialEntries:[`/${ENVIRONMENT_ID}/${thread.id}`]}));
await router.load();
// AppRoot supplies the real app router.
await using _app = await render(<AppRoot router={router}/>, {router:false});
await expect.element(page.getByTestId('composer-editor')).toBeVisible();
await vi.waitFor(() => expect(rect('[data-thread-terminal-drawer]').height).toBeGreaterThan(100));
await page.getByRole('button',{name:'Stashed prompts: 20. Open stash.'}).click();
await expect.element(page.getByRole('list',{name:'Stashed prompts'})).toBeVisible();
await vi.waitFor(() => expect(rect('[data-composer-drawer-layer="true"]').height).toBeGreaterThan(0));
const stash = measure('stash',`${width}x${height},terminal=${terminalHeight}`);
if (height===768) await page.screenshot({path:'__PROOF_PREFIX__-stash.png',fullPage:false});
const scroll = document.querySelector<HTMLElement>('[data-composer-stash-drawer] [data-slot="scroll-area-viewport"]')!;
expect(scroll).not.toBeNull();
scroll.scrollTop = scroll.scrollHeight;
await vi.waitFor(() => expect(scroll.scrollTop).toBeGreaterThan(0));
await page.getByRole('button',{name:'Restore stashed prompt: Saved prompt 0: investigate the UI layout',exact:true}).click();
await expect.element(page.getByRole('list',{name:'Stashed prompts'})).not.toBeInTheDocument();
await vi.waitFor(() => expect(useComposerDraftStore.getState().getComposerDraft(ref)?.prompt).toBe(draft + '\n\nSaved prompt 0: investigate the UI layout'));
console.log(`RESTORE ${width}x${height} pointer restore last scrolled entry succeeded; draft preserved and prompt appended`);
await _app.unmount();
// Remount the production composer with the slash trigger at its real initial cursor.
useComposerDraftStore.getState().setPrompt(ref, draft + '\n/');
const controlRouter = getRouter(createMemoryHistory({initialEntries:[`/${ENVIRONMENT_ID}/${thread.id}`]}));
await controlRouter.load();
await using _control = await render(<AppRoot router={controlRouter}/>, {router:false});
await expect.element(page.getByRole('listbox')).toBeVisible();
await vi.waitFor(() => expect(rect('[data-thread-terminal-drawer]').height).toBeGreaterThan(100));
const command = measure('command',`${width}x${height},terminal=${terminalHeight}`);
expect(command.spill).toBeLessThanOrEqual(1);
expect(command.composer.height).toBe(stash.composer.height);
expect(command.terminal.height).toBe(stash.terminal.height);
if (height===768) await page.screenshot({path:'__PROOF_PREFIX__-slash.png',fullPage:false});
if (height===768) { expect(stash.spill).toBeGreaterThan(50); expect(stash.editorOverlap).toBeGreaterThan(50); expect(stash.pointerHitsStashInsideEditor).toBe(true); }
});
}
TSX
sed "s/__PROOF_PREFIX__/$name/g" "$fixture/fixture.browser.test.tsx" > "$test_file"
export HOME="$fixture/user-home" TMPDIR="$fixture/tmp" T3CODE_HOME="$fixture/t3-home" PLAYWRIGHT_BROWSERS_PATH="$browser_cache"
export PATH="$checkout/node_modules/.bin:$PATH"
cd "$checkout/apps/web"
printf 'REVISION '; git rev-parse HEAD
printf 'RUNTIME '; node --version
cases=${CASES:-'1440x900 terminal=320|1366x768 terminal=320|1280x720 terminal=320|1024x600 terminal=320|1440x900 terminal=400|1920x1080 terminal=320'}
old_ifs=$IFS
IFS='|'
for case_name in $cases; do
IFS=$old_ifs
if env -u NODE_ENV vp test run --project browser "src/components/$name.browser.test.tsx" -t "production composer $case_name" --silent=false > "$fixture/run.out" 2>&1; then
sed -n '/MEASURE /p; /RESTORE /p; /Tests .*passed/p' "$fixture/run.out"
cat "$fixture/run.out" >> "$fixture/all.out"
else
cat "$fixture/run.out"
exit 1
fi
IFS='|'
done
IFS=$old_ifs
if [ -n "${OUT:-}" ]; then
for kind in stash slash; do
if [ -f "src/components/$name-$kind.png" ]; then cp "src/components/$name-$kind.png" "$OUT/$kind-1366x768.png"; fi
done
fi
node - "$fixture/all.out" <<'JS'
const fs = require('node:fs');
const text = fs.readFileSync(process.argv[2], 'utf8');
const rows = text.split('\n').filter(l => l.includes('MEASURE ')).map(l => JSON.parse(l.split('MEASURE ')[1]));
const stash = rows.filter(r => r.kind === 'stash');
const control = rows.filter(r => r.kind === 'command');
if (!stash.length || stash.length !== control.length) throw Error('Missing failing input or working control');
for (const c of control) if (c.spill > 1) throw Error('Slash control also overflowed');
if (!stash.some(r => r.spill > 50 && r.editorOverlap > 50 && r.pointerHitsStashInsideEditor)) throw Error('Defect not observed');
console.log('DEFECT PRESENT: stash rows paint over the real editor; slash controls stay within the layer; pointer restoration of the last scrolled stash entry succeeds.');
JSExpected behavior
The stash drawer should fit the space above the composer that the shared layer budgets for its menus, with entries scrolling inside that height, as the slash-command menu does. ComposerCommandMenuLayer computes maxHeight: Math.max(96, rect.top - 24 + composerOverlap) and applies it to its fixed flex-column layer (lines 80-93), and the production composer renders both ComposerStashMenu and ComposerCommandMenu inside that layer. The intended attachment overlap is 17px (one root em plus 1px), which accounts for 4px at the top of the editor in this layout. #9556 and the merged fix #9625 established this budget for the slash, skill and file menus; neither records an intentional exception for the stash.
Actual behavior
Summary. With the bottom terminal drawer open, opening the composer's Prompt Stash makes the stash list paint over the current draft. The stash keeps its own min(24rem,40dvh) scroll cap instead of fitting the height budget of the shared composer menu layer, so at 1366×768 with a 266px composer and a 320px terminal it extends 201px below its 155px layer and covers 205px of the 208px editor. The slash-command menu, placed in the same layer with identical geometry, stays inside the layer. Overflow also occurs at 1440×900, 1280×720 and 1024×600, but not at 1920×1080. Stash entries remain reachable and restoring one preserves the draft; the effect is visual obstruction while the stash is open.
At 1366×768 with the 320px terminal, the layer is 155px tall but the stash surface is about 356px tall: it extends 201px below the layer and intersects 205px of the 208px editor, and an elementFromPoint hit-test inside the editor lands on the stash. The screenshot shows stash rows painted over most of the draft lines. The slash control in the same composer and terminal geometry fits the 155px layer with 0px spill.
Measurements from the run (CSS pixels, rounded; the same numbers were observed in an earlier independent run and in the rerun of the script above):
viewport terminal (requested/actual) layer stash surface stash spill editor covered slash spill
1440x900 320/320 287 409 122 126 0
1366x768 320/320 155 356 201 205 0
1280x720 320/320 107 337 230 208 0
1024x600 320/220 96 289 193 197 0
1440x900 400/400 207 409 202 206 0
1920x1080 320/320 433 433 0 4 0
At 1024×600 the production terminal clamps the requested 320px to 220px and the stash still spills 193px. At 1920×1080 there is enough room and the stash stays inside the layer. In every case the stash stayed inside the viewport, scrolled, and restored its last entry by pointer, preserving the draft and appending the selected prompt; no inaccessible entries or lost text were observed.
Evidence
- Expected source: the shared layer budget in
apps/web/src/components/chat/ComposerCommandMenuLayer.tsxlines 32-55 and 80-93, both menus placed in that layer inapps/web/src/components/chat/ChatComposer.tsxlines 3402-3445, and the slash, skill and file menu fix in merged fix(web): keep the slash menu above the composer when vertical space is short #9625 for [Bug]: Slash-command menu overlaps the composer with the bottom terminal open #9556. - Failure source: the measurements and screenshots from the reproduction above;
ComposerStashMenu.tsxlines 127-145 renderComposerBanner.Rootwithout a shrinkingmin-h-0flex chain, andComposerBanner.ScrollinComposerBanner.tsxlines 248-258 caps the list independently atmax-h-[min(24rem,40dvh)], whileComposerCommandMenu.tsxlines 139-150 useflex min-h-0 ... flex-col overflow-hiddenwith amin-h-0list. - 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 with all six cases passing, printed the same measurements as the table (the slash control's editor overlap is the intended 4px in every case), and left git status --porcelain empty. An earlier production measurement with 12 stash entries also overflowed at 1366×768 (193px spill, 197px editor overlap), so the 20-entry fixture is not the only failing input. The source cause above is a hypothesis from reading the components; the fix is free to take a different approach.
Related
Closed #5604 concerns outside-click dismissal of the stash, closed #26588 a dark strip while the stash is closed, and closed #5441 stale anchoring when another pane opens. Open #28840 (a redesign) restructures the stash as a flex column with a capped scroll list but its root still lacks min-h-0 and it does not describe this overflow; open #28527 and #34998 change only the command menu and the layer's Safari viewport source.
Restoration check
With the script's input at 1366×768 and a 320px terminal, the open stash surface ends within 1px of the layer's bottom (spill ≤ 1px), covers no more of the editor than the intended attachment overlap, and a hit-test inside the editor below that overlap reaches the editor rather than the stash; the same holds at 1440×900, 1280×720 and 1024×600. The script's defect assertions then fail, as intended. Controls keep passing: the slash menu still fits the layer, the 1920×1080 case still shows the stash without spill, the stash list still scrolls, and pointer restoration of the last entry still preserves the draft and appends the prompt.
Impact
Cosmetic issue
P3 under triage: The open stash visibly obscures a multiline draft when the bottom terminal reduces the space above the composer, at ordinary laptop and desktop sizes; scrolling, pointer restoration and draft preservation keep working, so the observed effect is transient visual obstruction with no loss of text or function.
Version or commit
d9805b2, rerun from a clean checkout. Upstream main at 49b858b has byte-identical ComposerStashMenu.tsx, ComposerCommandMenuLayer.tsx, ComposerCommandMenu.tsx and ComposerBanner.tsx; a full-app runtime rerun on that head was not completed because the available dependency install lacks its newly required gt-react package.
Environment
macOS arm64, Node v26.12.1, locked Vite+/Vitest 5.0.5 and Playwright 1.64.1, headless Chromium browser tests run from apps/web with env -u NODE_ENV. The full production web UI was rendered against the repository's in-process fake environment; the desktop Electron app and native mobile were not run.
Logs or stack traces
Script output, condensed (each MEASURE line is a JSON record of layer, stash or slash surface, composer, editor and terminal rectangles; values below are taken from it):
REVISION d9805b2ca667be7caec8c9035950564ab96704c3
kind case terminal layer surface spill editorOverlap hitInsideEditorIsStash
stash 1440x900,terminal=320 320 287 409 122 126 true
command 1440x900,terminal=320 320 287 287 0 4 false
stash 1366x768,terminal=320 320 155 356 201 205 true
command 1366x768,terminal=320 320 155 155 0 4 false
stash 1280x720,terminal=320 320 107 337 230 208 true
command 1280x720,terminal=320 320 107 107 0 4 false
stash 1024x600,terminal=320 220 96 289 193 197 true
command 1024x600,terminal=320 220 96 96 0 4 false
stash 1440x900,terminal=400 400 207 409 202 206 true
command 1440x900,terminal=400 400 207 207 0 4 false
stash 1920x1080,terminal=320 320 433 433 0 4 false
command 1920x1080,terminal=320 320 305 305 0 4 false
RESTORE <each case> pointer restore last scrolled entry succeeded; draft preserved and prompt appended
Tests 1 passed | 5 skipped (6) (once per case)
DEFECT PRESENT: stash rows paint over the real editor; slash controls stay within the layer; pointer restoration of the last scrolled stash entry succeeds.
Screenshots, recordings, or supporting files
Workaround
Closing the stash (Escape with the composer focused, or choosing an entry, which preserves the draft) removes the overlap, and a taller window or shorter terminal gives the stash enough room.


Activity