fix(desktop): keep the run location in the composer toolbar for the whole session

Collapsing directory, branch and worktree into one pill was supposed to end
with the location holding still: editable while the session is a draft,
read-only afterwards, same row either way. It did not. The condition read
`isHeroComposer`, and ActiveSession renders the hero variant only while the
session is empty, so the variant and the draft state flip in the same render.
The location dropped back out to a chip below the panel at exactly the moment
it was meant to stay put. The condition is the composer's width now, not its
variant.

The test written to guard this rendered `variant="hero"` against a session
with messages — a combination ActiveSession never produces — so it passed
while the shipped composer still moved the chip. It renders the default
variant now and asserts the chip sits inside the panel.

Sizing that row exposed the rest of the mismatch: the draft and the live
session were two different geometries. The draft inset its divider inside the
panel's padding; the live one welded a `-mx-4 -mb-4` band to the panel edge.
The first message therefore shifted every control 4px left and 4px down and
stretched the divider by 34px. The live row adopts the draft spacing, because
EmptySession renders the same values — two shells against one.

That alone would have grown the panel by 8px, but the live textarea was also
paying for a descender gap: a textarea is inline-block, and the hero branch
escapes it only by sitting in a flex row. `block` recovers 6px, so the panel
ends up 2px taller with four alignment defects gone.

The narrow layouts keep the band. `p-3` has no padding to spend on inset, and
they never swap variants mid-session, so there is nothing there to hold still.
This commit is contained in:
程序员阿江(Relakkes) 2026-07-27 18:51:40 +08:00
parent dbf01846e0
commit 128eb77e08
3 changed files with 86 additions and 12 deletions

View File

@ -803,18 +803,37 @@ describe('ChatInput file mentions', () => {
// Sending the first message used to move the run location from inside the
// panel to a chip below it. It stays in the toolbar now and only loses its
// affordances, so nothing shifts under the cursor.
//
// The variant here is `default` on purpose: ActiveSession renders the hero
// composer only while the session is empty, so a live session is always the
// default one. Asserting this against `hero` passed while the shipped
// composer still dropped the chip below the panel.
it('swaps the pill for a read-only chip in the same row once the session has messages', async () => {
// beforeEach seeds one message, so this is a live session, not a draft.
render(<ChatInput variant="hero" />)
render(<ChatInput variant="default" />)
const chip = await screen.findByTestId('run-location-readonly')
expect(chip).toHaveTextContent('repo')
expect(chip).toHaveTextContent('main')
expect(screen.getByTestId('chat-input-toolbar')).toContainElement(chip)
expect(screen.getByTestId('chat-input-panel')).toContainElement(chip)
expect(screen.queryByTestId('run-location-outside')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /^Location/ })).not.toBeInTheDocument()
})
// The narrow layouts never adopted the in-toolbar pill: there is no room for
// it beside the model selector, so they keep the location on its own line
// below the panel.
it('keeps the run location below the panel on the composer beside a workspace panel', async () => {
render(<ChatInput compact />)
const chip = await screen.findByTestId('run-location-outside')
expect(chip).toHaveTextContent('repo')
expect(screen.getByTestId('chat-input-panel')).not.toContainElement(chip)
expect(screen.queryByTestId('run-location-readonly')).not.toBeInTheDocument()
})
it('uses the persisted message count to keep reopened sessions in context mode while history loads', async () => {
useSessionStore.setState({
sessions: [{
@ -1530,11 +1549,38 @@ describe('ChatInput file mentions', () => {
const toolbar = screen.getByTestId('chat-input-toolbar')
expect(toolbar).not.toHaveClass('absolute')
expect(toolbar).toHaveClass('mt-2')
expect(toolbar).toHaveClass('mt-3')
expect(input).not.toHaveClass('pb-12')
expect(input).not.toHaveClass('pb-14')
})
// The draft and the live session render the same composer, so the row that
// carries the location, the permission mode and the model has to sit in the
// same place in both. The live one used to weld itself to the panel edge
// with `-mx-4 -mb-4`, which pulled every control 4px left and stretched the
// divider across the panel the moment the first message landed.
it('keeps the wide composer toolbar inset when a draft turns into a live session', async () => {
const { unmount } = render(<ChatInput variant="hero" />)
const draftToolbar = screen.getByTestId('chat-input-toolbar')
expect(draftToolbar).toHaveClass('pt-3')
expect(draftToolbar.className).not.toMatch(/-m[xy]-\d/)
unmount()
const live = render(<ChatInput variant="default" />)
const liveToolbar = screen.getByTestId('chat-input-toolbar')
expect(liveToolbar).toHaveClass('pt-3')
expect(liveToolbar.className).not.toMatch(/-m[xy]-\d/)
live.unmount()
// The narrow composer keeps the band: `p-3` leaves too little room to
// spend on inset, and it never swaps variants mid-session.
render(<ChatInput compact />)
expect(screen.getByTestId('chat-input-toolbar')).toHaveClass('-mx-3')
})
it('uses Ctrl or Command Enter to send when that composer preference is selected', async () => {
useSettingsStore.setState({
chatSendBehavior: 'modifierEnter',

View File

@ -205,8 +205,14 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
// stays there for the whole session: editable while the session is still a
// draft, read-only once the first message lands. It used to jump from inside
// the panel to a chip below it at that moment.
const showLocationInToolbar = isHeroComposer && !useCompactControls && !isMemberSession
const embedLaunchControlsInHero = showLocationInToolbar && showLaunchControls
//
// Deliberately not keyed on `isHeroComposer`: ActiveSession only renders the
// hero variant while the session is empty, so keying on it moved the location
// out of the toolbar at the exact moment it was supposed to stay put — the
// first message swaps the variant and the draft state in the same render.
// The condition is the composer's width, not its variant.
const showLocationInToolbar = !useCompactControls && !isMemberSession
const embedLaunchControlsInToolbar = showLocationInToolbar && showLaunchControls
const pendingSlashUiAction = !isMemberSession && input.trim().startsWith('/')
? resolveSlashUiAction(input.trim().slice(1))
: null
@ -1245,17 +1251,35 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
placeholder={composerPlaceholder}
disabled={isWorkspaceMissing}
rows={1}
className={`w-full resize-none bg-transparent text-sm leading-relaxed text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)] disabled:opacity-50 ${
// `block`: a textarea is inline-block by default, so it carries a
// ~6px descender gap under it. The hero branch escapes it through
// its flex row; this one is a plain block child and was sitting
// 18px above the divider where the hero sits 12px.
className={`block w-full resize-none bg-transparent text-sm leading-relaxed text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)] disabled:opacity-50 ${
useCompactControls ? 'py-1.5' : 'py-2'
}`}
/>
)}
<div data-testid="chat-input-toolbar" className={isHeroComposer
? 'flex items-center justify-between border-t border-[var(--color-border-separator)] pt-3'
: `mt-2 flex items-center justify-between border-t border-[var(--color-border-separator)] ${
useCompactControls ? `-mx-3 -mb-3 px-2.5 py-2 ${isMobileComposer ? 'gap-1' : 'gap-2'}` : '-mx-4 -mb-4 px-3 py-3'
}`}>
{/*
The wide composer keeps one geometry for the whole session. The
draft and the live session used to render two different rows the
draft's divider was inset inside the panel's padding, the live one
ran edge to edge over a `-mx-4 -mb-4` band so the first message
shifted every control left by 4px and widened the divider by 34px.
The hero spacing wins because EmptySession renders the same row.
Its top gap comes from the panel's own `flex-col gap-3`, which the
live panel does not have, so that one repeats here as `mt-3`.
The narrow layouts keep the band: `p-3` leaves too little room to
spend on inset, and they never swap variants mid-session anyway.
*/}
<div data-testid="chat-input-toolbar" className={`flex items-center justify-between border-t border-[var(--color-border-separator)] ${
isHeroComposer
? 'pt-3'
: useCompactControls
? `mt-2 -mx-3 -mb-3 px-2.5 py-2 ${isMobileComposer ? 'gap-1' : 'gap-2'}`
: 'mt-3 pt-3'
}`}>
<div
data-testid="chat-input-toolbar-leading"
className={`flex min-w-0 items-center ${isMobileComposer ? 'shrink-0 gap-1' : 'gap-2'}`}
@ -1302,7 +1326,7 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
<PermissionModeSelector compact={useCompactControls} />
{showLocationInToolbar && (
embedLaunchControlsInHero ? (
embedLaunchControlsInToolbar ? (
<RepositoryLaunchControls
workDir={activeLaunchWorkDir}
onWorkDirChange={handleLaunchWorkDirChange}

View File

@ -13,7 +13,10 @@ type Props = {
/**
* `toolbar` is the read-only twin of the run-location pill: same row, same
* metrics, minus the border and the chevron. Sending the first message swaps
* one for the other in place, so nothing moves.
* one for the other in place, so nothing moves. It is what the wide desktop
* composer renders; `chip` is left for the narrow layouts (H5, and the
* desktop composer beside an open workspace panel), which keep the location
* on its own line below the panel.
*/
variant?: 'chip' | 'toolbar'
}
@ -98,6 +101,7 @@ export function ProjectContextChip({
return (
<div
title={title}
data-testid="run-location-outside"
className={`inline-flex max-w-full items-center rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] text-[var(--color-text-secondary)] ${
compact ? 'gap-1.5 px-3 py-1.5 text-xs' : 'gap-2 px-4 py-2 text-sm'
}`}