mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
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:
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` |
|
||||
|
||||
## 开放问题
|
||||
|
||||
无。
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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': '置顶',
|
||||
|
||||
Reference in new issue
Block a user