diff --git a/.github/assets/worktree-toggle/01-toggle-off.png b/.github/assets/worktree-toggle/01-toggle-off.png new file mode 100644 index 00000000000..c87ecab8b4d Binary files /dev/null and b/.github/assets/worktree-toggle/01-toggle-off.png differ diff --git a/.github/assets/worktree-toggle/02-toggle-chip.png b/.github/assets/worktree-toggle/02-toggle-chip.png new file mode 100644 index 00000000000..9f877914a50 Binary files /dev/null and b/.github/assets/worktree-toggle/02-toggle-chip.png differ diff --git a/.github/assets/worktree-toggle/03-badge-on.png b/.github/assets/worktree-toggle/03-badge-on.png new file mode 100644 index 00000000000..88f89e9c570 Binary files /dev/null and b/.github/assets/worktree-toggle/03-badge-on.png differ diff --git a/.github/assets/worktree-toggle/04-badge-chip.png b/.github/assets/worktree-toggle/04-badge-chip.png new file mode 100644 index 00000000000..9ae7bbfd09a Binary files /dev/null and b/.github/assets/worktree-toggle/04-badge-chip.png differ diff --git a/docs/design/2026-07-20-worktree-empty-state-toggle.md b/docs/design/2026-07-20-worktree-empty-state-toggle.md new file mode 100644 index 00000000000..a4bb2abaae3 --- /dev/null +++ b/docs/design/2026-07-20-worktree-empty-state-toggle.md @@ -0,0 +1,77 @@ +# Web Shell 空状态 worktree 隔离开关 + +## 背景 + +Worktree 隔离会话(见 +[2026-07-19-webshell-worktree-sessions.md](./2026-07-19-webshell-worktree-sessions.md)) +目前的唯一入口是侧边栏 workspace 头部 **git 分支胶囊的下拉菜单** +(`WorkspaceSection.tsx`),且需要同时满足 `onOpenGitDiff`、 +`workspace.trusted`、`gitStatus?.branch` 三个条件才渲染。用户很难发现一个 +git pill 是可以点击的,功能隐藏过深。 + +Web Shell 没有独立的"新建会话页面"——点击新建会话后呈现的是聊天空状态 +(WelcomeHeader + 输入框),它就是事实上的新建会话页。空状态已经有现成的 +worktree pending 徽标 UI(`App.tsx` 的 `worktreeWelcomeBadge`)和完整的 +pending 状态机(`pendingWorktreeRef` / `worktreePending`),会话在发送首条 +消息时才真正创建(懒创建),因此"开关"只是设置一个 pending 意图。 + +## 目标 + +- 在聊天空状态提供可见的 worktree 隔离开关,点击后复用现有 pending 状态机 + 与懒创建链路。 +- 开启后显示现有 pending 徽标,并提供取消途径。 +- 保留侧边栏 git pill 菜单入口不变(per-workspace 快捷入口)。 + +## 非目标 + +- 不改动 SDK、daemon 路由、`GitWorktreeService`——创建链路完全复用。 +- 不改变"意图跟随 workspace"的既有语义:pending 意图始终作用于下一次创建 + 会话时解析出的 workspace(`lockedWorkspaceCwd ?? selectedWorkspaceCwd ?? +primary`),与侧边栏入口的现状一致。 +- 不处理"pending 开启后切换到非 git workspace"的失败提示(现状即会报错, + 超出本次范围)。 + +## 设计 + +### 开关可见性(eligibility) + +仅当以下条件全部满足时,空状态显示开关: + +| 条件 | 信号 | 理由 | +| -------------------------- | ------------------------------------------------------------- | --------------------------------------------------------------- | +| 聊天空状态 | `welcomeHeader` 仅在 `isChatEmptyState` 时渲染 | 天然满足,无需额外判断 | +| 当前 workspace 已信任 | `workspaces.find(e => e.cwd === activeWorkspaceCwd)?.trusted` | 与侧边栏入口一致:未信任 workspace 不做 git 变更 | +| 当前 workspace 是 git 仓库 | `selectedWorkspaceGitStatus?.branch` | daemon 对非 git 仓库硬失败(`worktree_not_git_repo`),提前隐藏 | + +`activeWorkspaceCwd` 复用现有 memo(`connection.sessionId ? +connection.workspaceCwd : (locked ?? selected ?? primary)`), +`selectedWorkspaceGitStatus` 复用现有拉取 effect。两者均为现有状态,不新增 +网络请求。git status 未加载完成前开关不显示,与侧边栏 `gitStatus?.branch` +门控行为一致。 + +### 交互 + +- **关闭态**:徽标位置渲染一个低调的 ghost 按钮(fork 图标 + + `worktree.welcomeTitle` 文案)。点击 → `pendingWorktreeRef.current = {}` + + `setWorktreePending(true)`。 +- **开启态**:渲染现有 `worktreeWelcomeBadge`(图标 + 标题 + 描述),右上角 + 增加 X 取消按钮(`aria-label` 用新 i18n key)。点击 → + `pendingWorktreeRef.current = undefined` + `setWorktreePending(false)`。 +- 发送首条消息 → `ensureSessionForPrompt` 按现有逻辑携带 `worktree: {}`, + 成功后自动清除 pending;失败保留徽标供重试(现状不变)。 +- 点侧边栏"新建会话"、加载已有会话等既有路径对 pending 的清除逻辑不变。 + +### 文件改动 + +| 文件 | 改动 | +| ----------------------------------------------------------------- | ------------------------------------------------------------------------ | +| `packages/web-shell/client/App.tsx` | eligibility memo、开启/取消 handler、welcomeHeader memo 中渲染开关/徽标 | +| `packages/web-shell/client/App.module.css` | ghost 开关按钮样式、徽标取消按钮样式 | +| `packages/web-shell/client/i18n.tsx` | 新增 `worktree.cancel`(en/zh) | +| `packages/web-shell/client/App.test.tsx` | 单元测试:可见性门控、开启/取消、提交时携带 `worktree: {}` | +| `packages/web-shell/client/e2e/utils/mockDaemon.ts` | 补 `workspaces` capability(含 `trusted`)与 `/workspaces/:cwd/git` 路由 | +| `packages/web-shell/client/e2e/web-shell.worktree-toggle.spec.ts` | 新增 Playwright E2E:开关出现/开启/取消、提交请求体含 `worktree` | + +## 开放问题 + +无。 diff --git a/packages/web-shell/client/App.module.css b/packages/web-shell/client/App.module.css index aa910208f89..24e4333b692 100644 --- a/packages/web-shell/client/App.module.css +++ b/packages/web-shell/client/App.module.css @@ -484,27 +484,171 @@ overflow: visible; } -.worktreeWelcomeBadge { +.worktreeWelcomeToggle { + display: flex; + align-items: center; + gap: 12px; + width: fit-content; + max-width: 100%; + margin: 18px auto 0; + padding: 9px 18px 9px 10px; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--card, transparent); + color: var(--foreground); + font-family: inherit; + text-align: left; + cursor: pointer; + box-shadow: 0 1px 2px rgb(0 0 0 / 0.04); + transition: + border-color 160ms ease, + box-shadow 160ms ease, + transform 160ms ease; + animation: worktreeWelcomeIn 220ms ease-out; +} + +.worktreeWelcomeToggle:hover { + border-color: var(--color-accent-border, rgba(139, 92, 246, 0.5)); + box-shadow: 0 6px 20px var(--color-accent-bg, rgba(139, 92, 246, 0.16)); + transform: translateY(-1px); +} + +.worktreeWelcomeToggle:active { + transform: translateY(0) scale(0.99); + transition-duration: 60ms; +} + +.worktreeToggleIcon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 34px; + height: 34px; + border-radius: 10px; + background: var(--color-accent-bg, rgba(139, 92, 246, 0.1)); + color: var(--color-accent-fg, #8b5cf6); + transition: + transform 220ms cubic-bezier(0.34, 1.56, 0.64, 1), + background 160ms ease; +} + +.worktreeWelcomeToggle:hover .worktreeToggleIcon { + transform: rotate(-10deg) scale(1.08); + background: var(--color-accent-bg, rgba(139, 92, 246, 0.16)); +} + +.worktreeToggleText { display: flex; flex-direction: column; + gap: 1px; + min-width: 0; +} + +.worktreeToggleLabel { + font-size: 13px; + font-weight: 600; + line-height: 1.35; + color: var(--foreground); +} + +.worktreeToggleHint { + font-size: 11.5px; + line-height: 1.35; + color: var(--muted-foreground); +} + +.worktreeWelcomeBadge { + position: relative; + display: flex; align-items: center; - gap: 4px; - margin-top: 12px; - padding: 12px 20px; - border-radius: 12px; + gap: 12px; + width: fit-content; + max-width: 100%; + margin: 18px auto 0; + padding: 10px 40px 10px 10px; + border: 1px solid var(--color-accent-border, rgba(139, 92, 246, 0.25)); + border-radius: 14px; background: var(--color-accent-bg, rgba(139, 92, 246, 0.06)); - border: 1px solid var(--color-accent-border, rgba(139, 92, 246, 0.15)); - color: var(--color-accent-fg, #8b5cf6); + color: var(--foreground); + text-align: left; + box-shadow: 0 2px 14px var(--color-accent-bg, rgba(139, 92, 246, 0.12)); + animation: worktreeWelcomeIn 220ms ease-out; +} + +.worktreeBadgeIcon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 36px; + height: 36px; + border-radius: 10px; + background: var(--color-accent-fg, #8b5cf6); + color: #fff; + box-shadow: 0 3px 10px var(--color-accent-border, rgba(139, 92, 246, 0.45)); +} + +.worktreeBadgeText { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 0; } .worktreeWelcomeTitle { - font-size: 14px; + font-size: 13px; font-weight: 600; + line-height: 1.35; + color: var(--color-accent-fg, #8b5cf6); } .worktreeWelcomeDesc { - font-size: 12px; - opacity: 0.7; + font-size: 11.5px; + line-height: 1.35; + color: var(--muted-foreground); +} + +.worktreeWelcomeCancel { + position: absolute; + top: 7px; + right: 7px; + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: none; + border-radius: 8px; + background: transparent; + color: var(--muted-foreground); + cursor: pointer; + opacity: 0.55; + transition: + opacity 140ms ease, + background 140ms ease, + color 140ms ease, + transform 180ms ease; +} + +.worktreeWelcomeCancel:hover { + opacity: 1; + background: var(--color-accent-border, rgba(139, 92, 246, 0.2)); + color: var(--color-accent-fg, #8b5cf6); + transform: rotate(90deg); +} + +@keyframes worktreeWelcomeIn { + from { + opacity: 0; + transform: translateY(5px) scale(0.97); + } + + to { + opacity: 1; + transform: translateY(0) scale(1); + } } .missingSessionState { diff --git a/packages/web-shell/client/App.test.tsx b/packages/web-shell/client/App.test.tsx index cea6e8e9f63..e28e8ceb0ac 100644 --- a/packages/web-shell/client/App.test.tsx +++ b/packages/web-shell/client/App.test.tsx @@ -358,13 +358,18 @@ vi.mock('./components/MessageList', async () => { } return { MessageList: React.forwardRef(function MessageList( - props: { showRetryHint?: boolean; onRetryClick?: () => void }, + props: { + showRetryHint?: boolean; + onRetryClick?: () => void; + welcomeHeader?: React.ReactNode; + }, ref: React.ForwardedRef<{ scrollToBottom: () => void }>, ) { React.useImperativeHandle(ref, () => ({ scrollToBottom: vi.fn() })); return React.createElement( 'div', { 'data-testid': 'messages' }, + props.welcomeHeader ?? null, React.createElement(InteractionBlockerProbe), props.showRetryHint ? React.createElement( @@ -1063,6 +1068,139 @@ describe('App session callbacks', () => { ); }); + describe('worktree welcome toggle', () => { + beforeEach(() => { + mockConnection.sessionId = undefined; + mockWorkspace.capabilities = { + workspaces: [ + { id: 'primary', cwd: '/workspace', primary: true, trusted: true }, + ], + }; + mockWorkspace.client.workspaceByCwd.mockImplementation(() => ({ + workspaceGit: vi.fn().mockResolvedValue({ branch: 'main' }), + workspaceSkills: mockWorkspaceActions.loadSkillsStatus, + })); + }); + + const toggleSelector = '[data-testid="worktree-welcome-toggle"]'; + const cancelSelector = '[data-testid="worktree-welcome-cancel"]'; + const badgeDesc = 'Changes happen'; + + async function waitForToggle(container: HTMLElement): Promise { + await vi.waitFor(() => { + expect(container.querySelector(toggleSelector)).not.toBeNull(); + }); + } + + async function clickButton( + container: HTMLElement, + selector: string, + ): Promise { + await act(async () => { + container.querySelector(selector)?.click(); + }); + } + + it('shows the toggle in the empty state for a trusted git workspace', async () => { + const { container } = renderApp(); + await waitForToggle(container); + }); + + it('hides the toggle for an untrusted workspace', async () => { + mockWorkspace.capabilities = { + workspaces: [ + { id: 'primary', cwd: '/workspace', primary: true, trusted: false }, + ], + }; + const { container } = renderApp(); + await flush(); + await flush(); + expect(container.querySelector(toggleSelector)).toBeNull(); + }); + + it('hides the toggle when the workspace is not a git repository', async () => { + mockWorkspace.client.workspaceByCwd.mockImplementation(() => ({ + workspaceGit: vi.fn().mockRejectedValue(new Error('not a git repo')), + workspaceSkills: mockWorkspaceActions.loadSkillsStatus, + })); + const { container } = renderApp(); + await flush(); + await flush(); + expect(container.querySelector(toggleSelector)).toBeNull(); + }); + + it('toggles the pending badge on and off', async () => { + const { container } = renderApp(); + await waitForToggle(container); + + await clickButton(container, toggleSelector); + expect(container.textContent).toContain(badgeDesc); + expect(container.querySelector(toggleSelector)).toBeNull(); + + await clickButton(container, cancelSelector); + expect(container.textContent).not.toContain(badgeDesc); + expect(container.querySelector(toggleSelector)).not.toBeNull(); + }); + + it('creates the session with worktree when the toggle is enabled', async () => { + const { container } = renderApp(); + await waitForToggle(container); + await clickButton(container, toggleSelector); + + await act(async () => { + testState.latestChatEditorProps?.onSubmit('work in isolation'); + await vi.waitFor(() => { + expect(mockSessionActions.createSession).toHaveBeenCalled(); + }); + }); + const arg = mockSessionActions.createSession.mock.calls[0]?.[0] as + | Record + | undefined; + expect(arg?.['worktree']).toEqual({}); + }); + + it('creates the session without worktree when the toggle is off', async () => { + renderApp(); + await flush(); + + await act(async () => { + testState.latestChatEditorProps?.onSubmit('regular session'); + await vi.waitFor(() => { + expect(mockSessionActions.createSession).toHaveBeenCalled(); + }); + }); + const arg = mockSessionActions.createSession.mock.calls[0]?.[0] as + | Record + | undefined; + expect(arg?.['worktree']).toBeUndefined(); + }); + + it('clears the pending worktree intent when starting a new session from the sidebar', async () => { + const { container } = renderApp(); + await waitForToggle(container); + await clickButton(container, toggleSelector); + expect(container.textContent).toContain(badgeDesc); + + await act(async () => { + container + .querySelector('[data-testid="new-session"]') + ?.click(); + await Promise.resolve(); + }); + + await act(async () => { + testState.latestChatEditorProps?.onSubmit('regular session'); + await vi.waitFor(() => { + expect(mockSessionActions.createSession).toHaveBeenCalled(); + }); + }); + const arg = mockSessionActions.createSession.mock.calls[0]?.[0] as + | Record + | undefined; + expect(arg?.['worktree']).toBeUndefined(); + }); + }); + it('reloads skills from the target workspace when starting a new session', async () => { const { container } = renderApp({ lockedWorkspaceCwd: '/work/secondary', diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index fef70381fae..acec8b039d5 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -38,7 +38,7 @@ import type { DaemonWorkspaceCapability, DaemonWorkspaceGitStatus, } from '@qwen-code/sdk/daemon'; -import { GitForkIcon } from 'lucide-react'; +import { GitForkIcon, XIcon } from 'lucide-react'; import { extractPendingPermission } from './adapters/transcriptAdapter'; import { MessageList, type MessageListHandle } from './components/MessageList'; import { extractVoiceModels, type VoiceModelOption } from './voice/voiceModels'; @@ -3929,13 +3929,15 @@ export function App({ * stay mounted until its prompt is admitted, or a rejection has nowhere to * render. Only that caller passes this. */ - opts?: { keepView?: boolean; worktree?: { slug?: string } }, + opts?: { keepView?: boolean }, ) => { const targetWorkspaceCwd = lockedWorkspaceCwd ?? workspaceCwd; selectedWorkspaceCwdRef.current = targetWorkspaceCwd; setSelectedWorkspaceCwd(targetWorkspaceCwd); - pendingWorktreeRef.current = opts?.worktree; - setWorktreePending(Boolean(opts?.worktree)); + // Starting a fresh chat drops any pending worktree intent set from the + // empty-state toggle, so it never leaks into the next created session. + pendingWorktreeRef.current = undefined; + setWorktreePending(false); // Close the drawer before awaiting so a failed createSession() doesn't leave // it stuck open with the page scroll still locked, matching loadSidebarSession. closeMobileDrawer(); @@ -5985,6 +5987,36 @@ export function App({ ], ); + // The empty-state toggle is offered only when the workspace the next + // session would land in is trusted and is a git repository — the daemon + // rejects worktree creation otherwise. Mirrors the sidebar entry's gating. + const worktreeToggleEligible = Boolean( + workspaces.find((entry) => entry.cwd === activeWorkspaceCwd)?.trusted && + selectedWorkspaceGitStatus?.branch, + ); + const worktreeToggleRef = useRef(null); + const worktreeCancelRef = useRef(null); + const worktreeFocusTarget = useRef<'cancel' | 'toggle' | null>(null); + const handleEnableWorktree = useCallback(() => { + pendingWorktreeRef.current = {}; + setWorktreePending(true); + worktreeFocusTarget.current = 'cancel'; + }, []); + const handleCancelWorktree = useCallback(() => { + pendingWorktreeRef.current = undefined; + setWorktreePending(false); + worktreeFocusTarget.current = 'toggle'; + }, []); + useEffect(() => { + if (!worktreeFocusTarget.current) return; + const target = worktreeFocusTarget.current; + worktreeFocusTarget.current = null; + if (target === 'cancel') { + worktreeCancelRef.current?.focus(); + } else { + worktreeToggleRef.current?.focus(); + } + }, [worktreePending]); const welcomeHeader = useMemo( () => ( <> @@ -5993,20 +6025,64 @@ export function App({ ) : ( )} - {worktreePending && ( + {worktreePending ? (
- - - {t('worktree.welcomeTitle')} + + - - {t('worktree.welcomeDesc')} + + + {t('worktree.welcomeTitle')} + + + {t('worktree.welcomeDesc')} + +
+ ) : ( + worktreeToggleEligible && ( + + ) )} ), - [renderWelcomeHeader, welcomeHeaderProps, worktreePending, t], + [ + renderWelcomeHeader, + welcomeHeaderProps, + worktreePending, + worktreeToggleEligible, + handleEnableWorktree, + handleCancelWorktree, + t, + ], ); const welcomeFooter = useMemo( () => renderWelcomeFooter?.(welcomeHeaderProps), @@ -6489,9 +6565,7 @@ export function App({ webShellThemeToSettingValue(theme), ); }} - onNewSession={(workspaceCwd, opts) => { - return createNewSession(workspaceCwd, opts); - }} + onNewSession={(workspaceCwd) => createNewSession(workspaceCwd)} onLoadSession={(sessionId, workspaceCwd) => { setMainView('chat'); return loadSidebarSession(sessionId, workspaceCwd); diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx index dd6f122c05a..3b21e3aa1c8 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx @@ -223,10 +223,7 @@ interface WebShellSidebarProps { onOpenSplitView: () => void; /** Whether to offer the in-window split view (large screens only). */ canOpenSplitView?: boolean; - onNewSession: ( - workspaceCwd?: string, - opts?: { worktree?: { slug?: string } }, - ) => Promise | boolean; + onNewSession: (workspaceCwd?: string) => Promise | boolean; onLoadSession: ( sessionId: string, workspaceCwd?: string, @@ -1393,14 +1390,14 @@ export function WebShellSidebar({ ]); const handleNewSession = useCallback( - (workspaceCwd?: string, opts?: { worktree?: { slug?: string } }) => { + (workspaceCwd?: string) => { if (creatingSessionRef.current) return; creatingSessionRef.current = true; setCreatingSession(true); void (async () => { try { - const created = await onNewSession(workspaceCwd, opts); + const created = await onNewSession(workspaceCwd); if (created) { void reload().catch(() => undefined); bumpWorkspaceReload(); @@ -3544,11 +3541,6 @@ export function WebShellSidebar({ groupActionsDisabled={groupBusy} excludePinned onOpenGitDiff={onOpenGitDiff} - onNewWorktreeSession={(cwd) => - handleNewSession(ws.primary ? undefined : cwd, { - worktree: {}, - }) - } formatTime={(iso) => formatRelativeTime(iso, t)} searchQuery={searchQuery} expanded={ws.primary ? projectExpanded : undefined} diff --git a/packages/web-shell/client/components/sidebar/WorkspaceSection.module.css b/packages/web-shell/client/components/sidebar/WorkspaceSection.module.css index d12ee66b179..695dae66275 100644 --- a/packages/web-shell/client/components/sidebar/WorkspaceSection.module.css +++ b/packages/web-shell/client/components/sidebar/WorkspaceSection.module.css @@ -86,6 +86,12 @@ display: inline-flex; flex: 1 1 auto; min-width: 0; + padding: 0; + border: none; + background: none; + font: inherit; + color: inherit; + text-align: inherit; cursor: pointer; } diff --git a/packages/web-shell/client/components/sidebar/WorkspaceSection.test.tsx b/packages/web-shell/client/components/sidebar/WorkspaceSection.test.tsx index 0026fe9c117..9c1d0fd764c 100644 --- a/packages/web-shell/client/components/sidebar/WorkspaceSection.test.tsx +++ b/packages/web-shell/client/components/sidebar/WorkspaceSection.test.tsx @@ -135,7 +135,7 @@ describe('WorkspaceSection label', () => { }); describe('WorkspaceSection git chip', () => { - it('renders a git chip inside a dropdown trigger for a trusted repo', async () => { + it('renders a clickable git chip for a trusted repo', async () => { const status: DaemonWorkspaceGitStatus = { v: 2, workspaceCwd: '/tmp/project', @@ -150,12 +150,22 @@ describe('WorkspaceSection git chip', () => { const chip = gitChip(); expect(chip).not.toBeNull(); - // The chip is now a read-only OUTPUT inside a DropdownMenuTrigger - // (the dropdown offers "Changes" and "New Worktree Task"). + // The chip is a read-only OUTPUT inside a button that opens the changes + // view on click. expect(chip?.tagName).toBe('OUTPUT'); expect(chip?.getAttribute('data-dirty')).toBe('true'); expect(chip?.className).toContain(gitStyles.gitBranchChipCompact); expect(chip?.getAttribute('aria-label')).toContain('main'); + + // The chip itself is a read-only OUTPUT; the wrapping button is what opens + // the Changes view. Click it to prove the onClick handler is actually wired + // — a miswire (e.g. a deleted onClick) would otherwise go undetected. + const button = chip?.closest('button'); + expect(button).not.toBeNull(); + act(() => { + button?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + expect(onOpenGitDiff).toHaveBeenCalledWith('/tmp/project'); }); it('hides the chip for an untrusted workspace and never queries git', async () => { diff --git a/packages/web-shell/client/components/sidebar/WorkspaceSection.tsx b/packages/web-shell/client/components/sidebar/WorkspaceSection.tsx index e5596d618ca..47b9b432d3f 100644 --- a/packages/web-shell/client/components/sidebar/WorkspaceSection.tsx +++ b/packages/web-shell/client/components/sidebar/WorkspaceSection.tsx @@ -13,14 +13,8 @@ import type { DaemonWorkspaceCapability, DaemonWorkspaceGitStatus, } from '@qwen-code/sdk/daemon'; -import { FolderClosedIcon, FolderOpenIcon, GitForkIcon } from 'lucide-react'; +import { FolderClosedIcon, FolderOpenIcon } from 'lucide-react'; import { GitBranchIndicator } from '../GitBranchIndicator'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '../ui/dropdown-menu'; import { SESSION_LIST_PAGE_SIZE } from '../../constants/sessions'; import { useI18n } from '../../i18n'; import { @@ -100,8 +94,6 @@ interface WorkspaceSectionProps { * fires this on click. Omitted for untrusted workspaces (no git surface). */ onOpenGitDiff?: (workspaceCwd: string) => void; - /** Create a new worktree-isolated session in this workspace. */ - onNewWorktreeSession?: (workspaceCwd: string) => void; } export function WorkspaceSection({ @@ -131,7 +123,6 @@ export function WorkspaceSection({ groupActionsDisabled, excludePinned = false, onOpenGitDiff, - onNewWorktreeSession, }: WorkspaceSectionProps) { const { t } = useI18n(); const [sessions, setSessions] = useState([]); @@ -357,40 +348,18 @@ export function WorkspaceSection({ )} {onOpenGitDiff && workspace.trusted && gitStatus?.branch && ( - - - - - - - - onOpenGitDiff(workspace.cwd)}> - {t('gitDiff.title')} - - {onNewWorktreeSession && ( - onNewWorktreeSession(workspace.cwd)} - className="flex-col items-start gap-0" - > - - - {t('sidebar.newWorktreeTask')} - - - {t('sidebar.worktreeDescription')} - - - )} - - + )} {headerActions?.(actionsVisible)} diff --git a/packages/web-shell/client/e2e/utils/mockDaemon.ts b/packages/web-shell/client/e2e/utils/mockDaemon.ts index 101c2907aad..2dc75050975 100644 --- a/packages/web-shell/client/e2e/utils/mockDaemon.ts +++ b/packages/web-shell/client/e2e/utils/mockDaemon.ts @@ -11,6 +11,7 @@ import { type DaemonSessionState, type DaemonSessionSummary, type DaemonWorkspaceExtensionsStatus, + type DaemonWorkspaceGitStatus, type DaemonWorkspaceMcpResourcesStatus, type DaemonWorkspaceMcpStatus, type DaemonWorkspaceMcpToolsStatus, @@ -51,6 +52,11 @@ export interface WebShellDaemonScenario { sessionGroups: DaemonSessionGroup[]; events: DaemonEvent[]; state: DaemonSessionState; + /** + * Response for `GET /workspaces/:cwd/git`. Defaults to a null-branch status + * (non-git workspace), matching the real daemon's graceful degradation. + */ + gitStatus?: DaemonWorkspaceGitStatus; } export interface MockDaemonController { @@ -289,6 +295,7 @@ export function createWebShellDaemonScenario( sessionGroups: overrides.sessionGroups ?? [], events: overrides.events ?? [], state, + gitStatus: overrides.gitStatus, }; } @@ -485,6 +492,7 @@ function isDaemonPath(path: string): boolean { /^\/workspace\/mcp\/[^/]+\/resources\/?$/.test(path) || /^\/workspace\/.+\/sessions\/?$/.test(path) || /^\/workspace\/.+\/session-groups\/?$/.test(path) || + /^\/workspaces\/.+\/git\/?$/.test(path) || path === '/session' || /^\/permission\/[^/]+\/?$/.test(path) || /^\/session\/[^/]+\/pending-prompts(?:\/[^/]+)?\/?$/.test(path) || @@ -542,6 +550,9 @@ function isDaemonRoute(method: string, path: string): boolean { if (method === 'GET' && /^\/session\/[^/]+\/events\/?$/.test(path)) { return true; } + if (method === 'GET' && /^\/workspaces\/.+\/git\/?$/.test(path)) { + return true; + } if ( method === 'POST' && /^\/session\/[^/]+\/(load|resume|prompt|permission\/[^/]+|model|approval-mode|heartbeat|cancel|detach)\/?$/.test( @@ -659,6 +670,17 @@ async function handleDaemonRoute( await json(route, catalog); return; } + if (method === 'GET' && /^\/workspaces\/.+\/git\/?$/.test(path)) { + await json( + route, + scenario.gitStatus ?? { + v: 2, + workspaceCwd: scenario.workspaceCwd, + branch: null, + }, + ); + return; + } if (method === 'POST' && path === '/session') { await json(route, sessionEnvelope(scenario, { attached: false })); return; diff --git a/packages/web-shell/client/e2e/visuals/harness.ts b/packages/web-shell/client/e2e/visuals/harness.ts index a7bf5273d20..751d12ab677 100644 --- a/packages/web-shell/client/e2e/visuals/harness.ts +++ b/packages/web-shell/client/e2e/visuals/harness.ts @@ -104,6 +104,24 @@ export async function gotoSession( ); } +/** + * Navigate to the new-session empty state (`/`) in the requested theme. Every + * other scenario lands on `/session/:id` via `gotoSession`, so without this the + * suite never renders the empty state at all — anything that lives only there + * (the onboarding copy, the worktree-isolation toggle) is invisible to the + * before/after preview. Asserts the theme took effect, same as `gotoSession`; + * there is no replay to settle because no session is loaded. + */ +export async function gotoNewSession( + page: Page, + theme: VisualTheme, +): Promise { + await primeTheme(page, theme); + await page.goto(`/?theme=${theme}`); + await expect(page.locator('[data-web-shell-root]')).toBeVisible(); + await expect(page.locator('html')).toHaveClass(new RegExp(`theme-${theme}`)); +} + export async function completeReplay( page: Page, daemon: MockDaemonController, diff --git a/packages/web-shell/client/e2e/visuals/screenshots.spec.ts b/packages/web-shell/client/e2e/visuals/screenshots.spec.ts index fc577f0bd9d..59979927b45 100644 --- a/packages/web-shell/client/e2e/visuals/screenshots.spec.ts +++ b/packages/web-shell/client/e2e/visuals/screenshots.spec.ts @@ -16,6 +16,7 @@ import { captureScreenshot, completeReplay, fillComposer, + gotoNewSession, gotoSession, installScenario, resolveBaseURL, @@ -393,6 +394,40 @@ for (const theme of THEMES) { await captureScreenshot(page, `workspace-sidebar-${theme}`); }); + test(`worktree empty state`, async ({ page }, testInfo) => { + // The new-session empty state offers worktree isolation only when the + // workspace the next session would use is trusted AND a git repo (the + // daemon rejects worktree creation otherwise). Every other scenario lands + // on /session/:id, so this is the suite's only view of the empty state — + // without it the toggle is invisible to the before/after preview. + const workspaceCwd = '/tmp/qwen-web-shell-e2e'; + const scenario = createWebShellDaemonScenario({ + workspaceCwd, + capabilities: { + workspaces: [ + { id: 'primary', cwd: workspaceCwd, primary: true, trusted: true }, + ], + }, + gitStatus: { v: 2, workspaceCwd, branch: 'main' }, + }); + await installScenario(page, scenario, resolveBaseURL(testInfo)); + await gotoNewSession(page, theme); + + const toggle = page.locator('[data-testid="worktree-welcome-toggle"]'); + await expect(toggle).toBeVisible(); + await captureScreenshot(page, `worktree-empty-state-${theme}`); + + // Enabling it swaps the toggle for the pending-worktree badge — the state + // the next session would be created in — with a cancel affordance. Assert + // the swap so a regression that drops the enabled state fails here, not + // only in the (visually reviewed) screenshot. + await toggle.click(); + await expect( + page.locator('[data-testid="worktree-welcome-cancel"]'), + ).toBeVisible(); + await captureScreenshot(page, `worktree-empty-state-enabled-${theme}`); + }); + test(`slash menu`, async ({ page }, testInfo) => { const scenario = createWebShellDaemonScenario(); const daemon = await installScenario( diff --git a/packages/web-shell/client/e2e/web-shell.worktree-toggle.spec.ts b/packages/web-shell/client/e2e/web-shell.worktree-toggle.spec.ts new file mode 100644 index 00000000000..30705920140 --- /dev/null +++ b/packages/web-shell/client/e2e/web-shell.worktree-toggle.spec.ts @@ -0,0 +1,122 @@ +import { expect, test, type Page } from '@playwright/test'; +import { + createWebShellDaemonScenario, + installMockDaemon, + type MockDaemonController, + type WebShellDaemonScenario, +} from './utils/mockDaemon'; + +const WORKSPACE_CWD = '/tmp/qwen-web-shell-e2e'; + +function createGitWorkspaceScenario( + overrides: Parameters[0] = {}, +): WebShellDaemonScenario { + return createWebShellDaemonScenario({ + capabilities: { + workspaces: [ + { id: 'primary', cwd: WORKSPACE_CWD, primary: true, trusted: true }, + ], + }, + gitStatus: { v: 2, workspaceCwd: WORKSPACE_CWD, branch: 'main' }, + ...overrides, + }); +} + +async function installScenario( + page: Page, + scenario: WebShellDaemonScenario, + baseURL: string, +): Promise { + return installMockDaemon(page, scenario, { baseURL }); +} + +async function fillComposer(page: Page, text: string): Promise { + const editor = page.locator('[data-web-shell-composer-editor] .cm-content'); + await editor.click(); + await page.keyboard.press( + process.platform === 'darwin' ? 'Meta+A' : 'Control+A', + ); + await page.keyboard.type(text); +} + +function sessionCreateBody( + daemon: MockDaemonController, +): Record | undefined { + const record = daemon.requests.find( + (r) => r.method === 'POST' && r.path === '/session', + ); + return record?.body as Record | undefined; +} + +test('enabling the worktree toggle sends worktree intent on session creation', async ({ + page, +}, testInfo) => { + const scenario = createGitWorkspaceScenario(); + const daemon = await installScenario( + page, + scenario, + String(testInfo.project.use.baseURL), + ); + + await page.goto('/'); + const toggle = page.locator('[data-testid="worktree-welcome-toggle"]'); + await expect(toggle).toBeVisible(); + await toggle.click(); + + await expect( + page.locator('[data-testid="worktree-welcome-cancel"]'), + ).toBeVisible(); + await expect(toggle).toHaveCount(0); + + await fillComposer(page, 'ping from worktree toggle'); + await page.locator('[data-web-shell-composer-submit]').click(); + + await expect.poll(() => sessionCreateBody(daemon) !== undefined).toBe(true); + expect(sessionCreateBody(daemon)?.['worktree']).toEqual({}); +}); + +test('cancelling the toggle omits worktree on session creation', async ({ + page, +}, testInfo) => { + const scenario = createGitWorkspaceScenario(); + const daemon = await installScenario( + page, + scenario, + String(testInfo.project.use.baseURL), + ); + + await page.goto('/'); + const toggle = page.locator('[data-testid="worktree-welcome-toggle"]'); + await expect(toggle).toBeVisible(); + await toggle.click(); + await page.locator('[data-testid="worktree-welcome-cancel"]').click(); + await expect(toggle).toBeVisible(); + + await fillComposer(page, 'ping after cancel'); + await page.locator('[data-web-shell-composer-submit]').click(); + + await expect.poll(() => sessionCreateBody(daemon) !== undefined).toBe(true); + expect(sessionCreateBody(daemon)?.['worktree']).toBeUndefined(); +}); + +test('toggle is hidden when the workspace is not a git repository', async ({ + page, +}, testInfo) => { + const scenario = createGitWorkspaceScenario({ gitStatus: undefined }); + const daemon = await installScenario( + page, + scenario, + String(testInfo.project.use.baseURL), + ); + + await page.goto('/'); + // Wait until the git status round-trip settles before asserting absence. + await expect + .poll(() => + daemon.requests.some((r) => /^\/workspaces\/.+\/git/.test(r.path)), + ) + .toBe(true); + await expect( + page.locator('[data-testid="worktree-welcome-toggle"]'), + ).toHaveCount(0); +}); diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index f2db9cd3e04..549dae6a69b 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -48,8 +48,9 @@ const EN: Messages = { 'gitDiff.expand': (v) => `Show changes for ${v?.path ?? 'file'}`, 'gitDiff.collapse': (v) => `Hide changes for ${v?.path ?? 'file'}`, 'worktree.welcomeTitle': 'Worktree isolated session', - 'worktree.welcomeDesc': - "Changes are made in a separate copy of the repo and won't affect your main branch", + 'worktree.toggleHint': 'Isolated copy · main branch untouched', + 'worktree.welcomeDesc': 'Changes happen in an isolated copy', + 'worktree.cancel': 'Cancel worktree isolation', 'gitLog.title': 'History', 'gitLog.subtitle': (v) => `${v?.count ?? 0} commits`, 'gitLog.loading': 'Loading history…', @@ -888,8 +889,6 @@ const EN: Messages = { 'sidebar.newChat': 'New chat', 'sidebar.newTask': 'New task', 'sidebar.newWorktreeTask': 'New worktree task', - 'sidebar.worktreeDescription': - 'Work in an isolated copy — changes stay separate from the main branch', 'sidebar.plugins': 'Plugins', 'sidebar.project': 'Project', 'sidebar.pinnedSessions': 'Pinned', @@ -2204,7 +2203,9 @@ const ZH: Messages = { 'gitDiff.expand': (v) => `显示 ${v?.path ?? '文件'} 的变更`, 'gitDiff.collapse': (v) => `隐藏 ${v?.path ?? '文件'} 的变更`, 'worktree.welcomeTitle': 'Worktree 隔离会话', - 'worktree.welcomeDesc': '变更在仓库的独立副本中进行,不会影响主分支', + 'worktree.toggleHint': '独立副本 · 不影响主分支', + 'worktree.welcomeDesc': '变更在独立副本中进行', + 'worktree.cancel': '取消 Worktree 隔离', 'gitLog.title': '提交历史', 'gitLog.subtitle': (v) => `${v?.count ?? 0} 条提交`, 'gitLog.loading': '加载历史中…', @@ -3054,7 +3055,6 @@ const ZH: Messages = { 'sidebar.newChat': '新对话', 'sidebar.newTask': '新建任务', 'sidebar.newWorktreeTask': '新建 Worktree 任务', - 'sidebar.worktreeDescription': '在隔离的仓库副本中工作——变更不会影响主分支', 'sidebar.plugins': '插件', 'sidebar.project': '项目', 'sidebar.pinnedSessions': '置顶',