mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-08-01 16:43:37 +08:00
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:
parent
dbf01846e0
commit
128eb77e08
@ -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',
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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'
|
||||
}`}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user