Preserve composer command popovers after drag-drop support

Drag-drop support made the composer panel clip overflow so the
stateful slash and file popovers still opened but were hidden above
the panel. Restore visible overflow on the drop target while keeping
the drop overlay and attachment flow intact, then lock both active
and empty composer surfaces with regression coverage.

Constraint: Drag-drop attachments must continue using the existing path-only composer pipeline
Rejected: Move slash and file menus outside the composer tree | broader portal refactor not needed for this regression
Confidence: high
Scope-risk: narrow
Directive: Do not reintroduce overflow clipping on composer panels without proving bottom-full popovers still render
Tested: cd desktop && bun run test -- --run src/components/chat/ChatInput.test.tsx src/pages/EmptySession.test.tsx
Tested: cd desktop && bun run lint
Tested: Browser smoke at http://127.0.0.1:1421/?serverUrl=http%3A%2F%2F127.0.0.1%3A3456 for / and @src popovers
Tested: bun run check:desktop
This commit is contained in:
程序员阿江(Relakkes) 2026-05-16 13:49:06 +08:00
parent b6fcd9e422
commit 5c5c5933f0
5 changed files with 92 additions and 5 deletions

View File

@ -748,6 +748,42 @@ describe('ChatInput file mentions', () => {
})
})
it('keeps slash and @ popovers outside the drop target clipping context', async () => {
mocks.search.mockResolvedValueOnce({
currentPath: '/repo',
parentPath: null,
query: '',
entries: [
{ name: 'README.md', path: '/repo/README.md', isDirectory: false },
],
})
render(<ChatInput compact />)
const panel = screen.getByTestId('chat-input-panel')
const input = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.change(input, {
target: {
value: '/',
selectionStart: 1,
},
})
expect(await screen.findByText('/mcp')).toBeInTheDocument()
expect(panel).toHaveClass('overflow-visible')
expect(panel).not.toHaveClass('overflow-hidden')
fireEvent.change(input, {
target: {
value: '@readme',
selectionStart: 7,
},
})
expect(await screen.findByText('README.md')).toBeInTheDocument()
expect(panel).toHaveClass('overflow-visible')
expect(panel).not.toHaveClass('overflow-hidden')
})
it('uses larger icon-only mobile action buttons for browser H5 access', async () => {
viewportMocks.isMobile = true
mocks.search.mockResolvedValueOnce({

View File

@ -809,10 +809,10 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro
ref={panelRef}
data-testid="chat-input-panel"
className={isHeroComposer
? `glass-panel relative flex flex-col gap-3 overflow-hidden ${embedLaunchControlsInHero ? 'rounded-xl' : 'rounded-t-xl rounded-b-none'} p-4 transition-colors ${isDragActive ? 'composer-drop-target-active' : ''}`
? `glass-panel relative flex flex-col gap-3 overflow-visible ${embedLaunchControlsInHero ? 'rounded-xl' : 'rounded-t-xl rounded-b-none'} p-4 transition-colors ${isDragActive ? 'composer-drop-target-active' : ''}`
: compact
? `glass-panel relative overflow-hidden p-3 transition-colors ${isMobileComposer ? 'rounded-2xl shadow-[0_-12px_36px_rgba(54,35,28,0.12)]' : 'rounded-xl'} ${isDragActive ? 'composer-drop-target-active' : ''}`
: `glass-panel relative overflow-hidden transition-colors ${isMobileComposer ? 'rounded-2xl p-3 shadow-[0_-12px_36px_rgba(54,35,28,0.12)]' : 'rounded-xl p-4'} ${isDragActive ? 'composer-drop-target-active' : ''}`}
? `glass-panel relative overflow-visible p-3 transition-colors ${isMobileComposer ? 'rounded-2xl shadow-[0_-12px_36px_rgba(54,35,28,0.12)]' : 'rounded-xl'} ${isDragActive ? 'composer-drop-target-active' : ''}`
: `glass-panel relative overflow-visible transition-colors ${isMobileComposer ? 'rounded-2xl p-3 shadow-[0_-12px_36px_rgba(54,35,28,0.12)]' : 'rounded-xl p-4'} ${isDragActive ? 'composer-drop-target-active' : ''}`}
{...dragHandlers}
>
{isDragActive && (

View File

@ -970,7 +970,7 @@ describe('WorkspacePanel', () => {
expect(view.getByTestId('workspace-code').textContent).toContain('const line2300 = 2300')
})
expect(view.getByRole('button', { name: 'Collapse preview' })).toBeTruthy()
})
}, 10_000)
it('renders image previews from workspace files', async () => {
await setWorkspaceState((state) => ({

View File

@ -9,6 +9,8 @@ const mocks = vi.hoisted(() => ({
getMessages: vi.fn(),
getSlashCommands: vi.fn(),
listSkills: vi.fn(),
search: vi.fn(),
browse: vi.fn(),
getTasksForList: vi.fn(),
resetTaskList: vi.fn(),
wsClearHandlers: vi.fn(),
@ -39,6 +41,13 @@ vi.mock('../api/skills', () => ({
},
}))
vi.mock('../api/filesystem', () => ({
filesystemApi: {
search: mocks.search,
browse: mocks.browse,
},
}))
vi.mock('../api/cliTasks', () => ({
cliTasksApi: {
getTasksForList: mocks.getTasksForList,
@ -191,6 +200,12 @@ describe('EmptySession', () => {
mocks.getMessages.mockResolvedValue({ messages: [] })
mocks.getSlashCommands.mockResolvedValue({ commands: [] })
mocks.listSkills.mockResolvedValue({ skills: [] })
mocks.search.mockResolvedValue({
currentPath: '/workspace/project',
parentPath: null,
query: '',
entries: [],
})
mocks.getTasksForList.mockResolvedValue({ tasks: [] })
mocks.resetTaskList.mockResolvedValue(undefined)
})
@ -489,6 +504,42 @@ describe('EmptySession', () => {
})
})
it('keeps slash and @ popovers visible above the empty-session drop target', async () => {
mocks.search.mockResolvedValueOnce({
currentPath: '/workspace/project',
parentPath: null,
query: '',
entries: [
{ name: 'README.md', path: '/workspace/project/README.md', isDirectory: false },
],
})
render(<EmptySession />)
const panel = screen.getByTestId('empty-session-composer-panel')
const input = screen.getByRole('textbox') as HTMLTextAreaElement
fireEvent.change(input, {
target: {
value: '/',
selectionStart: 1,
},
})
expect(await screen.findByText('/mcp')).toBeInTheDocument()
expect(panel).toHaveClass('overflow-visible')
expect(panel).not.toHaveClass('overflow-hidden')
fireEvent.change(input, {
target: {
value: '@readme',
selectionStart: 7,
},
})
expect(await screen.findByText('README.md')).toBeInTheDocument()
expect(panel).toHaveClass('overflow-visible')
expect(panel).not.toHaveClass('overflow-hidden')
})
it('starts in a selected non-Git project without showing a repository warning', async () => {
mocks.getRepositoryContext.mockResolvedValueOnce(notGitRepositoryContext())

View File

@ -566,7 +566,7 @@ export function EmptySession() {
<div
ref={panelRef}
data-testid="empty-session-composer-panel"
className={`glass-panel relative flex flex-col gap-3 overflow-hidden ${
className={`glass-panel relative flex flex-col gap-3 overflow-visible ${
isMobileComposer ? 'rounded-2xl p-3 shadow-[0_-12px_36px_rgba(54,35,28,0.12)]' : 'rounded-xl p-0'
} ${isDragActive ? 'composer-drop-target-active' : ''}`}
{...dragHandlers}