From cb2978b222b913bf14c173d54bedd5b635af838d 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: Thu, 14 May 2026 17:07:37 +0800 Subject: [PATCH] Keep desktop launch context visually attached to the composer The empty-session desktop composer rendered repository launch context as a separate footer, which created a visual gap between the prompt surface and the project/branch/worktree controls. This keeps the PC layout in one panel while preserving the existing mobile bottom layout. Constraint: Mobile H5 composer layout must remain unchanged. Rejected: Restyle the shared launch bar globally | existing standalone and mobile placements rely on the old chrome. Confidence: high Scope-risk: narrow Directive: Keep the composer placement desktop-only unless mobile layout is intentionally redesigned. Tested: cd desktop && bun run test src/pages/EmptySession.test.tsx src/components/shared/RepositoryLaunchControls.test.tsx src/components/shared/DirectoryPicker.test.tsx Tested: bun run check:desktop Tested: agent-browser screenshots for PC and mobile empty-session composer. Not-tested: Packaged Tauri runtime smoke. --- .../shared/RepositoryLaunchControls.test.tsx | 10 + .../shared/RepositoryLaunchControls.tsx | 7 +- desktop/src/pages/EmptySession.test.tsx | 17 + desktop/src/pages/EmptySession.tsx | 338 +++++++++--------- 4 files changed, 211 insertions(+), 161 deletions(-) diff --git a/desktop/src/components/shared/RepositoryLaunchControls.test.tsx b/desktop/src/components/shared/RepositoryLaunchControls.test.tsx index cf5c32e3..db05ca1a 100644 --- a/desktop/src/components/shared/RepositoryLaunchControls.test.tsx +++ b/desktop/src/components/shared/RepositoryLaunchControls.test.tsx @@ -122,6 +122,16 @@ describe('RepositoryLaunchControls', () => { expect(listbox.parentElement?.className).toContain('w-[390px]') }) + it('uses the flatter desktop bar when embedded in a composer', async () => { + renderControls({ placement: 'composer' }) + + const trigger = await screen.findByRole('button', { name: 'Select branch: main' }) + const bar = trigger.parentElement + expect(bar).toHaveClass('min-h-[44px]', 'bg-transparent') + expect(bar).not.toHaveClass('rounded-b-xl') + expect(bar).not.toHaveClass('bg-[var(--color-surface-container-low)]') + }) + it('uses the full-width mobile bottom sheet in H5 mobile browser mode', async () => { viewportMocks.isMobile = true viewportMocks.isTauri = false diff --git a/desktop/src/components/shared/RepositoryLaunchControls.tsx b/desktop/src/components/shared/RepositoryLaunchControls.tsx index de78fdb7..856c3bfd 100644 --- a/desktop/src/components/shared/RepositoryLaunchControls.tsx +++ b/desktop/src/components/shared/RepositoryLaunchControls.tsx @@ -29,6 +29,7 @@ type Props = { onUseWorktreeChange: (enabled: boolean) => void onLaunchReadyChange?: (ready: boolean) => void disabled?: boolean + placement?: 'standalone' | 'composer' } const BRANCH_MENU_HEIGHT = 360 @@ -55,9 +56,11 @@ export function RepositoryLaunchControls({ onUseWorktreeChange, onLaunchReadyChange, disabled = false, + placement = 'standalone', }: Props) { const t = useTranslation() const isMobileBrowser = useMobileViewport() && !isTauriRuntime() + const isComposerPlacement = placement === 'composer' && !isMobileBrowser const [context, setContext] = useState(null) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) @@ -331,10 +334,12 @@ export function RepositoryLaunchControls({ } return ( -
+
diff --git a/desktop/src/pages/EmptySession.test.tsx b/desktop/src/pages/EmptySession.test.tsx index e79f14e2..949f12db 100644 --- a/desktop/src/pages/EmptySession.test.tsx +++ b/desktop/src/pages/EmptySession.test.tsx @@ -198,6 +198,23 @@ describe('EmptySession', () => { expect(screen.getByTestId('empty-session-composer-panel')).toHaveClass('rounded-2xl') }) + it('integrates repository launch controls into the desktop composer panel', async () => { + render() + + const panel = screen.getByTestId('empty-session-composer-panel') + expect(panel).toHaveClass('rounded-xl', 'p-0') + expect(panel).not.toHaveClass('rounded-b-none') + + fireEvent.click(screen.getByRole('button', { name: 'Pick project' })) + + const branchButton = await screen.findByRole('button', { name: 'Select branch: main' }) + const launchBar = branchButton.parentElement + expect(launchBar).toBeTruthy() + expect(launchBar).toHaveClass('bg-transparent') + expect(launchBar).not.toHaveClass('rounded-b-xl') + expect(panel).toContainElement(launchBar) + }) + it('creates a session with the selected project and branch when submitted', async () => { render() diff --git a/desktop/src/pages/EmptySession.tsx b/desktop/src/pages/EmptySession.tsx index c3079217..4d9e2a39 100644 --- a/desktop/src/pages/EmptySession.tsx +++ b/desktop/src/pages/EmptySession.tsx @@ -553,194 +553,212 @@ export function EmptySession() {
event.preventDefault()} onDrop={handleDrop} > - {fileSearchOpen && ( - { - if (atCursorPos < 0) return - const replacement = `@${relativePath}` - const tokenEnd = atCursorPos + 1 + atFilter.length - const newValue = `${input.slice(0, atCursorPos)}${replacement}${input.slice(tokenEnd)}` - const newCursorPos = atCursorPos + replacement.length - setInput(newValue) - setAtFilter(relativePath) - requestAnimationFrame(() => { - textareaRef.current?.focus() - textareaRef.current?.setSelectionRange(newCursorPos, newCursorPos) - }) - }} - onSelect={(path, name) => { - if (atCursorPos >= 0) { - const attachmentName = name.split('/').filter(Boolean).pop() ?? name +
+ {fileSearchOpen && ( + { + if (atCursorPos < 0) return + const replacement = `@${relativePath}` const tokenEnd = atCursorPos + 1 + atFilter.length - const beforeToken = input.slice(0, atCursorPos) - const afterToken = beforeToken ? input.slice(tokenEnd) : input.slice(tokenEnd).replace(/^\s+/, '') - const spacer = beforeToken && afterToken && !/\s$/.test(beforeToken) && !/^\s/.test(afterToken) ? ' ' : '' - const newValue = `${beforeToken}${spacer}${afterToken}` - const newCursorPos = atCursorPos + spacer.length - setAttachments((prev) => [ - ...prev, - { - id: `att-${Date.now()}-${Math.random().toString(36).slice(2)}`, - name: attachmentName, - type: 'file', - path, - }, - ]) + const newValue = `${input.slice(0, atCursorPos)}${replacement}${input.slice(tokenEnd)}` + const newCursorPos = atCursorPos + replacement.length setInput(newValue) - setFileSearchOpen(false) - setAtFilter('') - setAtCursorPos(-1) - void textareaRef.current?.focus() + setAtFilter(relativePath) requestAnimationFrame(() => { + textareaRef.current?.focus() textareaRef.current?.setSelectionRange(newCursorPos, newCursorPos) }) - } - }} - /> - )} + }} + onSelect={(path, name) => { + if (atCursorPos >= 0) { + const attachmentName = name.split('/').filter(Boolean).pop() ?? name + const tokenEnd = atCursorPos + 1 + atFilter.length + const beforeToken = input.slice(0, atCursorPos) + const afterToken = beforeToken ? input.slice(tokenEnd) : input.slice(tokenEnd).replace(/^\s+/, '') + const spacer = beforeToken && afterToken && !/\s$/.test(beforeToken) && !/^\s/.test(afterToken) ? ' ' : '' + const newValue = `${beforeToken}${spacer}${afterToken}` + const newCursorPos = atCursorPos + spacer.length + setAttachments((prev) => [ + ...prev, + { + id: `att-${Date.now()}-${Math.random().toString(36).slice(2)}`, + name: attachmentName, + type: 'file', + path, + }, + ]) + setInput(newValue) + setFileSearchOpen(false) + setAtFilter('') + setAtCursorPos(-1) + void textareaRef.current?.focus() + requestAnimationFrame(() => { + textareaRef.current?.setSelectionRange(newCursorPos, newCursorPos) + }) + } + }} + /> + )} - {localSlashPanel && ( -
- setLocalSlashPanel(null)} + {localSlashPanel && ( +
+ setLocalSlashPanel(null)} + /> +
+ )} + + {slashMenuOpen && filteredCommands.length > 0 && ( +
+
+ {filteredCommands.map((command, index) => ( + + ))} +
+
+ )} + + {attachments.length > 0 && ( + + )} + +
+