From f3dc2399c2468f3967e4f464a6abf16009647520 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, 7 May 2026 17:47:42 +0800 Subject: [PATCH 1/8] fix: polish desktop launch repository controls The empty-session launch surface needed to behave like a real coding-agent entry point: project, branch, and worktree choices are now visible together, and branch choices that cannot safely run in-place force an isolated worktree before session creation. Constraint: Desktop launch flows must preserve repository safety when a target branch is dirty or checked out elsewhere Rejected: Keep branch and worktree controls as detached chips | the selection state was easy to miss and visually inconsistent with the launcher Confidence: high Scope-risk: moderate Directive: Do not remove the automatic worktree requirement without rechecking dirty and checked-out branch launches through the desktop UI Tested: cd desktop && bun run test -- --run src/components/shared/DirectoryPicker.test.tsx src/pages/EmptySession.test.tsx Tested: cd desktop && bun run lint Tested: bun run check:desktop Tested: browser-driven desktop smoke on ports 4789/4790 Not-tested: bun run verify still has the pre-existing agent-utils coverage ratchet failure outside desktop --- desktop/src/__tests__/pages.test.tsx | 10 +-- .../shared/DirectoryPicker.test.tsx | 36 ++++++++++ .../src/components/shared/DirectoryPicker.tsx | 40 +++++++---- .../shared/RepositoryLaunchControls.tsx | 43 ++++++++---- desktop/src/i18n/locales/en.ts | 4 +- desktop/src/i18n/locales/zh.ts | 4 +- desktop/src/pages/EmptySession.test.tsx | 50 ++++++++++++++ desktop/src/pages/EmptySession.tsx | 66 +++++++++---------- 8 files changed, 181 insertions(+), 72 deletions(-) diff --git a/desktop/src/__tests__/pages.test.tsx b/desktop/src/__tests__/pages.test.tsx index 24e38544..b6833951 100644 --- a/desktop/src/__tests__/pages.test.tsx +++ b/desktop/src/__tests__/pages.test.tsx @@ -159,7 +159,7 @@ describe('Content-only pages render without errors', () => { expect(screen.queryByText('/internal-only')).not.toBeInTheDocument() }) - it('EmptySession renders mascot and composer', async () => { + it('EmptySession renders the launch composer', async () => { let container!: HTMLElement await act(async () => { container = render().container @@ -167,8 +167,8 @@ describe('Content-only pages render without errors', () => { await Promise.resolve() }) expect(container.querySelector('textarea')).toBeInTheDocument() - expect(container.innerHTML).toContain('New session') - expect(container.innerHTML).toContain('Ask anything') + expect(container.innerHTML).toContain('What should we build?') + expect(container.innerHTML).toContain('Ask Claude anything') }) it('EmptySession shows draft context usage before a session is created', async () => { @@ -235,11 +235,11 @@ describe('Content-only pages render without errors', () => { }) const { container } = render() // With empty messages, the hero is shown - expect(container.innerHTML).toContain('New session') + expect(container.innerHTML).toContain('What should we build?') // ChatInput has a textarea const textarea = container.querySelector('textarea') expect(textarea).toBeInTheDocument() - expect(textarea).toHaveAttribute('placeholder', 'Ask anything...') + expect(textarea).toHaveAttribute('placeholder', 'Ask Claude anything. Type @ to mention files or tools') expect(textarea).toHaveAttribute('rows', '2') expect(container.innerHTML).not.toContain('Preview') // Cleanup diff --git a/desktop/src/components/shared/DirectoryPicker.test.tsx b/desktop/src/components/shared/DirectoryPicker.test.tsx index fa638391..eeb86578 100644 --- a/desktop/src/components/shared/DirectoryPicker.test.tsx +++ b/desktop/src/components/shared/DirectoryPicker.test.tsx @@ -16,6 +16,7 @@ vi.mock('../../api/filesystem', () => ({ import { DirectoryPicker } from './DirectoryPicker' import { sessionsApi } from '../../api/sessions' +import { filesystemApi } from '../../api/filesystem' describe('DirectoryPicker', () => { it('uses the source repository name as the fallback label for desktop worktree paths', () => { @@ -57,4 +58,39 @@ describe('DirectoryPicker', () => { expect(trigger).toHaveTextContent('NanmiCoder/OpenCutSkill') expect(trigger).not.toHaveTextContent('main') }) + + it('supports the flat workbar trigger variant without changing the selected label', () => { + render( + , + ) + + const trigger = screen.getByRole('button') + expect(trigger).toHaveTextContent('project') + expect(trigger.className).toContain('rounded-lg') + expect(trigger.className).not.toContain('rounded-full') + }) + + it('renders browse entries without nesting interactive buttons', async () => { + vi.mocked(sessionsApi.getRecentProjects).mockResolvedValue({ projects: [] }) + vi.mocked(filesystemApi.browse).mockResolvedValue({ + currentPath: '/workspace', + parentPath: '/Users/nanmi', + entries: [{ name: 'project', path: '/workspace/project', isDirectory: true }], + }) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + render() + + fireEvent.click(screen.getByRole('button', { name: /选择项目|Select a project/ })) + fireEvent.click(await screen.findByText(/选择其他文件夹|Choose a different folder/)) + + expect(await screen.findByRole('button', { name: /project/ })).toBeInTheDocument() + expect(errorSpy).not.toHaveBeenCalledWith(expect.stringContaining('validateDOMNesting')) + + errorSpy.mockRestore() + }) }) diff --git a/desktop/src/components/shared/DirectoryPicker.tsx b/desktop/src/components/shared/DirectoryPicker.tsx index 1b68bc81..e4b15fdf 100644 --- a/desktop/src/components/shared/DirectoryPicker.tsx +++ b/desktop/src/components/shared/DirectoryPicker.tsx @@ -7,6 +7,7 @@ import { useTranslation } from '../../i18n' type Props = { value: string onChange: (path: string) => void + variant?: 'chip' | 'workbar' } type DirEntry = { name: string; path: string; isDirectory: boolean } @@ -28,7 +29,7 @@ function projectNameFromPath(filePath: string) { return displayRoot.split('/').filter(Boolean).pop() || filePath } -export function DirectoryPicker({ value, onChange }: Props) { +export function DirectoryPicker({ value, onChange, variant = 'chip' }: Props) { const t = useTranslation() const [isOpen, setIsOpen] = useState(false) const [mode, setMode] = useState<'recent' | 'browse'>('recent') @@ -144,6 +145,13 @@ export function DirectoryPicker({ value, onChange }: Props) { // Find selected project info const selectedProject = projects.find((p) => p.realPath === value) + const isWorkbar = variant === 'workbar' + const triggerClassName = isWorkbar + ? 'flex min-w-0 max-w-full items-center gap-2 rounded-lg px-2.5 py-2 text-sm font-medium text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35' + : 'flex items-center gap-2 px-3 py-1.5 bg-[var(--color-surface-container-low)] hover:bg-[var(--color-surface-hover)] rounded-full text-xs transition-colors border border-[var(--color-border)]' + const emptyTriggerClassName = isWorkbar + ? 'flex min-w-0 items-center gap-2 rounded-lg px-2.5 py-2 text-sm font-medium text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35' + : 'flex items-center gap-2 text-xs text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] transition-colors' return (
@@ -152,27 +160,27 @@ export function DirectoryPicker({ value, onChange }: Props) { ) : ( )} @@ -283,16 +291,22 @@ export function DirectoryPicker({ value, onChange }: Props) { {browseEntries.length === 0 ? (
{t('dirPicker.noSubdirs')}
) : browseEntries.map((entry) => ( - + - +
))} )} diff --git a/desktop/src/components/shared/RepositoryLaunchControls.tsx b/desktop/src/components/shared/RepositoryLaunchControls.tsx index 45fb8285..05c1c27c 100644 --- a/desktop/src/components/shared/RepositoryLaunchControls.tsx +++ b/desktop/src/components/shared/RepositoryLaunchControls.tsx @@ -199,6 +199,18 @@ export function RepositoryLaunchControls({ return null }, [context, selectedBranch, t, useWorktree]) + const requiresIsolation = useMemo(() => { + if (context?.state !== 'ok' || !selectedBranch) return false + if (selectedBranch.name === context.currentBranch) return false + return context.dirty || selectedBranch.checkedOut + }, [context, selectedBranch]) + + useEffect(() => { + if (requiresIsolation && !useWorktree) { + onUseWorktreeChange(true) + } + }, [onUseWorktreeChange, requiresIsolation, useWorktree]) + const selectBranch = (candidate: RepositoryBranchInfo) => { onBranchChange(candidate.name) setBranchMenuOpen(false) @@ -244,13 +256,15 @@ export function RepositoryLaunchControls({ onLaunchReadyChange?.(isLaunchReady) }, [isLaunchReady, onLaunchReadyChange]) + const workbarButtonClassName = 'inline-flex min-w-0 items-center gap-2 rounded-lg px-2.5 py-2 text-sm font-medium text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)]/35 disabled:cursor-not-allowed disabled:opacity-50' + return (
-
- +
+ {loading && workDir && ( -
+
{t('common.loading')}
@@ -258,40 +272,41 @@ export function RepositoryLaunchControls({ {isGitReady && ( <> +