feat(web-shell): surface worktree isolation in the new-session empty state (#7365)

* feat(web-shell): surface worktree isolation in the new-session empty state

The worktree-isolated session entry was buried in the sidebar git-branch
pill dropdown, making it hard to discover. Add a visible toggle to the
chat empty state — the de-facto new-session page — that reuses the
existing pending-worktree state machine and lazy session creation, so no
SDK or daemon changes are needed. Enabling it shows the pending badge
with a cancel affordance; the first prompt then creates the session in an
isolated worktree. The toggle is offered only when the target workspace
is trusted and is a git repository, mirroring the sidebar entry gating.

Also simplify the sidebar git pill: drop the now-redundant "New worktree
task" item and make the pill open the changes view directly instead of a
single-item dropdown.

* chore: add PR verification screenshots for the worktree toggle

Co-authored-by: Qwen-Coder <[email protected]>

* test(web-shell): capture the new-session empty state in the visuals suite

The worktree toggle lives in the new-session empty state, and every visuals
scenario navigates to /session/:id via gotoSession — so the suite had never
rendered the empty state at all, and the before/after preview reported "no
screenshot changes" for this PR despite the new UI.

Add a `gotoNewSession` harness helper (primes the theme, lands on `/`, asserts
the theme took effect; no replay to settle) and a `worktree empty state`
scenario using the git-ready workspace this PR already made mockable
(`gitStatus` + the /workspaces/:cwd/git route). It captures both states — the
offered toggle and, after clicking, the pending-worktree badge with its cancel
affordance — and asserts the swap, so a regression fails an assertion rather
than only differing in the screenshot. All four captures are byte-stable
across runs (0% pixel diff).

The helper also closes the structural gap: any future empty-state work
(onboarding copy, first-run affordances) now has a way into the preview.

* refactor(web-shell): drop dead worktree session opt; click-test git chip (#7365)

* fix(web-shell): address review feedback on worktree toggle (#7365)

- Move focus to cancel button on toggle enable and back on cancel (a11y)
- Include branch name in git-pill button aria-label (a11y)
- Replace hardcoded flush() ticks with vi.waitFor() in test helper
- Move git-repo mock default from afterEach to beforeEach
- Add test: sidebar New chat clears pending worktree intent

---------

Co-authored-by: Qwen-Coder <[email protected]>
Co-authored-by: wenshao <[email protected]>
Co-authored-by: Qwen Code Autofix <[email protected]>
Co-authored-by: qwen-code-ci-bot <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-07-21 07:38:33 +00:00
1 parent 276094ae67
commit 2709d1fccd
17 files changed
+696 -89

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

@@ -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` |
## 开放问题
无。
+154 -10
View File
@@ -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 {
+139 -1
View File
@@ -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<void> {
await vi.waitFor(() => {
expect(container.querySelector(toggleSelector)).not.toBeNull();
});
}
async function clickButton(
container: HTMLElement,
selector: string,
): Promise<void> {
await act(async () => {
container.querySelector<HTMLButtonElement>(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<string, unknown>
| 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<string, unknown>
| 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<HTMLButtonElement>('[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<string, unknown>
| undefined;
expect(arg?.['worktree']).toBeUndefined();
});
});
it('reloads skills from the target workspace when starting a new session', async () => {
const { container } = renderApp({
lockedWorkspaceCwd: '/work/secondary',
+88 -14
View File
@@ -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';
@@ -3931,13 +3931,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();
@@ -5987,6 +5989,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<HTMLButtonElement>(null);
const worktreeCancelRef = useRef<HTMLButtonElement>(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(
() => (
<>
@@ -5995,20 +6027,64 @@ export function App({
) : (
<WelcomeHeader {...welcomeHeaderProps} />
)}
{worktreePending && (
{worktreePending ? (
<div className={styles.worktreeWelcomeBadge}>
<GitForkIcon size={20} strokeWidth={1.5} />
<span className={styles.worktreeWelcomeTitle}>
{t('worktree.welcomeTitle')}
<span className={styles.worktreeBadgeIcon}>
<GitForkIcon size={18} strokeWidth={1.8} />
</span>
<span className={styles.worktreeWelcomeDesc}>
{t('worktree.welcomeDesc')}
<span className={styles.worktreeBadgeText}>
<span className={styles.worktreeWelcomeTitle}>
{t('worktree.welcomeTitle')}
</span>
<span className={styles.worktreeWelcomeDesc}>
{t('worktree.welcomeDesc')}
</span>
</span>
<button
ref={worktreeCancelRef}
type="button"
className={styles.worktreeWelcomeCancel}
aria-label={t('worktree.cancel')}
data-testid="worktree-welcome-cancel"
onClick={handleCancelWorktree}
>
<XIcon size={14} strokeWidth={2} />
</button>
</div>
) : (
worktreeToggleEligible && (
<button
ref={worktreeToggleRef}
type="button"
className={styles.worktreeWelcomeToggle}
data-testid="worktree-welcome-toggle"
onClick={handleEnableWorktree}
>
<span className={styles.worktreeToggleIcon}>
<GitForkIcon size={16} strokeWidth={1.8} />
</span>
<span className={styles.worktreeToggleText}>
<span className={styles.worktreeToggleLabel}>
{t('worktree.welcomeTitle')}
</span>
<span className={styles.worktreeToggleHint}>
{t('worktree.toggleHint')}
</span>
</span>
</button>
)
)}
</>
),
[renderWelcomeHeader, welcomeHeaderProps, worktreePending, t],
[
renderWelcomeHeader,
welcomeHeaderProps,
worktreePending,
worktreeToggleEligible,
handleEnableWorktree,
handleCancelWorktree,
t,
],
);
const welcomeFooter = useMemo(
() => renderWelcomeFooter?.(welcomeHeaderProps),
@@ -6491,9 +6567,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);
@@ -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> | boolean;
onNewSession: (workspaceCwd?: string) => Promise<boolean> | 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}
@@ -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;
}
@@ -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 () => {
@@ -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<DaemonSessionSummary[]>([]);
@@ -357,40 +348,18 @@ export function WorkspaceSection({
)}
</button>
{onOpenGitDiff && workspace.trusted && gitStatus?.branch && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<span className={styles.gitPill} role="button" tabIndex={0}>
<GitBranchIndicator
branch={gitStatus.branch}
status={gitStatus}
compact
/>
</span>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="start">
<DropdownMenuItem onClick={() => onOpenGitDiff(workspace.cwd)}>
{t('gitDiff.title')}
</DropdownMenuItem>
{onNewWorktreeSession && (
<DropdownMenuItem
onClick={() => onNewWorktreeSession(workspace.cwd)}
className="flex-col items-start gap-0"
>
<span className="flex items-center gap-1.5">
<GitForkIcon
size={14}
strokeWidth={1.2}
style={{ color: 'var(--color-accent-fg, #8b5cf6)' }}
/>
{t('sidebar.newWorktreeTask')}
</span>
<span className="text-xs opacity-60 font-normal">
{t('sidebar.worktreeDescription')}
</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
<button
type="button"
className={styles.gitPill}
aria-label={`${t('gitDiff.title')} — ${gitStatus.branch}`}
onClick={() => onOpenGitDiff(workspace.cwd)}
>
<GitBranchIndicator
branch={gitStatus.branch}
status={gitStatus}
compact
/>
</button>
)}
{headerActions?.(actionsVisible)}
</div>
@@ -11,6 +11,7 @@ import {
type DaemonSessionState,
type DaemonSessionSummary,
type DaemonWorkspaceExtensionsStatus,
type DaemonWorkspaceGitStatus,
type DaemonWorkspaceMcpResourcesStatus,
type DaemonWorkspaceMcpStatus,
type DaemonWorkspaceMcpToolsStatus,
@@ -52,6 +53,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 {
@@ -304,6 +310,7 @@ export function createWebShellDaemonScenario(
sessionGroups: overrides.sessionGroups ?? [],
events: overrides.events ?? [],
state,
gitStatus: overrides.gitStatus,
};
}
@@ -500,6 +507,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) ||
@@ -557,6 +565,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(
@@ -674,6 +685,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;
@@ -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<void> {
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,
@@ -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(
@@ -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<typeof createWebShellDaemonScenario>[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<MockDaemonController> {
return installMockDaemon(page, scenario, { baseURL });
}
async function fillComposer(page: Page, text: string): Promise<void> {
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<string, unknown> | undefined {
const record = daemon.requests.find(
(r) => r.method === 'POST' && r.path === '/session',
);
return record?.body as Record<string, unknown> | 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);
});
+6 -6
View File
@@ -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…',
@@ -889,8 +890,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',
@@ -2205,7 +2204,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': '加载历史中…',
@@ -3056,7 +3057,6 @@ const ZH: Messages = {
'sidebar.newChat': '新对话',
'sidebar.newTask': '新建任务',
'sidebar.newWorktreeTask': '新建 Worktree 任务',
'sidebar.worktreeDescription': '在隔离的仓库副本中工作——变更不会影响主分支',
'sidebar.plugins': '插件',
'sidebar.project': '项目',
'sidebar.pinnedSessions': '置顶',