From 3ac8daef66ac3aa0d067787725b6c5356ca43168 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 28 Oct 2026 12:34:58 +0800 Subject: [PATCH 1/6] fix(forge): clarify worktree controls and align change panels --- .../forge-panel/changes-view.expected.md | 6 +- .../forge-panel/local-card.expected.md | 4 + .../forge-panel/worktree-option.expected.md | 3 + apps/web/tests/forge-panel.e2e.ts | 107 +++++++++++++++++- .../client-ui-forge/README.i18n.yaml | 4 +- .../experimental/client-ui-forge/README.md | 4 +- .../experimental/client-ui-forge/README.zh.md | 6 +- .../src/client/ChangesTab.module.css | 8 -- .../client-ui-forge/src/client/ChangesTab.tsx | 1 - .../client-ui-forge/src/client/DiffFile.tsx | 8 +- .../src/client/FileLabel.module.css | 2 + .../src/client/ForgeTab.module.css | 15 ++- .../src/client/WorktreeToggle.module.css | 26 ++++- .../src/client/WorktreeToggle.tsx | 28 +++-- .../src/client/changes-address.ts | 5 +- .../client-ui-forge/src/client/locales.ts | 32 +++--- .../tests/changes-address.client.spec.ts | 2 +- .../tests/changes-tab.client.spec.tsx | 16 +-- .../tests/worktree-toggle.client.spec.tsx | 19 ++-- 19 files changed, 214 insertions(+), 82 deletions(-) create mode 100644 apps/web/tests/expected/forge-panel/local-card.expected.md create mode 100644 apps/web/tests/expected/forge-panel/worktree-option.expected.md diff --git a/apps/web/tests/expected/forge-panel/changes-view.expected.md b/apps/web/tests/expected/forge-panel/changes-view.expected.md index f8c94d3817..27d92b67f7 100644 --- a/apps/web/tests/expected/forge-panel/changes-view.expected.md +++ b/apps/web/tests/expected/forge-panel/changes-view.expected.md @@ -8,15 +8,13 @@ - button "左右对比" [pressed] - button "自动换行" [pressed] - button "收起 panel.ts" [expanded] -- 'button "复制路径: src/panel.ts"': src/panel.ts -- text: +2 −2 +- text: src/panel.ts +2 −2 - button "panel.ts 的更多操作" - text: 1 export const n1 = 1 1 export const n1 = 1 2 export const n2 = 2 2 export const n2 = 'two' 3 export const n3 = 3 3 export const n3 = 3 4 export const n4 = 4 4 export const n4 = 4 5 export const n5 = 5 5 export const n5 = 5 - button "6 行未变更" - text: 12 export const n12 = 12 12 export const n12 = 12 13 export const n13 = 13 13 export const n13 = 13 14 export const n14 = 14 14 export const n14 = 14 15 export const n15 = 15 15 export const n15 = 'fifteen' 16 export const n16 = 16 16 export const n16 = 16 17 export const n17 = 17 17 export const n17 = 17 18 export const n18 = 18 18 export const n18 = 18 - button "其余未变更的行" - button "收起 panel.md" [expanded] -- 'button "复制路径: docs/panel.md"': docs/panel.md -- text: 新文件 +2 −0 +- text: docs/panel.md 新文件 +2 −0 - button "panel.md 的更多操作" - text: "1 + # Panel 2 + Shows the change requests of a Session." diff --git a/apps/web/tests/expected/forge-panel/local-card.expected.md b/apps/web/tests/expected/forge-panel/local-card.expected.md new file mode 100644 index 0000000000..24058934d3 --- /dev/null +++ b/apps/web/tests/expected/forge-panel/local-card.expected.md @@ -0,0 +1,4 @@ +- button "Local changes" +- text: HEAD → origin/main +- 'button "View the changes of this Session: 60 files, 60 lines added, 0 lines deleted"': +60 −0 60 files +- button "Create change request" diff --git a/apps/web/tests/expected/forge-panel/worktree-option.expected.md b/apps/web/tests/expected/forge-panel/worktree-option.expected.md new file mode 100644 index 0000000000..a7034240e7 --- /dev/null +++ b/apps/web/tests/expected/forge-panel/worktree-option.expected.md @@ -0,0 +1,3 @@ +- text: main +- checkbox "新建工作树" +- text: 新建工作树 diff --git a/apps/web/tests/forge-panel.e2e.ts b/apps/web/tests/forge-panel.e2e.ts index 3422f1c8d7..c08fcd7277 100644 --- a/apps/web/tests/forge-panel.e2e.ts +++ b/apps/web/tests/forge-panel.e2e.ts @@ -328,7 +328,7 @@ async function requestReleaseWorktree(failureShot: string, followUp = false): Pr const { page } = launched const option = page.locator('[data-forge-worktree]') await option.waitFor({ timeout: 20_000 }) - await option.getByRole('checkbox', { name: '工作树', exact: true }).check() + await option.getByRole('checkbox', { name: '新建工作树', exact: true }).check() await option.getByRole('button', { name: '基于 main', exact: true }).click() await page.getByRole('menuitem', { name: 'release', exact: true }).click() await option.getByRole('button', { name: '基于 release', exact: true }).waitFor() @@ -626,9 +626,9 @@ it.skipIf(MODE === 'record')('shows the local changes of the Session by scope an await local.locator('[data-forge-diff-file="notes.md"]').getByText('新文件 · 未提交', { exact: true }).waitFor() const readme = local.locator('[data-forge-diff-file="README.md"]') await page.context().grantPermissions(['clipboard-read', 'clipboard-write']) - await readme.getByRole('button', { name: '复制路径: README.md', exact: true }).click() - await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('README.md') await page.evaluate(() => navigator.clipboard.writeText('unchanged')) + await readme.locator('[data-forge-file-label]').click() + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('unchanged') await readme.locator('[data-forge-path-spacer]').click() await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('unchanged') await readme.getByRole('button', { name: '6 行未变更', exact: true }).click() @@ -654,14 +654,15 @@ it.skipIf(MODE === 'record')('shows the local changes of the Session by scope an await panelFile.getByRole('button', { name: '6 行未变更', exact: true }).click() await panelFile.getByText('export const n6 = 6', { exact: true }).first().waitFor({ timeout: 20_000 }) await panelFile.getByRole('button', { name: '2 行未变更', exact: true }).waitFor() - await panelFile.getByRole('button', { name: '复制路径: src/panel.ts', exact: true }).click() + await panelFile.getByRole('button', { name: 'panel.ts 的更多操作', exact: true }).click() + await page.getByRole('menuitem', { name: '复制路径', exact: true }).click() await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('src/panel.ts') const docsFile = view.locator('[data-forge-diff-file="docs/panel.md"]') await docsFile.getByRole('button', { name: '收起 panel.md', exact: true }).click() await page.setViewportSize({ width: 1280, height: 500 }) const scrollport = view.locator('[data-forge-changes-scroll]') await scrollport.evaluate((element) => { element.scrollTop = 100; element.dispatchEvent(new Event('scroll')) }) - await panelFile.getByRole('button', { name: '复制路径: src/panel.ts', exact: true }).click() + await panelFile.locator('[data-forge-file-label]').click() const position = await scrollport.evaluate(element => element.scrollTop) expect(position).toBeGreaterThan(0) const originalFile = await panelFile.elementHandle() @@ -697,6 +698,100 @@ it.skipIf(MODE === 'record')('shows the local changes of the Session by scope an }) +it.skipIf(MODE === 'record')('keeps localized status columns separate and long file lists inside one vertical scroller', async () => { + const { scaffold, page, project, tripwire } = await launchPanel('web-e2e-forge-layout') + await page.locator('[data-forge-current-branch]').waitFor() + await compareOrRefreshGolden(join(EXPECTED, 'worktree-option.expected.md'), + await captureStableAria(page, '[data-forge-worktree]', scaffold.workspaceCwd), MODE) + await page.getByRole('button', { name: '收起侧边栏', exact: true }).click() + await page.setViewportSize({ width: 390, height: 844 }) + await expect.poll(() => page.locator('[data-forge-worktree]') + .evaluate(element => element.getBoundingClientRect().right)).toBeLessThanOrEqual(390) + await page.setViewportSize({ width: 1680, height: 1000 }) + await git(project, 'update-ref', 'refs/remotes/origin/main', 'HEAD') + await git(project, 'symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main') + await mkdir(join(project, 'src')) + await Promise.all(Array.from({ length: 60 }, (_, index) => writeFile(join(project, 'src', `file-${index}.ts`), `export const value = ${index}\n`))) + const { settled } = await sendPrompt(page, scaffold) + const session = scaffold.ctx.sessions.get(await settled) + if (session === undefined) throw new Error('The Session must stay loaded') + scaffold.ctx.forgeSession.track(session, PULL) + await page.locator('[data-forge-header]').click() + const panel = page.locator('[data-forge-panel]') + await panel.getByRole('button', { name: '刷新状态', exact: true }).click() + const local = panel.locator('[data-forge-local]') + await local.locator('button[aria-expanded]').click() + await expect.poll(() => local.locator('li').count(), { timeout: 20_000 }).toBe(60) + for (const width of [1280, 390]) { + await page.setViewportSize({ width, height: 700 }) + const outerScrollers = await panel.evaluate((element) => { + const scrollers: string[] = [] + for (let ancestor = element.parentElement; ancestor !== null; ancestor = ancestor.parentElement) { + if (/auto|scroll/.test(getComputedStyle(ancestor).overflowY) && ancestor.scrollHeight > ancestor.clientHeight + 1) { + scrollers.push(ancestor.className) + } + } + return scrollers + }) + expect(outerScrollers).toEqual([]) + expect(await panel.evaluate(element => element.scrollHeight > element.clientHeight)).toBe(true) + await local.locator('li').last().scrollIntoViewIfNeeded() + expect(await local.locator('li').last().isVisible()).toBe(true) + } + await local.locator('button[aria-expanded]').click() + const change = panel.locator(`[data-forge-change="${PULL}"]`) + for (const [locale, labels] of [ + ['zh', ['检查', '评审', '合并', '变更']], + ['en', ['Checks', 'Review', 'Merge', 'Changes']], + ['de', ['Prüfungen', 'Review', 'Zusammenführen', 'Änderungen']], + ] as const) { + await scaffold.ctx.settings.update('locale', { preference: locale }) + await change.getByText(labels[0], { exact: true }).waitFor() + if (locale === 'en') { + await compareOrRefreshGolden(join(EXPECTED, 'local-card.expected.md'), + await captureStableAria(page, '[data-forge-local]', scaffold.workspaceCwd), MODE) + } + for (const width of [1280, 390]) { + await page.setViewportSize({ width, height: 700 }) + await expect.poll(() => change.evaluate(element => element.getBoundingClientRect().right)).toBeLessThanOrEqual(width) + await change.getByText(labels[3], { exact: true }).hover() + for (const label of labels) { + const geometry = await change.getByText(label, { exact: true }).evaluate((element) => { + const value = element.nextElementSibling + if (value === null) throw new Error('Status label must have a value') + const range = document.createRange() + range.selectNodeContents(element) + const name = range.getBoundingClientRect() + const content = value.getBoundingClientRect() + const row = element.parentElement! + const icon = element.previousElementSibling! + const rowRect = row.getBoundingClientRect() + return { + gap: content.left - name.right, + iconInset: icon.getBoundingClientRect().left - rowRect.left, + rowInset: rowRect.left - row.closest('[data-forge-change]')!.getBoundingClientRect().left, + labelWidth: element.getBoundingClientRect().width, + valueOffset: content.left - rowRect.left, + right: content.right, + width: document.documentElement.clientWidth, + } + }) + expect(geometry.gap).toBeGreaterThanOrEqual(8) + expect(geometry.iconInset).toBe(8) + expect(geometry.rowInset).toBeGreaterThanOrEqual(6) + expect(geometry.rowInset).toBeLessThanOrEqual(7) + if (locale === 'zh') { + expect(geometry.labelWidth).toBe(32) + expect(geometry.valueOffset).toBe(76) + } + expect(geometry.right).toBeLessThanOrEqual(geometry.width) + } + } + } + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) +}) + it.skipIf(MODE === 'record').each(['darwin', 'win32'] as const)('closes diff menus when the %s desktop shortcut deactivates a retained tab', async (platform) => { const { page, scaffold, project, tripwire } = await launchPanel(`web-e2e-forge-menu-${platform}`, 'normal', platform) await git(project, 'update-ref', 'refs/remotes/origin/main', 'HEAD') @@ -708,7 +803,7 @@ it.skipIf(MODE === 'record').each(['darwin', 'win32'] as const)('closes diff men await page.locator('[data-forge-panel]').getByRole('button', { name: '刷新状态', exact: true }).click() await page.locator('[data-forge-local]').getByRole('button', { name: /查看本会话的变更:1 个文件/ }).click() const view = page.locator('[data-forge-changes="local"]') - await view.getByRole('button', { name: '复制路径: README.md', exact: true }).waitFor() + await view.locator('[data-forge-diff-file="README.md"]').waitFor() for (const name of ['分支', '全部 1 个文件', 'README.md 的更多操作']) { await view.getByRole('button', { name, exact: true }).click() await page.getByRole('menu').waitFor() diff --git a/packages/experimental/client-ui-forge/README.i18n.yaml b/packages/experimental/client-ui-forge/README.i18n.yaml index 8c53380124..4138366ca5 100644 --- a/packages/experimental/client-ui-forge/README.i18n.yaml +++ b/packages/experimental/client-ui-forge/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 2f8a621b991260a1 /deepseek-ai-dsh-experimental-client-ui-forge/use-this-package: - en: 4c7d1cbc76085d84 - zh: 3f8fc65d6bea1cfb + en: ae3d26e8ed457318 + zh: c1a51731c3075af2 /deepseek-ai-dsh-experimental-client-ui-forge/understand-the-implementation: en: 013e84157a09da7e zh: 99b4f276385d5e16 diff --git a/packages/experimental/client-ui-forge/README.md b/packages/experimental/client-ui-forge/README.md index e38514f161..c6efb48f65 100644 --- a/packages/experimental/client-ui-forge/README.md +++ b/packages/experimental/client-ui-forge/README.md @@ -29,14 +29,14 @@ Enable the [forge bundle](../forge-bundle/README.md) on the Plugins page; it com Panel text follows the application language: Chinese, English, Japanese, Spanish, French, German, Russian, or Portuguese. -- **Worktree option** — on the New Session screen, Worktree sits at the right end of the row with the workspace and mode pickers. Checking it shows the base branch, the current branch by default; the menu lists recent local branches. The option is disabled, with the reason in its tooltip, outside a Git checkout, in a read-only Session, and when the checkout or its Git directory lies outside the Session's writable root, as for a project that is itself a linked worktree. Sending the first message hands it to the Session at once, and it waits there while the branch `dsh--<4 random hexadecimal digits>` is created from the base under the repository's `.agents/worktrees/` and its checkout becomes the Session's working directory; the message's turn starts once the worktree exists. +- **Worktree option** — on the New Session screen, New worktree sits beside the current branch at the right end of the row with the workspace and mode pickers. Both stay hidden outside a Git checkout. The branch is read-only until New worktree is checked, when it becomes the base branch selector; the menu lists recent local branches. The option is disabled, with the reason in its tooltip, in a read-only Session and when the checkout or its Git directory lies outside the Session's writable root, as for a project that is itself a linked worktree. Sending the first message hands it to the Session at once, and it waits there while the branch `dsh--<4 random hexadecimal digits>` is created from the base under the repository's `.agents/worktrees/` and its checkout becomes the Session's working directory; the message's turn starts once the worktree exists. - **Creation card** — while the worktree is created, a card above the composer shows the new branch and its two steps, creating the branch and checking out files; More details adds the branch and base. Cancel stops the attempt. After a stopped or failed attempt the message keeps waiting, and the card shows the reason of a failure: Retry tries again with a new branch name, and Continue without a worktree withdraws the request so the message goes on in the current checkout. - **Removed worktree** — when the worktree the Session works in has been removed outside the Session, a card above the composer says so, and a message sent meanwhile waits without starting its turn until the worktree is restored or left; the card then says that the message goes on afterwards. Restore worktree, also offered as Restore in the tab, checks the branch out again at the same directory, or creates the branch again at the commit it started from when the branch is gone too; while it runs, the card follows its two steps, preparing the worktree and checking out files, and the tab's Restore turns into a spinner. Continue in the project moves the Session to the source checkout. Meanwhile the tab's first row shows a warning glyph. - **Header button** — appears when the Session has bound change requests or its directory is inside a Git checkout, and shows or hides the tab. A red dot marks an open change request with failed checks, requested changes, or conflicts. - **Session list** — after a Session row's time, a branch glyph shows while the Session works in the worktree the panel created for it, and a glyph shows the state of its most urgent change request: one needing attention first, then open, draft, not yet polled, merged, and closed. The glyph of an open change request keeps the row's subdued color with a dot for running, passed, or failed checks; merged and closed ones keep their state colors. The row's hover card names the worktree branch and what it started from, then up to three change requests with their number, title, state, and checks, and how many more there are. A restart shows the states known before it until the next poll. - **Tab** — the first row names the checked-out branch, or the abbreviated commit on a detached HEAD, after a folder glyph for the project directory or a branch glyph for a worktree, and the kind of checkout, which is the worktree the Session created with its base, another worktree, or the project directory; its tooltip adds the path with the Host home shortened to `~`, its button copies the full path, and for a removed worktree it offers Restore. Below it, each change request card shows a glyph for its state, its title, which opens it in the browser, and its number; the glyph tells open, draft, merged, and closed apart, and marks an open one that is not a draft with failed checks, requested changes, or conflicts as needing attention, as the header button's red dot does. The card's header also offers Open in browser, Copy change request link, and Remove from this Session, whose toast offers Undo. A collapsed card adds the checks of an open change request and the most urgent of requested changes, conflicts, or draft, or when a merged or closed one ended. An expanded card shows the head and base branches, then one row per fact: for an open change request the checks, whose row opens the list of checks with failed ones first and their durations and links, the review decision with unresolved threads, and the merge state, or when a merged or closed one ended; and the changed lines, formatted in the selected UI language, whose row opens the changes view. An open draft also offers Ready for review; a change request the host would merge now offers Merge, whose menu lists the methods the repository allows with the user's usual one chosen and pins the displayed head when opened. Confirm merge uses that head; a polled head change disables confirmation until the menu is reopened after review. A toast reports each outcome, and new commits since that head stop the merge. When exactly one bound change request is open, its card starts expanded. Refresh inspects the checkout, checks sign-in, and polls every change request again; its icon turns until the inspection and the sign-in check end. A banner shows the `gh auth login` command when sign-in is missing. - **Local changes card** — while the `branch` scope changes files and no open change request has the checked-out branch as its head, a card above the change requests names the ref it compares with, such as `HEAD → origin/main`, and shows the added and deleted lines and the number of files, which open the changes view; its file list, which names files as the changes view does, opens the view at one file. Create change request, offered once the code host repository is recognized, sends the Session a user message in the UI language that asks the Agent to create a change request for these changes, and a toast reports whether it was sent. -- **Changes view** — The Changes row of an expanded change request card and the local changes card open a right-sidebar tab that lists the changed files. A local view shows Branch by default: the working tree and untracked files against the fork point, which is the base commit of the worktree the Session created, else the merge base of HEAD with the default branch of the code host remote. Uncommitted compares them with HEAD, Unstaged with the index, and Staged compares the index with HEAD. A change request view shows the host's diff at its head and says how many commits the checked-out branch has beyond that head when it is the change request's head branch. The toolbar jumps to a file, totals the changed lines, refreshes, and switches between two columns and one and between wrapped and cut long lines. Visited changes tabs stay mounted across tab and Session switches, hiding the sidebar, and docking changes, preserving file folds, revealed large files, expanded context, layout, and scroll position while the diff is unchanged. Hidden changes views release their panel subscription and defer diff reads; returning reads again only when the latest head or local summary differs. A refresh that replaces the diff discards expanded context, and changed content may move the reading position. Temporary menus close when the view is hidden. Closing the tab or reloading the application discards this progress. Files are named before their directories, which use smaller subdued type and shorten first; hovering one shows its complete path, and both paths for a renamed file. Clicking the file name or directory in either diff source copies its repository-relative path and reports success or failure in a toast. Each file shows badges for a new, deleted, or renamed file, the last naming the old file name, or the old path when only the directory changed, and, in the Branch scope, an untracked one, and a menu that copies its path and opens the whole file in the sidebar for local changes or its page at the head in the browser for a change request. Unchanged lines between hunks collapse into rows that expand from the file's new side: the working tree, the index for Staged, or the head commit through the code host; a file that changed after the diff was read stays collapsed until the view reads again. A binary file and an untracked file over `maxDiffBytes` show a note instead of their lines, a file whose hunks draw more than 1,000 lines starts as one collapsed strip that names its changed lines and draws them when opened, and a file below files that change more than 5,000 lines starts collapsed. The view reads again on Refresh and the page refresh shortcut, on each navigation, when the change request's head moves, and when the Branch scope's summary changes; a change request's view first waits for the status that names its head, unless no provider serves it or that poll failed. While a read replaces changes already shown, the Refresh icon turns. A change request whose diff the code host or its provider refuses over a size limit, such as a GitHub diff over 300 files, says its changes are too large to show here and offers View in browser; another failed read offers Retry and, for a change request, View in browser. A change request's diff is downloaded once per head commit and base branch while it stays cached, so reopening, refreshing, and navigating at the same head read nothing from the host. +- **Changes view** — The Changes row of an expanded change request card and the local changes card open a right-sidebar tab that lists the changed files. The local tab is named Local changes and shows Branch by default: the working tree and untracked files against the fork point, which is the base commit of the worktree the Session created, else the merge base of HEAD with the default branch of the code host remote. Uncommitted compares them with HEAD, Unstaged with the index, and Staged compares the index with HEAD. A change request view shows the host's diff at its head and says how many commits the checked-out branch has beyond that head when it is the change request's head branch. The toolbar jumps to a file, totals the changed lines, refreshes, and switches between two columns and one and between wrapped and cut long lines. Visited changes tabs stay mounted across tab and Session switches, hiding the sidebar, and docking changes, preserving file folds, revealed large files, expanded context, layout, and scroll position while the diff is unchanged. Hidden changes views release their panel subscription and defer diff reads; returning reads again only when the latest head or local summary differs. A refresh that replaces the diff discards expanded context, and changed content may move the reading position. Temporary menus close when the view is hidden. Closing the tab or reloading the application discards this progress. Files are named before their directories, which use smaller subdued type and shorten first; hovering one shows its complete path, and both paths for a renamed file. Each file shows badges for a new, deleted, or renamed file, the last naming the old file name, or the old path when only the directory changed, and, in the Branch scope, an untracked one, and a menu that copies its path and opens the whole file in the sidebar for local changes or its page at the head in the browser for a change request. Unchanged lines between hunks collapse into rows that expand from the file's new side: the working tree, the index for Staged, or the head commit through the code host; a file that changed after the diff was read stays collapsed until the view reads again. A binary file and an untracked file over `maxDiffBytes` show a note instead of their lines, a file whose hunks draw more than 1,000 lines starts as one collapsed strip that names its changed lines and draws them when opened, and a file below files that change more than 5,000 lines starts collapsed. The view reads again on Refresh and the page refresh shortcut, on each navigation, when the change request's head moves, and when the Branch scope's summary changes; a change request's view first waits for the status that names its head, unless no provider serves it or that poll failed. While a read replaces changes already shown, the Refresh icon turns. A change request whose diff the code host or its provider refuses over a size limit, such as a GitHub diff over 300 files, says its changes are too large to show here and offers View in browser; another failed read offers Retry and, for a change request, View in browser. A change request's diff is downloaded once per head commit and base branch while it stays cached, so reopening, refreshing, and navigating at the same head read nothing from the host. `gitCommand`, `gitTimeoutMs`, `graceMs`, `maxOutputBytes`, `readinessTimeoutMs`, `maxBranches`, `worktreePrefix`, `maxDiffBytes`, `maxUntrackedFiles`, and `maxCachedDiffBytes` are listed in the [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-client-ui-forge). diff --git a/packages/experimental/client-ui-forge/README.zh.md b/packages/experimental/client-ui-forge/README.zh.md index 1ff38bba3c..a9377e7a3b 100644 --- a/packages/experimental/client-ui-forge/README.zh.md +++ b/packages/experimental/client-ui-forge/README.zh.md @@ -29,14 +29,14 @@ kind: "package-reference" 面板文案跟随应用语言,支持中文、英文、日语、西班牙语、法语、德语、俄语和葡萄牙语。 -- **工作树选项**——在新会话页面上,“工作树”位于工作区与模式选择器那一行的右端。勾选后显示基准分支,默认为当前分支;菜单列出最近的本地分支。不在 Git 检出中、Session 为只读,或检出及其 Git 目录不在 Session 的可写根内(例如项目本身就是链接 worktree)时,该选项不可用,提示中说明原因。发送第一条消息后,消息立即交给 Session 并在那里等待,同时从基准分支在仓库的 `.agents/worktrees/` 下创建分支 `dsh--<4 位随机十六进制数>`,并把它的检出设为 Session 的工作目录;worktree 建好后这条消息的这一轮才开始。 +- **工作树选项**——在新会话页面上,“新建工作树”与当前分支一起位于工作区与模式选择器那一行的右端。不在 Git 检出中时,两者均隐藏。分支默认为只读展示;勾选“新建工作树”后,它变为基准分支选择器,菜单列出最近的本地分支。Session 为只读,或检出及其 Git 目录不在 Session 的可写根内(例如项目本身就是链接 worktree)时,该选项不可用,提示中说明原因。发送第一条消息后,消息立即交给 Session 并在那里等待,同时从基准分支在仓库的 `.agents/worktrees/` 下创建分支 `dsh--<4 位随机十六进制数>`,并把它的检出设为 Session 的工作目录;worktree 建好后这条消息的这一轮才开始。 - **创建卡片**——创建 worktree 期间,输入框上方的卡片显示新分支以及“创建分支”“检出文件”两个步骤;“更多详情”会列出分支与基准。“取消”停止这次尝试。尝试被停止或失败后,消息继续等待,失败时卡片显示原因:“重试”换一个新分支名再试;“不使用工作树”撤回请求,消息在当前检出中继续发送。 - **工作树已被删除**——Session 所在的 worktree 在 Session 之外被删除后,输入框上方的卡片会说明这一点;此期间发送的消息会等待、不开始这一轮,直到该 worktree 被恢复或离开,卡片也会说明消息之后会接着发送。“恢复工作树”(标签页中的“恢复”与之相同)在原目录重新检出该分支;分支也已删除时,从它开始时的提交重新创建分支;恢复期间卡片显示“准备工作树”“检出文件”两个步骤,标签页中的“恢复”变为加载状态。“在项目目录继续”把 Session 移回源检出。此期间标签页顶部一行显示警示图标。 - **标题栏按钮**——Session 有已绑定的变更请求、或其目录位于 Git 检出中时出现,用来显示或隐藏标签页。打开状态的变更请求存在检查失败、被要求修改或冲突时,按钮带红点。 - **会话列表**——Session 在面板为它创建的 worktree 中工作时,会话行的时间之后显示分支图标;另有一个图标显示它最需要关注的变更请求的状态,依次为需要处理、打开、草稿、尚未轮询、已合并、已关闭。打开中的变更请求图标保持会话行的浅色,并用圆点标出检查进行中、已通过或失败;已合并与已关闭的保留各自的状态颜色。会话行的悬浮卡片写出 worktree 分支及其起点,再列出最多三个变更请求的编号、标题、状态与检查,并说明还有多少个。重启后,下次轮询之前显示重启前已知的状态。 - **标签页**——顶部一行显示当前检出的分支(分离 HEAD 时为缩写提交),项目目录前显示目录图标、worktree 前显示分支图标,并说明检出的类型(Session 创建的 worktree 及其基准、其他 worktree 或项目目录);它的提示给出路径,宿主主目录缩写为 `~`,旁边的按钮复制完整路径;worktree 已被删除时提供“恢复”。其下每张变更请求卡片显示状态图标、标题(点击在浏览器中打开)与编号;图标区分打开、草稿、已合并与已关闭,打开中的变更请求(草稿除外)检查失败、被要求修改或有冲突时显示为“需要处理”,与标题栏按钮的红点一致。卡片右上角还提供“在浏览器中打开”“复制变更请求链接”与“从本会话移除”,移除后的 toast 提供“撤销”。折叠的卡片还显示打开的变更请求的检查,以及被要求修改、冲突、草稿中最需要注意的一项;已合并或已关闭的则显示结束时间。展开的卡片显示源分支与目标分支,下面每项一行:打开的变更请求依次是检查(点击该行弹出检查列表,失败的排在前面,带耗时与链接)、评审结论与未解决讨论数、合并状态,已合并或已关闭的显示结束时间;最后是按所选界面语言格式化的变更行数,点击该行打开变更视图。打开中的草稿还提供“标记为可评审”;主机现在就会合并的变更请求提供“合并”,其菜单列出仓库允许的合并方式并选中用户常用的一种,打开时固定卡片显示的 head。“确认合并”使用这个 head;轮询发现 head 变化后禁用确认,需重新检查并打开菜单。每次操作的结果都以 toast 报告;该 head 之后有新提交时不会合并。恰好有一个已绑定的变更请求处于打开状态时,它的卡片默认展开。刷新会重新检查检出与登录状态,并立即再次轮询每个变更请求;检查与登录状态确认结束前,刷新图标持续旋转。缺少登录时,横幅显示 `gh auth login` 命令。 -- **变更卡片**——`branch` 范围有文件变更、且没有以当前检出分支为源分支的打开中变更请求时,变更请求列表上方的卡片显示比较的基准(例如 `HEAD → origin/main`)以及新增、删除行数与文件数,点击打开变更视图;展开的文件列表像变更视图一样标出各个文件,点击某个文件时,变更视图定位到该文件。识别出代码托管仓库后,卡片提供“创建变更请求”:它以界面语言向 Session 发送一条用户消息,请 Agent 为这些变更创建变更请求,并以 toast 报告是否已发送。 -- **变更视图**——展开的变更请求卡片上的“变更”一行与变更卡片会打开一个右侧边栏标签页,列出所有变更的文件。本地变更默认显示“分支”:工作树与未跟踪文件相对于分叉点的变更;分叉点是 Session 创建的 worktree 的基准提交,否则是 HEAD 与代码托管远端默认分支的合并基准。“未提交”与 HEAD 比较,“未暂存”与暂存区比较,“已暂存”比较暂存区与 HEAD。变更请求的视图显示托管平台在其 head 处的 diff;当前检出分支就是该变更请求的源分支时,还会说明本地分支比该 head 多出几个提交。工具栏可以跳到某个文件、汇总变更行数、刷新,并在左右对比与单栏对比、自动换行与截断长行之间切换。访问过的变更标签页会在切换标签页与 Session、隐藏侧边栏及调整停靠位置时保持挂载,在 diff 未改变时保留文件折叠、大文件展开、上下文展开、布局与滚动位置。隐藏的变更视图释放面板订阅并推迟 diff 读取;返回时仅在最新 head 或本地摘要不同时重新读取。刷新替换 diff 后会丢弃展开的上下文,内容变化也可能移动阅读位置。视图隐藏时关闭临时菜单。关闭标签页或重新加载应用后不保留这些进度。文件名在前,目录在后,用更小的浅色字显示,空间不够时目录先缩短;悬停时显示完整路径,重命名的文件显示新旧两条路径。两种 diff 来源中,点击文件名或目录都会复制仓库相对路径,并通过 toast 报告成功或失败。每个文件带有新文件、已删除、重命名的标记(重命名标记写出原文件名,只换了目录时写出原路径),“分支”范围下的未跟踪文件还标“未提交”;文件菜单可以复制路径,本地变更可在侧边栏打开整个文件,变更请求可在浏览器中查看该文件在 head 处的页面。hunk 之间未变更的行折叠成一行,点击后从文件的新版本展开:工作树、“已暂存”时为暂存区,或经托管平台读取的 head 提交;读取 diff 之后文件又有修改时不会展开,直到视图重新读取。二进制文件与超过 `maxDiffBytes` 的未跟踪文件只显示一条说明;hunk 超过 1,000 行的文件先显示为一条写出变更行数的折叠条,点击后才绘制;前面的文件累计变更超过 5,000 行后,其后的文件默认收起。刷新与页面刷新快捷键、每次导航、变更请求的 head 变化,以及“分支”范围的摘要变化时,视图都会重新读取;变更请求的视图会先等到给出 head 的状态再读取,除非没有提供方服务它或那次轮询失败。重新读取已显示的变更时,刷新图标会旋转。托管平台或其提供方因超过大小上限拒绝给出 diff 的变更请求(例如 GitHub 上超过 300 个文件的 diff)显示“变更太大,无法在这里显示”并提供“在浏览器中查看”;其他读取失败提供“重试”,变更请求还提供“在浏览器中查看”。变更请求的 diff 在缓存期间对每个 head 提交与目标分支只下载一次,因此在同一 head 下重新打开、刷新与导航都不会再从托管平台读取。 +- **本地变更卡片**——`branch` 范围有文件变更、且没有以当前检出分支为源分支的打开中变更请求时,变更请求列表上方的卡片显示比较的基准(例如 `HEAD → origin/main`)以及新增、删除行数与文件数,点击打开变更视图;展开的文件列表像变更视图一样标出各个文件,点击某个文件时,变更视图定位到该文件。识别出代码托管仓库后,卡片提供“创建变更请求”:它以界面语言向 Session 发送一条用户消息,请 Agent 为这些变更创建变更请求,并以 toast 报告是否已发送。 +- **变更视图**——展开的变更请求卡片上的“变更”一行与变更卡片会打开一个右侧边栏标签页,列出所有变更的文件。本地标签页名为“本地变更”,默认显示“分支”:工作树与未跟踪文件相对于分叉点的变更;分叉点是 Session 创建的 worktree 的基准提交,否则是 HEAD 与代码托管远端默认分支的合并基准。“未提交”与 HEAD 比较,“未暂存”与暂存区比较,“已暂存”比较暂存区与 HEAD。变更请求的视图显示托管平台在其 head 处的 diff;当前检出分支就是该变更请求的源分支时,还会说明本地分支比该 head 多出几个提交。工具栏可以跳到某个文件、汇总变更行数、刷新,并在左右对比与单栏对比、自动换行与截断长行之间切换。访问过的变更标签页会在切换标签页与 Session、隐藏侧边栏及调整停靠位置时保持挂载,在 diff 未改变时保留文件折叠、大文件展开、上下文展开、布局与滚动位置。隐藏的变更视图释放面板订阅并推迟 diff 读取;返回时仅在最新 head 或本地摘要不同时重新读取。刷新替换 diff 后会丢弃展开的上下文,内容变化也可能移动阅读位置。视图隐藏时关闭临时菜单。关闭标签页或重新加载应用后不保留这些进度。文件名在前,目录在后,用更小的浅色字显示,空间不够时目录先缩短;悬停时显示完整路径,重命名的文件显示新旧两条路径。每个文件带有新文件、已删除、重命名的标记(重命名标记写出原文件名,只换了目录时写出原路径),“分支”范围下的未跟踪文件还标“未提交”;文件菜单可以复制路径,本地变更可在侧边栏打开整个文件,变更请求可在浏览器中查看该文件在 head 处的页面。hunk 之间未变更的行折叠成一行,点击后从文件的新版本展开:工作树、“已暂存”时为暂存区,或经托管平台读取的 head 提交;读取 diff 之后文件又有修改时不会展开,直到视图重新读取。二进制文件与超过 `maxDiffBytes` 的未跟踪文件只显示一条说明;hunk 超过 1,000 行的文件先显示为一条写出变更行数的折叠条,点击后才绘制;前面的文件累计变更超过 5,000 行后,其后的文件默认收起。刷新与页面刷新快捷键、每次导航、变更请求的 head 变化,以及“分支”范围的摘要变化时,视图都会重新读取;变更请求的视图会先等到给出 head 的状态再读取,除非没有提供方服务它或那次轮询失败。重新读取已显示的变更时,刷新图标会旋转。托管平台或其提供方因超过大小上限拒绝给出 diff 的变更请求(例如 GitHub 上超过 300 个文件的 diff)显示“变更太大,无法在这里显示”并提供“在浏览器中查看”;其他读取失败提供“重试”,变更请求还提供“在浏览器中查看”。变更请求的 diff 在缓存期间对每个 head 提交与目标分支只下载一次,因此在同一 head 下重新打开、刷新与导航都不会再从托管平台读取。 `gitCommand`、`gitTimeoutMs`、`graceMs`、`maxOutputBytes`、`readinessTimeoutMs`、`maxBranches`、`worktreePrefix`、`maxDiffBytes`、`maxUntrackedFiles` 与 `maxCachedDiffBytes` 列在[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-client-ui-forge)中。 diff --git a/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css b/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css index 95b8d3410a..be6deeff26 100644 --- a/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css +++ b/packages/experimental/client-ui-forge/src/client/ChangesTab.module.css @@ -306,13 +306,6 @@ display: flex; flex: 0 1 auto; min-width: 0; - padding: 0; - border: 0; - background: transparent; - color: inherit; - cursor: pointer; - font: inherit; - text-align: left; } .fileSpacer { @@ -615,7 +608,6 @@ .localView:focus-visible, .localToggle:focus-visible, .fileToggle:focus-visible, -.filePath:focus-visible, .localFile:focus-visible, .select:focus-visible, .gap:focus-visible { diff --git a/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx b/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx index 377ea7e932..65b0b403b6 100644 --- a/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx +++ b/packages/experimental/client-ui-forge/src/client/ChangesTab.tsx @@ -485,7 +485,6 @@ export function ChangesTab({ badge={badgeOf(file, data, t)} tooltip={pathTooltipOf(file, t)} actions={fileActions(data, file)} - onCopyPath={() => { copyPath(path) }} sectionRef={(element) => { if (element === null) sections.current.delete(path) else sections.current.set(path, element) diff --git a/packages/experimental/client-ui-forge/src/client/DiffFile.tsx b/packages/experimental/client-ui-forge/src/client/DiffFile.tsx index 6a67f99eab..c8247ac672 100644 --- a/packages/experimental/client-ui-forge/src/client/DiffFile.tsx +++ b/packages/experimental/client-ui-forge/src/client/DiffFile.tsx @@ -62,8 +62,6 @@ export interface DiffFileProps { readonly tooltip: string /** Rows of the file's menu. */ readonly actions: readonly DiffFileAction[] - /** Copy the repository-relative path from the file header. */ - readonly onCopyPath: () => void /** Receives the section element, which scrolling to the file targets. */ readonly sectionRef: (element: HTMLElement | null) => void /** Forge panel copy. */ @@ -248,7 +246,7 @@ function FileMenu({ name, actions, t }: { * @returns the file section. */ export function DiffFile(props: DiffFileProps): ReactNode { - const { file, expanded, onToggle, badge, tooltip, actions, onCopyPath, sectionRef, t } = props + const { file, expanded, onToggle, badge, tooltip, actions, sectionRef, t } = props const name = file.path.slice(file.path.lastIndexOf('/') + 1) return (
@@ -263,9 +261,9 @@ export function DiffFile(props: DiffFileProps): ReactNode { - + {badge !== '' && {badge}} {!file.binary && } diff --git a/packages/experimental/client-ui-forge/src/client/FileLabel.module.css b/packages/experimental/client-ui-forge/src/client/FileLabel.module.css index dcee927f92..add13b56ed 100644 --- a/packages/experimental/client-ui-forge/src/client/FileLabel.module.css +++ b/packages/experimental/client-ui-forge/src/client/FileLabel.module.css @@ -1,5 +1,7 @@ /* A changed file's name, then its directory in smaller subdued type. */ .label { + /* Keep the hidden accessible path inside the file's own scroll container. */ + position: relative; display: flex; min-width: 0; } diff --git a/packages/experimental/client-ui-forge/src/client/ForgeTab.module.css b/packages/experimental/client-ui-forge/src/client/ForgeTab.module.css index ddebe94967..19d60ee8fe 100644 --- a/packages/experimental/client-ui-forge/src/client/ForgeTab.module.css +++ b/packages/experimental/client-ui-forge/src/client/ForgeTab.module.css @@ -388,16 +388,18 @@ /* Expanded facts, one row each: an icon under the glyph, the label, the value, and a chevron on a row that opens something. */ .rows { - display: flex; - flex-direction: column; - gap: 2px; + display: grid; + /* Edge tracks include each row's padding; labels retain their Chinese width and grow for longer translations. */ + grid-template-columns: 24px minmax(32px, max-content) minmax(0, 1fr) 22px; + gap: 2px 10px; padding: 4px 6px 6px; border-top: 0.5px solid var(--dsw-alias-border-l2); } .row { display: grid; - grid-template-columns: 16px 32px minmax(0, 1fr) 12px; + grid-column: 1 / -1; + grid-template-columns: subgrid; align-items: center; column-gap: 10px; box-sizing: border-box; @@ -449,6 +451,7 @@ align-items: center; gap: 6px; min-width: 0; + overflow-wrap: anywhere; } .rowValue[data-tone='error'] { @@ -469,7 +472,9 @@ /* The checks menu anchors to the whole row. */ .checksAnchor { - display: block; + display: grid; + grid-column: 1 / -1; + grid-template-columns: subgrid; } .checksList { diff --git a/packages/experimental/client-ui-forge/src/client/WorktreeToggle.module.css b/packages/experimental/client-ui-forge/src/client/WorktreeToggle.module.css index 6844385290..036363016d 100644 --- a/packages/experimental/client-ui-forge/src/client/WorktreeToggle.module.css +++ b/packages/experimental/client-ui-forge/src/client/WorktreeToggle.module.css @@ -9,7 +9,8 @@ } .toggle, -.base { +.base, +.current { display: inline-flex; align-items: center; gap: 6px; @@ -45,7 +46,17 @@ padding-right: 4px; } +.current { + max-width: 200px; + cursor: default; +} + +.current svg { + flex: none; +} + .base:focus-visible, +.current:focus-visible, .toggle:has(.checkbox:focus-visible) { outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: 2px; @@ -105,3 +116,16 @@ color: var(--dsw-alias-label-tertiary); font-size: 12px; } + +@media (max-width: 600px) { + .root { + flex-direction: column; + align-items: flex-end; + gap: 0; + } + + .base, + .current { + max-width: 120px; + } +} diff --git a/packages/experimental/client-ui-forge/src/client/WorktreeToggle.tsx b/packages/experimental/client-ui-forge/src/client/WorktreeToggle.tsx index 1abde16c94..bf2a3335a9 100644 --- a/packages/experimental/client-ui-forge/src/client/WorktreeToggle.tsx +++ b/packages/experimental/client-ui-forge/src/client/WorktreeToggle.tsx @@ -2,7 +2,7 @@ import { useState, type ReactNode } from 'react' import clsx from 'clsx' -import { IconChevronDownOutlineRegular, Menu, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconBranchOutlineRegular, IconChevronDownOutlineRegular, Menu, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { MenuEntry } from '@deepseek-ai/dsh-client-ui-primitives' import type { HostObservable, InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -95,22 +95,26 @@ function BaseMenu({ base, held, branches, request, t }: { } /** What the option shows for a panel state, or undefined while it stays hidden. */ -function optionOf(state: ForgePanelState | null): { readonly usable: boolean; readonly current: string | null } | undefined { +function optionOf(state: ForgePanelState | null): { + readonly usable: boolean + readonly current: string | null + readonly commit: string | null +} | undefined { if (state === null || !state.worktree.editable) return undefined const checkout = state.checkout - if (checkout.state === 'none') return { usable: false, current: null } if (checkout.state !== 'ready') return undefined - return { usable: state.worktree.blocker === null, current: checkout.checkout.branch } + return { usable: state.worktree.blocker === null, current: checkout.checkout.branch, commit: checkout.checkout.commit } } /** * Render the worktree option while the Session can still start in a worktree: - * the base branch menu once requested, then the checkbox. When the checkout - * cannot host a worktree, the tooltip gives the reason and the checkbox only + * the current branch, or the base branch menu once requested, then the checkbox. + * When the checkout cannot host a worktree, the tooltip gives the reason and the checkbox only * withdraws an open request. Both stay fixed while the worktree is being * created. * @param props - panel snapshot hook, request actions, and copy. - * @returns the option, or nothing once a turn started, the request settled, or no worktree service is mounted. + * @returns the option, or nothing outside a known Git checkout, once a turn started, + * the request settled, or no worktree service is mounted. */ export function WorktreeToggle({ usePanel, request, cancel, branches, t }: WorktreeToggleProps): ReactNode { const state = usePanel(snapshot => snapshot.state) @@ -121,14 +125,22 @@ export function WorktreeToggle({ usePanel, request, cancel, branches, t }: Workt const requested = pending ?? state.worktree.request !== null const disabled = held || pending !== null || (!option.usable && !requested) const blocker = state.worktree.blocker ?? 'not-git' + const current = option.current ?? [t('checkout.detached'), option.commit].filter(value => value !== null).join(' · ') const change = (checked: boolean): void => { setPending(checked) void (checked ? request(option.current) : cancel()).then(() => { setPending(null) }) } return ( - {requested && option.usable && ( + {requested && option.usable ? ( + ) : ( + + + + {current} + + )}