From 5c5c5933f04ba68dd8dfb275300f19f7164f7647 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sat, 16 May 2026 13:49:06 +0800 Subject: [PATCH] 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 --- .../src/components/chat/ChatInput.test.tsx | 36 +++++++++++++ desktop/src/components/chat/ChatInput.tsx | 6 +-- .../workspace/WorkspacePanel.test.tsx | 2 +- desktop/src/pages/EmptySession.test.tsx | 51 +++++++++++++++++++ desktop/src/pages/EmptySession.tsx | 2 +- 5 files changed, 92 insertions(+), 5 deletions(-) diff --git a/desktop/src/components/chat/ChatInput.test.tsx b/desktop/src/components/chat/ChatInput.test.tsx index be175876..6e65185a 100644 --- a/desktop/src/components/chat/ChatInput.test.tsx +++ b/desktop/src/components/chat/ChatInput.test.tsx @@ -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() + + 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({ diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index de5d1bfe..e449c83f 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -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 && ( diff --git a/desktop/src/components/workspace/WorkspacePanel.test.tsx b/desktop/src/components/workspace/WorkspacePanel.test.tsx index 334aaa2e..d3a652ec 100644 --- a/desktop/src/components/workspace/WorkspacePanel.test.tsx +++ b/desktop/src/components/workspace/WorkspacePanel.test.tsx @@ -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) => ({ diff --git a/desktop/src/pages/EmptySession.test.tsx b/desktop/src/pages/EmptySession.test.tsx index ed57a01e..67bb8904 100644 --- a/desktop/src/pages/EmptySession.test.tsx +++ b/desktop/src/pages/EmptySession.test.tsx @@ -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() + + 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()) diff --git a/desktop/src/pages/EmptySession.tsx b/desktop/src/pages/EmptySession.tsx index fdf235d2..82469abb 100644 --- a/desktop/src/pages/EmptySession.tsx +++ b/desktop/src/pages/EmptySession.tsx @@ -566,7 +566,7 @@ export function EmptySession() {