From a4ee2905510473c59f22c4ba9c6c5dd1041b8c79 Mon Sep 17 00:00:00 2001 From: DavidShen Date: Fri, 7 Aug 2026 18:36:46 +0800 Subject: [PATCH 1/7] =?UTF-8?q?fix(desktop):=20=E8=BD=AE=E6=AC=A1=E5=AE=A1?= =?UTF-8?q?=E6=9F=A5=E5=90=88=E6=B5=81=20git=20=E5=AE=A1=E6=9F=A5=E6=9D=A5?= =?UTF-8?q?=E6=BA=90=E4=B8=8B=E6=8B=89,=E5=8D=8F=E5=90=8C=20worker=20?= =?UTF-8?q?=E5=AE=A1=E6=9F=A5=E5=85=A5=E5=8F=A3=E4=B8=8D=E5=86=8D=E6=97=A0?= =?UTF-8?q?=E5=93=8D=E5=BA=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两项审查面板修正: 1. 来源合流(对齐 Codex 的单 source + turnSelection 模型):轮次审查视图 header 换成与 git 审查同一个来源下拉,选中态为「本条消息的变更」;切到任一 git 来源即退出轮次审查(清 turnTarget),不再需要关掉 tab 重开。source / selectedCommitOid 上提到 ReviewTabBody,进出轮次视图不再丢来源选择。 废弃「当前工作区」文字按钮及其 4 语言文案。 2. 协同审查入口(orca-workers):worker 流里点变更卡「审查」原先把 review tab 开进 worker 自己的桶——协同视图下该桶不可见,点击无任何反应。现经 SidebarHostSessionProvider 把 tab 开到 lead 的可见桶,turnTarget 携带 targetSessionId 按 worker 会话取数;跨会话轮次审查不提供 git 来源切换 (git 视图跟随桶会话 workdir,对 worker 的 worktree 语义错误)。main 侧 RSB 命令 sanitizer 校验并透传 hostSessionId(detached 窗口路径)。 Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: DavidShen --- .../__tests__/ipc.test.ts | 74 ++++++++++ .../src/main/right-sidebar-window/ipc.ts | 9 ++ .../components/chat/TurnChangesCard.tsx | 5 + .../chat/__tests__/TurnChangesCard.test.tsx | 21 ++- .../lib/executeSidebarCommand.ts | 1 + .../right-sidebar/lib/openTurnReview.ts | 26 +++- .../right-sidebar/lib/sidebarHostSession.tsx | 35 +++++ .../plugins/orca-workers/index.tsx | 5 + .../plugins/review/ReviewTabBody.tsx | 136 ++++++++++++++---- .../__tests__/ReviewTabBody.helpers.test.ts | 59 ++++++++ .../right-sidebar/plugins/review/index.tsx | 9 ++ .../src/renderer/i18n/locales/en/common.json | 1 - .../src/renderer/i18n/locales/ja/common.json | 1 - .../src/renderer/i18n/locales/ko/common.json | 1 - .../renderer/i18n/locales/zh-CN/common.json | 1 - apps/desktop/src/shared/rightSidebarWindow.ts | 5 + 16 files changed, 351 insertions(+), 38 deletions(-) create mode 100644 apps/desktop/src/renderer/features/right-sidebar/lib/sidebarHostSession.tsx diff --git a/apps/desktop/src/main/right-sidebar-window/__tests__/ipc.test.ts b/apps/desktop/src/main/right-sidebar-window/__tests__/ipc.test.ts index 1858e462de7..60501519d3b 100644 --- a/apps/desktop/src/main/right-sidebar-window/__tests__/ipc.test.ts +++ b/apps/desktop/src/main/right-sidebar-window/__tests__/ipc.test.ts @@ -216,6 +216,80 @@ describe('right-sidebar-window IPC', () => { ).rejects.toThrow(/searchJump/); }); + it('validates and forwards the turn-review host bucket session', async () => { + // 协同面板里 worker 流的审查入口带宿主(lead)桶。sanitizer 重建命令对象, + // 漏透传 hostSessionId 会让 detached 窗口路径退回 worker 的不可见桶。 + const controller = makeController(); + const { handler, mainWebContents } = registerController(controller); + + await handler( + { sender: mainWebContents }, + { + command: { + type: 'open-turn-review', + sessionId: 'worker-1', + changeSetIds: ['change-1'], + requestNonce: 1, + hostSessionId: 'lead-1', + }, + allowOpen: true, + }, + ); + await handler( + { sender: mainWebContents }, + { + command: { + type: 'open-turn-review', + sessionId: 'worker-1', + changeSetIds: ['change-1'], + requestNonce: 2, + }, + allowOpen: true, + }, + ); + + expect(controller.routeCommand).toHaveBeenNthCalledWith(1, { + command: { + type: 'open-turn-review', + sessionId: 'worker-1', + changeSetIds: ['change-1'], + selectedDiffId: null, + selectedPath: null, + requestNonce: 1, + hostSessionId: 'lead-1', + }, + allowOpen: true, + }); + expect(controller.routeCommand).toHaveBeenNthCalledWith(2, { + command: { + type: 'open-turn-review', + sessionId: 'worker-1', + changeSetIds: ['change-1'], + selectedDiffId: null, + selectedPath: null, + requestNonce: 2, + hostSessionId: null, + }, + allowOpen: true, + }); + + await expect( + handler( + { sender: mainWebContents }, + { + command: { + type: 'open-turn-review', + sessionId: 'worker-1', + changeSetIds: ['change-1'], + requestNonce: 3, + hostSessionId: 42, + }, + allowOpen: true, + }, + ), + ).rejects.toThrow(/hostSessionId/); + }); + it('validates and forwards external-file browser commands', async () => { const controller = makeController(); const { handler, mainWebContents } = registerController(controller); diff --git a/apps/desktop/src/main/right-sidebar-window/ipc.ts b/apps/desktop/src/main/right-sidebar-window/ipc.ts index cb423dfc4dc..ba9db24f99d 100644 --- a/apps/desktop/src/main/right-sidebar-window/ipc.ts +++ b/apps/desktop/src/main/right-sidebar-window/ipc.ts @@ -127,6 +127,13 @@ function parseCommand(raw: unknown): RsbWindowCommand { if (typeof r.requestNonce !== 'number' || !Number.isSafeInteger(r.requestNonce)) { throwIpcError('INVALID_PARAMS', 'command.requestNonce must be an integer'); } + if ( + r.hostSessionId !== undefined + && r.hostSessionId !== null + && (typeof r.hostSessionId !== 'string' || r.hostSessionId.length === 0 || r.hostSessionId.length > 256) + ) { + throwIpcError('INVALID_PARAMS', 'command.hostSessionId must be string | null'); + } return { type: 'open-turn-review', sessionId: r.sessionId, @@ -134,6 +141,8 @@ function parseCommand(raw: unknown): RsbWindowCommand { selectedDiffId: typeof r.selectedDiffId === 'string' ? r.selectedDiffId : null, selectedPath: typeof r.selectedPath === 'string' ? r.selectedPath : null, requestNonce: r.requestNonce, + // 协同面板里 worker 流的入口带宿主(lead)桶;缺省 null = tab 落 sessionId 自身桶。 + hostSessionId: typeof r.hostSessionId === 'string' ? r.hostSessionId : null, }; } if (r.type === 'open-file-browser') { diff --git a/apps/desktop/src/renderer/components/chat/TurnChangesCard.tsx b/apps/desktop/src/renderer/components/chat/TurnChangesCard.tsx index 2beb63d1e12..1a9e2f0114c 100644 --- a/apps/desktop/src/renderer/components/chat/TurnChangesCard.tsx +++ b/apps/desktop/src/renderer/components/chat/TurnChangesCard.tsx @@ -12,6 +12,7 @@ import { import { useTranslation } from 'react-i18next'; import { openTurnReview } from '@/features/right-sidebar/lib/openTurnReview'; +import { useSidebarHostSessionId } from '@/features/right-sidebar/lib/sidebarHostSession'; import { shouldOpenTextLightboxForOrigin } from '@/lib/filePreview'; import { resolveToolFilePath } from '@/lib/localPathResolver'; import { toast } from '@/lib/toast'; @@ -123,9 +124,13 @@ export function TurnChangesCard({ setApplying(false); }, [changeSet.id]); + // 内嵌在 RSB(协同 worker 面板)里时把 review tab 开到宿主(lead)的可见桶; + // 主实例 hostSessionId 为 null,openTurnReview 落到本会话桶,行为不变。 + const hostSessionId = useSidebarHostSessionId(); const openReview = (selectedDiffId?: string): void => { void openTurnReview(sessionId, [changeSet.id], { selectedDiffId: selectedDiffId ?? null, + hostSessionId, }); }; diff --git a/apps/desktop/src/renderer/components/chat/__tests__/TurnChangesCard.test.tsx b/apps/desktop/src/renderer/components/chat/__tests__/TurnChangesCard.test.tsx index 827cd1a1b13..4fa59fd8e2a 100644 --- a/apps/desktop/src/renderer/components/chat/__tests__/TurnChangesCard.test.tsx +++ b/apps/desktop/src/renderer/components/chat/__tests__/TurnChangesCard.test.tsx @@ -43,6 +43,7 @@ vi.mock('../useFileChipContextMenu', () => ({ useFileChipContextMenu: mocks.useFileChipContextMenu, })); +import { SidebarHostSessionProvider } from '@/features/right-sidebar/lib/sidebarHostSession'; import { TurnChangesCard } from '../TurnChangesCard'; import type { TurnChangeSetSummary } from '../../../../shared/turnChangeSet'; @@ -155,7 +156,25 @@ describe('TurnChangesCard file actions', () => { expect(mocks.openTurnReview).toHaveBeenCalledWith( 'session-1', ['change-1'], - { selectedDiffId: 'file-1' }, + { selectedDiffId: 'file-1', hostSessionId: null }, + ); + }); + + it('routes review to the sidebar host bucket when embedded in the collab panel', () => { + // 协同面板里 worker 流内嵌于 lead 的 RSB tab:review tab 必须开到 lead 的 + // 可见桶(hostSessionId),否则落进 worker 自己的桶,点了没有任何反应。 + render( + + + , + ); + + fireEvent.click(screen.getByRole('button', { name: /src\/test\.ts/ })); + + expect(mocks.openTurnReview).toHaveBeenCalledWith( + 'session-1', + ['change-1'], + { selectedDiffId: 'file-1', hostSessionId: 'lead-1' }, ); }); diff --git a/apps/desktop/src/renderer/features/right-sidebar/lib/executeSidebarCommand.ts b/apps/desktop/src/renderer/features/right-sidebar/lib/executeSidebarCommand.ts index 43a09f5a724..7a37d8c27d4 100644 --- a/apps/desktop/src/renderer/features/right-sidebar/lib/executeSidebarCommand.ts +++ b/apps/desktop/src/renderer/features/right-sidebar/lib/executeSidebarCommand.ts @@ -56,6 +56,7 @@ export async function executeSidebarCommand(command: RsbWindowCommand): Promise< selectedDiffId: command.selectedDiffId ?? null, selectedPath: command.selectedPath ?? null, requestNonce: command.requestNonce, + hostSessionId: command.hostSessionId ?? null, }); return; } diff --git a/apps/desktop/src/renderer/features/right-sidebar/lib/openTurnReview.ts b/apps/desktop/src/renderer/features/right-sidebar/lib/openTurnReview.ts index 8dcacc1f3a4..64669990b8b 100644 --- a/apps/desktop/src/renderer/features/right-sidebar/lib/openTurnReview.ts +++ b/apps/desktop/src/renderer/features/right-sidebar/lib/openTurnReview.ts @@ -11,9 +11,20 @@ let nextRequestNonce = 0; export async function openTurnReview( sessionId: string, changeSetIds: string[], - opts: { selectedDiffId?: string | null; selectedPath?: string | null; requestNonce?: number } = {}, + opts: { + selectedDiffId?: string | null; + selectedPath?: string | null; + requestNonce?: number; + /** + * 承载 review tab 的 RSB 桶(缺省 = sessionId 自身)。协同面板里 worker 流 + * 的入口传 lead sessionId —— worker 自己的桶在协同视图下不可见,tab 开进去 + * 用户看不到任何反应。 + */ + hostSessionId?: string | null; + } = {}, ): Promise { const requestNonce = opts.requestNonce ?? ++nextRequestNonce; + const hostSessionId = opts.hostSessionId ?? sessionId; const command = { type: 'open-turn-review' as const, sessionId, @@ -21,23 +32,26 @@ export async function openTurnReview( selectedDiffId: opts.selectedDiffId ?? null, selectedPath: opts.selectedPath ?? null, requestNonce, + hostSessionId, }; const routeResult = await routeSidebarCommand(command); if (routeResult !== 'attached') { - if (routeResult === 'routed') requestRightSidebarVisibility('open', { sessionId }); + if (routeResult === 'routed') requestRightSidebarVisibility('open', { sessionId: hostSessionId }); return; } - await ensureHydrated(sessionId); - const tab = await addOrFocusSingletonTab(sessionId, 'review', null); - await patchTabState(sessionId, tab.id, (current) => ({ + await ensureHydrated(hostSessionId); + const tab = await addOrFocusSingletonTab(hostSessionId, 'review', null); + await patchTabState(hostSessionId, tab.id, (current) => ({ ...(current && typeof current === 'object' ? current as Record : {}), turnTarget: { changeSetIds, selectedDiffId: opts.selectedDiffId ?? null, selectedPath: opts.selectedPath ?? null, requestNonce, + // 目标会话与宿主桶不同(跨会话审查 worker 的轮次)时,review 插件按它取数。 + targetSessionId: sessionId, }, })); - requestRightSidebarVisibility('open', { sessionId }); + requestRightSidebarVisibility('open', { sessionId: hostSessionId }); } diff --git a/apps/desktop/src/renderer/features/right-sidebar/lib/sidebarHostSession.tsx b/apps/desktop/src/renderer/features/right-sidebar/lib/sidebarHostSession.tsx new file mode 100644 index 00000000000..adfaa6a08a7 --- /dev/null +++ b/apps/desktop/src/renderer/features/right-sidebar/lib/sidebarHostSession.tsx @@ -0,0 +1,35 @@ +/** + * sidebarHostSession — 标记「当前聊天流内嵌在哪个会话的右栏(RSB)里」。 + * --------------------------------------------------------------------------- + * RSB 的 tab 桶按 session 划分,只有 Shell 当前注册的那个 session 的桶可见。 + * 协同(orca-workers)tab 挂在 lead 会话的桶里,里面内嵌 worker 会话的消息流; + * worker 流里的入口(如变更卡的「审查」)若把 tab 开进 worker 自己的桶,用户 + * 永远看不到(MainLayout 对 session 不匹配的可见性请求有意只持久化不动 UI)。 + * + * 该 context 由内嵌宿主(orca-workers tab body)提供 lead sessionId;消费方 + * (TurnChangesCard 等)据此把 tab 开到可见的宿主桶。默认 null = 消息流就是 + * 路由主实例,无需改桶。 + */ + +import { createContext, useContext, type ReactNode } from 'react'; + +const SidebarHostSessionContext = createContext(null); + +export function SidebarHostSessionProvider({ + sessionId, + children, +}: { + sessionId: string; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +/** 内嵌在 RSB tab 里时返回宿主(lead)sessionId;主实例返回 null。 */ +export function useSidebarHostSessionId(): string | null { + return useContext(SidebarHostSessionContext); +} diff --git a/apps/desktop/src/renderer/features/right-sidebar/plugins/orca-workers/index.tsx b/apps/desktop/src/renderer/features/right-sidebar/plugins/orca-workers/index.tsx index a393514a4ac..f578352c8c2 100644 --- a/apps/desktop/src/renderer/features/right-sidebar/plugins/orca-workers/index.tsx +++ b/apps/desktop/src/renderer/features/right-sidebar/plugins/orca-workers/index.tsx @@ -29,6 +29,7 @@ import { isOrcaLeadSession } from '@/lib/orcaSessionIdentity'; import { isSidebarWindow } from '@/lib/sidebarWindow'; import * as sessionService from '@/lib/sessionService'; import { createLogger } from '@/lib/logger'; +import { SidebarHostSessionProvider } from '../../lib/sidebarHostSession'; import { registerTabKind } from '../../registry'; import { hasTabCloseInterceptor } from '../../store'; import type { TabKindPlugin } from '../../types'; @@ -157,6 +158,9 @@ function OrcaWorkersTabBody({ ); return ( + // 内嵌 worker 流里的审查等入口据此把 RSB tab 开到 lead 的可见桶, + // 而不是 worker 自己的(协同视图下不可见的)桶。 + + ); } diff --git a/apps/desktop/src/renderer/features/right-sidebar/plugins/review/ReviewTabBody.tsx b/apps/desktop/src/renderer/features/right-sidebar/plugins/review/ReviewTabBody.tsx index 12f649cccc4..41f06ce8586 100644 --- a/apps/desktop/src/renderer/features/right-sidebar/plugins/review/ReviewTabBody.tsx +++ b/apps/desktop/src/renderer/features/right-sidebar/plugins/review/ReviewTabBody.tsx @@ -106,6 +106,23 @@ interface ReviewTabBodyProps { ctx: TabKindHostContext; } +/** + * source / selectedCommitOid 由外层 ReviewTabBody 持有并下发:轮次视图与 + * Git 视图共用同一个来源状态机,从轮次视图的来源下拉切走时目标 source + * 要在 Git 视图挂载前就位(对齐 Codex 的单 source + turnSelection 模型)。 + */ +interface GitReviewBodyProps extends ReviewTabBodyProps { + source: ReviewSource; + setSource: (source: ReviewSource) => void; + selectedCommitOid: string | null; + setSelectedCommitOid: (oid: string | null) => void; +} + +interface TurnReviewBodyProps extends ReviewTabBodyProps { + setSource: (source: ReviewSource) => void; + setSelectedCommitOid: (oid: string | null) => void; +} + type ReviewToggleAction = Extract; type RevealActionScope = 'file' | 'section'; type ReviewToolbarLayout = 'wide' | 'compact' | 'minimal'; @@ -368,13 +385,51 @@ export function useClearReviewOperationNoticeOnSourceChange( } export function ReviewTabBody(props: ReviewTabBodyProps) { - if (props.state.turnTarget) return ; - return ; + const [source, setSource] = useState('unstaged'); + const [selectedCommitOid, setSelectedCommitOid] = useState(null); + if (props.state.turnTarget) { + return ( + + ); + } + return ( + + ); } -function TurnChangeSetReviewBody({ state, ctx }: ReviewTabBodyProps) { +function TurnChangeSetReviewBody({ state, ctx, setSource, setSelectedCommitOid }: TurnReviewBodyProps) { const { t } = useTranslation(); const target = state.turnTarget; + // 变更集所属会话。协同面板里审查 worker 的轮次时,tab 桶在 lead 会话 + // (worker 自己的桶在协同视图下不可见),数据按 targetSessionId 取。 + const reviewSessionId = target?.targetSessionId ?? ctx.sessionId; + const crossSession = Boolean(target?.targetSessionId && target.targetSessionId !== ctx.sessionId); + // 供来源下拉的「提交」子菜单用;与 Git 视图同一 IPC,子菜单展开时刷新。 + // 跨会话时不挂来源下拉(git 视图跟随桶会话 workdir,对 worker 语义错误), + // 传 null 跳过取数。 + const commitsState = useReviewCommits(crossSession ? null : ctx.sessionId || null, state.branchBaseRef ?? null); + const switchToGitSource = useCallback((next: ReviewSource) => { + // 对齐 Codex EP 语义:切到其它来源即退出轮次审查(清 turnTarget), + // 轮次选择不保留;要再看本条消息需从聊天流卡片重新进入。 + setSelectedCommitOid(null); + setSource(next); + ctx.patchState({ turnTarget: null }); + }, [ctx, setSelectedCommitOid, setSource]); + const switchToCommitSource = useCallback((oid: string) => { + setSelectedCommitOid(oid); + setSource('commit'); + ctx.patchState({ turnTarget: null }); + }, [ctx, setSelectedCommitOid, setSource]); const [changeSets, setChangeSets] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -403,7 +458,7 @@ function TurnChangeSetReviewBody({ state, ctx }: ReviewTabBodyProps) { setLoading(false); return; } - void window.electronAPI.maker.getTurnChangeSets(ctx.sessionId, targetIdsKey.split('\0')) + void window.electronAPI.maker.getTurnChangeSets(reviewSessionId, targetIdsKey.split('\0')) .then((sets) => { if (cancelled) return; setChangeSets(sets); @@ -418,7 +473,7 @@ function TurnChangeSetReviewBody({ state, ctx }: ReviewTabBodyProps) { return () => { cancelled = true; }; - }, [ctx.deviceLinkDeviceId, ctx.remoteHostId, ctx.sessionId, reloadToken, t, targetIdsKey]); + }, [ctx.deviceLinkDeviceId, ctx.remoteHostId, reloadToken, reviewSessionId, t, targetIdsKey]); const selectedDiff = target?.selectedDiffId ? visibleDiffs.find((diff) => diff.id === target.selectedDiffId) @@ -441,21 +496,34 @@ function TurnChangeSetReviewBody({ state, ctx }: ReviewTabBodyProps) { return (
- - - {t('rightSidebar.review.turn.title')} - + {crossSession ? ( + // 跨会话(协同 worker 的轮次):不提供 git 来源切换——git 视图跟随 + // 桶会话的 workdir,对 worker 的 worktree 语义错误。静态标题,关 tab 退出。 + <> + + + {t('rightSidebar.review.turn.title')} + + + ) : ( + + )} + +{totalAdd}{' '} -{totalDel} -
{!loading && !error && isPartial && (
@@ -503,7 +571,7 @@ function TurnChangeSetReviewBody({ state, ctx }: ReviewTabBodyProps) { ); } -function GitReviewTabBody({ state, ctx }: ReviewTabBodyProps) { +function GitReviewTabBody({ state, ctx, source, setSource, selectedCommitOid, setSelectedCommitOid }: GitReviewBodyProps) { const { t } = useTranslation(); const { confirm } = useConfirmDialog(); const sessionId = ctx.sessionId || null; @@ -511,8 +579,6 @@ function GitReviewTabBody({ state, ctx }: ReviewTabBodyProps) { const branchBaseRef = state.branchBaseRef ?? null; const { data, loading, error, refresh, setData: setReviewData } = useReviewGitState(sessionId, hideWhitespace); const commitsState = useReviewCommits(sessionId, branchBaseRef); - const [source, setSource] = useState('unstaged'); - const [selectedCommitOid, setSelectedCommitOid] = useState(null); const [pendingKey, setPendingKey] = useState(null); const [operationError, setOperationError] = useState(null); const [operationSummary, setOperationSummary] = useState(null); @@ -909,7 +975,7 @@ function GitReviewTabBody({ state, ctx }: ReviewTabBodyProps) { if (!committed) return messageText; return decoratePushError(messageText, err); }).then((completed) => ({ committed, completed })); - }, [branchDiffState, commitsState, decoratePushError, runPushFlow, runWrite, sessionId, source, updateReviewDataFromWrite]); + }, [branchDiffState, commitsState, decoratePushError, runPushFlow, runWrite, sessionId, setSelectedCommitOid, source, updateReviewDataFromWrite]); const pushCurrentBranch = useCallback(() => { if (!sessionId) return; @@ -927,7 +993,7 @@ function GitReviewTabBody({ state, ctx }: ReviewTabBodyProps) { setSelectedCommitOid(null); setSource('branch'); } - }, [commits, commitsState.data, commitsState.loading, selectedCommitOid, source]); + }, [commits, commitsState.data, commitsState.loading, selectedCommitOid, setSelectedCommitOid, setSource, source]); const togglePath = useCallback( (id: string) => { @@ -962,7 +1028,7 @@ function GitReviewTabBody({ state, ctx }: ReviewTabBodyProps) { const selectCommitSource = useCallback((oid: string) => { setSelectedCommitOid(oid); setSource('commit'); - }, []); + }, [setSelectedCommitOid, setSource]); const requestFileJump = useCallback((diff: FileDiff) => { setJumpRequest((prev) => ({ id: diff.id, nonce: (prev?.nonce ?? 0) + 1 })); }, []); @@ -2145,8 +2211,9 @@ export function SourceDropdown({ onSelectCommit, onRefreshCommits, }: { - source: ReviewSource; - counts: { unstaged: number; staged: number; branch: number; lastTurn: number }; + /** `'turn'` 表示轮次审查(turnTarget)选中态:非 git 来源,仅作为当前项展示。 */ + source: ReviewSource | 'turn'; + counts: { unstaged?: number; staged?: number; branch?: number; lastTurn?: number }; commits?: ReviewCommit[]; commitsLoading?: boolean; commitsError?: string | null; @@ -2158,6 +2225,11 @@ export function SourceDropdown({ onRefreshCommits?: () => void; }) { const { t } = useTranslation(); + // 轮次项只在轮次审查态存在(进入它的唯一入口是聊天流的变更卡片); + // 切走即清 turnTarget、不提供"切回轮次"的常驻项,对齐 Codex 的 EP 语义。 + const turnOption: SourceDropdownOption | null = source === 'turn' + ? { source: 'turn', label: t('rightSidebar.review.turn.title') } + : null; const options: SourceDropdownOption[] = [ { source: 'unstaged', label: t('rightSidebar.review.source.unstaged'), count: counts.unstaged }, { source: 'staged', label: t('rightSidebar.review.source.staged'), count: counts.staged }, @@ -2166,7 +2238,7 @@ export function SourceDropdown({ { source: 'last-turn', label: t('rightSidebar.review.source.lastTurn') }, ]; const directOptions = options.filter((option) => option.source !== 'commit'); - const selected = options.find((option) => option.source === source) ?? options[0]; + const selected = turnOption ?? options.find((option) => option.source === source) ?? options[0]; const commitList = commits ?? []; const commitMenuLoaded = commitsLoaded ?? false; return ( @@ -2190,6 +2262,13 @@ export function SourceDropdown({ sideOffset={4} className="w-[var(--radix-dropdown-menu-trigger-width)] min-w-[12rem] rounded-[8px] border border-[var(--cmd-palette-border)] bg-[var(--cmd-palette-bg)] p-1 shadow-[var(--shadow-menu)]" > + {turnOption && ( + + )} {directOptions.slice(0, 2).map((option) => ( onChange(option.source)} + // 轮次伪选项已是选中态,点它只关菜单,不产生来源切换。 + onSelect={() => { + if (option.source !== 'turn') onChange(option.source); + }} className="flex h-8 items-center gap-2 rounded-[6px] px-2 text-[12px] text-[var(--text-primary)] focus:bg-[var(--cmd-palette-item-hover)]" > {option.label} diff --git a/apps/desktop/src/renderer/features/right-sidebar/plugins/review/__tests__/ReviewTabBody.helpers.test.ts b/apps/desktop/src/renderer/features/right-sidebar/plugins/review/__tests__/ReviewTabBody.helpers.test.ts index 0a7a8181001..ea29f3b91ea 100644 --- a/apps/desktop/src/renderer/features/right-sidebar/plugins/review/__tests__/ReviewTabBody.helpers.test.ts +++ b/apps/desktop/src/renderer/features/right-sidebar/plugins/review/__tests__/ReviewTabBody.helpers.test.ts @@ -231,6 +231,65 @@ describe('ReviewTabBody compact source dropdown', () => { }); }); +describe('ReviewTabBody turn source dropdown', () => { + // 轮次审查(turnTarget)与 Git 审查共用同一个来源下拉:轮次态下选中项是 + // 轮次伪选项,git 来源仍全部可选——这是"从轮次视图切回 git 审查不需要 + // 关掉重开 tab"的回归钉。 + it('shows the turn pseudo-source as the selected trigger label', () => { + render(createElement(SourceDropdown, { + source: 'turn', + counts: {}, + onChange: vi.fn(), + })); + + const trigger = screen.getByRole('button', { name: 'rightSidebar.review.sourceDropdownAria' }); + expect(trigger.textContent).toBe('rightSidebar.review.turn.title'); + }); + + it('lists git sources in turn mode and switches directly without close-reopen', async () => { + const onChange = vi.fn(); + render(createElement(SourceDropdown, { + source: 'turn', + counts: {}, + onChange, + })); + + const trigger = screen.getByRole('button', { name: 'rightSidebar.review.sourceDropdownAria' }); + fireEvent.keyDown(trigger, { key: 'Enter' }); + + expect(await screen.findByRole('menuitem', { name: 'rightSidebar.review.turn.title' })).toBeTruthy(); + fireEvent.click(screen.getByRole('menuitem', { name: 'rightSidebar.review.source.unstaged' })); + expect(onChange).toHaveBeenCalledWith('unstaged'); + }); + + it('keeps the turn pseudo-item selection-only (no source change on click)', async () => { + const onChange = vi.fn(); + render(createElement(SourceDropdown, { + source: 'turn', + counts: {}, + onChange, + })); + + const trigger = screen.getByRole('button', { name: 'rightSidebar.review.sourceDropdownAria' }); + fireEvent.keyDown(trigger, { key: 'Enter' }); + fireEvent.click(await screen.findByRole('menuitem', { name: 'rightSidebar.review.turn.title' })); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('does not offer the turn pseudo-item while a git source is active', async () => { + render(createElement(SourceDropdown, { + source: 'unstaged', + counts: { unstaged: 0, staged: 0, branch: 0, lastTurn: 0 }, + onChange: vi.fn(), + })); + + const trigger = screen.getByRole('button', { name: 'rightSidebar.review.sourceDropdownAria' }); + fireEvent.keyDown(trigger, { key: 'Enter' }); + await screen.findByRole('menuitem', { name: 'rightSidebar.review.source.unstaged' }); + expect(screen.queryByRole('menuitem', { name: 'rightSidebar.review.turn.title' })).toBeNull(); + }); +}); + describe('ReviewTabBody branch base dropdown', () => { it('marks stale local branch candidates with a tertiary text suffix', () => { render(createElement(BranchBaseDropdown, { diff --git a/apps/desktop/src/renderer/features/right-sidebar/plugins/review/index.tsx b/apps/desktop/src/renderer/features/right-sidebar/plugins/review/index.tsx index 6486a9a2b7e..a09ad0c623f 100644 --- a/apps/desktop/src/renderer/features/right-sidebar/plugins/review/index.tsx +++ b/apps/desktop/src/renderer/features/right-sidebar/plugins/review/index.tsx @@ -28,6 +28,12 @@ export interface ReviewState { selectedDiffId: string | null; selectedPath: string | null; requestNonce: number; + /** + * 变更集所属会话。null(旧数据)= 与 tab 桶同会话;与桶会话不同(协同面板 + * 里审查 worker 的轮次)时按它取数,且不提供 git 来源切换(git 视图跟随桶 + * 会话的 workdir,对 worker 语义错误)。 + */ + targetSessionId: string | null; } | null; /** 用户收起过哪些 diff id。空数组表示所有当前 diff 默认展开。 */ collapsedPaths: string[]; @@ -125,6 +131,9 @@ const plugin: TabKindPlugin = { requestNonce: typeof (rawTurnTarget as { requestNonce?: unknown }).requestNonce === 'number' ? (rawTurnTarget as { requestNonce: number }).requestNonce : 0, + targetSessionId: typeof (rawTurnTarget as { targetSessionId?: unknown }).targetSessionId === 'string' + ? (rawTurnTarget as { targetSessionId: string }).targetSessionId + : null, } : null; return { turnTarget, collapsedPaths, diffViewMode, fileTreeVisible, wordWrap, wordDiff, hideWhitespace, richMarkdownPreview, branchBaseRef }; diff --git a/apps/desktop/src/renderer/i18n/locales/en/common.json b/apps/desktop/src/renderer/i18n/locales/en/common.json index cb6297e4ca2..fc1d7aa2d6e 100644 --- a/apps/desktop/src/renderer/i18n/locales/en/common.json +++ b/apps/desktop/src/renderer/i18n/locales/en/common.json @@ -4479,7 +4479,6 @@ "review": { "turn": { "title": "Changes from this message", - "currentWorkspace": "Current workspace", "localOnly": "Exact message changes are not available for remote sessions yet.", "partialNotice": "This message includes command or tool writes that could not be tracked exactly. Only the captured subset is shown; it is not a complete patch.", "emptyTitle": "No recorded changes", diff --git a/apps/desktop/src/renderer/i18n/locales/ja/common.json b/apps/desktop/src/renderer/i18n/locales/ja/common.json index 00f335790cb..0e9b1d5e3c3 100644 --- a/apps/desktop/src/renderer/i18n/locales/ja/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ja/common.json @@ -4477,7 +4477,6 @@ "review": { "turn": { "title": "このメッセージの変更", - "currentWorkspace": "現在のワークスペース", "localOnly": "メッセージ単位の正確な変更は、リモートタスクではまだ利用できません。", "partialNotice": "このメッセージには正確に追跡できないコマンドまたはツールの書き込みが含まれます。以下は取得できた一部のみで、完全なパッチではありません。", "emptyTitle": "記録された変更はありません", diff --git a/apps/desktop/src/renderer/i18n/locales/ko/common.json b/apps/desktop/src/renderer/i18n/locales/ko/common.json index 85368fe6261..6ae301689b8 100644 --- a/apps/desktop/src/renderer/i18n/locales/ko/common.json +++ b/apps/desktop/src/renderer/i18n/locales/ko/common.json @@ -4477,7 +4477,6 @@ "review": { "turn": { "title": "이 메시지의 변경 사항", - "currentWorkspace": "현재 작업 공간", "localOnly": "메시지별 정확한 변경 사항은 아직 원격 작업에서 사용할 수 없습니다.", "partialNotice": "이 메시지에는 정확히 추적할 수 없는 명령 또는 도구 쓰기가 포함되어 있습니다. 아래에는 캡처된 일부만 표시되며 완전한 패치가 아닙니다.", "emptyTitle": "기록된 변경 사항 없음", diff --git a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json index a3a14d4fb96..61f08495fb7 100644 --- a/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json +++ b/apps/desktop/src/renderer/i18n/locales/zh-CN/common.json @@ -4477,7 +4477,6 @@ "review": { "turn": { "title": "本条消息的变更", - "currentWorkspace": "当前工作区", "localOnly": "本条消息的精确变更暂不支持远程任务。", "partialNotice": "本条消息包含无法精确追踪的命令或工具写入;以下仅展示已精确捕获的部分,不能视为完整补丁。", "emptyTitle": "没有已记录的变更", diff --git a/apps/desktop/src/shared/rightSidebarWindow.ts b/apps/desktop/src/shared/rightSidebarWindow.ts index ae341e93c65..80108789b55 100644 --- a/apps/desktop/src/shared/rightSidebarWindow.ts +++ b/apps/desktop/src/shared/rightSidebarWindow.ts @@ -56,6 +56,11 @@ export type RsbWindowCommand = selectedDiffId?: string | null; selectedPath?: string | null; requestNonce: number; + /** + * 承载 review tab 的 RSB 桶(缺省 = sessionId 自身)。协同面板里 worker + * 流的入口传 lead sessionId:worker 自己的桶在协同视图下不可见。 + */ + hostSessionId?: string | null; } | { type: 'open-file-browser'; From f2d94900083f471fab96b8a0fa29a1c9dc175ce4 Mon Sep 17 00:00:00 2001 From: DavidShen Date: Fri, 7 Aug 2026 18:37:01 +0800 Subject: [PATCH 2/7] =?UTF-8?q?fix(desktop):=20=E6=9C=AC=E6=9D=A1=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E7=94=9F=E6=88=90=E7=9A=84=E6=96=87=E4=BB=B6=E5=8D=A1?= =?UTF-8?q?=E5=AF=B9=E8=BD=AC=E4=B9=89=E6=AE=8B=E7=95=99=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E5=8E=BB=E9=87=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实测场景(pr-watch session):powershell 包一层 node -e 的命令落库文本里, 路径带转义残留(C:\Users\...);同轮另一条命令用正斜杠形态。二者在 fs 层是同一文件(Windows 归并重复分隔符),但去重 key 未折叠连续反斜杠, 产生两个同名 chip。 - dedupeKeyForPath:Windows 形态折叠连续分隔符(UNC 头部 \ 保留); - canonicalizeWindowsShape:盘符路径的画布路径本身同步折叠——chip tooltip、 Explorer 定位与打开拿到干净的单反斜杠形态,MessageStream 与变更卡 exactPaths 的抑制比对也能对上。 用该 session 真实落库数据重放派生管线验证:registry.json 从 2 条收敛为 1 条。 Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: DavidShen --- .../renderer/__tests__/generatedFiles.test.ts | 34 +++++++++++++++++++ .../src/renderer/lib/generatedFiles.ts | 17 ++++++++-- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/renderer/__tests__/generatedFiles.test.ts b/apps/desktop/src/renderer/__tests__/generatedFiles.test.ts index d5d598e8302..4f7d50c56ff 100644 --- a/apps/desktop/src/renderer/__tests__/generatedFiles.test.ts +++ b/apps/desktop/src/renderer/__tests__/generatedFiles.test.ts @@ -122,6 +122,40 @@ describe('collectGeneratedFiles', () => { expect(files[0].source).toBe('tool'); }); + it('collapses doubled backslashes so escaped wrapper commands do not duplicate chips', () => { + // 实测场景(pr-watch session):powershell 包一层 node -e,落库命令文本里的 + // 路径带转义残留(`C:\\Users\\...`);同轮另一条命令用正斜杠形态。fs 层它们 + // 是同一文件(Windows 归并重复分隔符),不折叠会出两个同名 chip。 + const files = collectGeneratedFiles( + [ + toolUse('Bash', { + command: "powershell -Command '$p = 'C:\\\\Users\\\\U\\\\pr-watch\\\\registry.json'; Get-Content $p'", + }), + toolUse('Bash', { + command: "node -e \"const p='C:/Users/U/pr-watch/registry.json'; console.log(p);\"", + }), + ], + 'C:\\Users\\U\\pr-watch', + ); + const registry = files.filter((f) => f.name === 'registry.json'); + expect(registry).toHaveLength(1); + // 画布路径本身也折叠成单反斜杠本机形态,不带转义残留。 + expect(registry[0].path).toBe('C:\\Users\\U\\pr-watch\\registry.json'); + }); + + it('keeps the UNC leading double backslash while collapsing inner separator runs', () => { + const files = collectGeneratedFiles( + [ + toolUse('Bash', { command: "copy out '\\\\server\\share\\\\dir\\report.csv'" }), + toolUse('Bash', { command: "open '\\\\server\\share\\dir\\report.csv'" }), + ], + 'C:\\work', + ); + const reports = files.filter((f) => f.name === 'report.csv'); + expect(reports).toHaveLength(1); + expect(reports[0].path.startsWith('\\\\server\\')).toBe(true); + }); + it('excludes command mentions of files edited by file tools this turn', () => { // 编码会话形态:Edit 改了源码文件,随后命令引用它(跑测试)。它是编辑不是 // 新建,不能因 mtime 落在本轮窗口就被当成产物。 diff --git a/apps/desktop/src/renderer/lib/generatedFiles.ts b/apps/desktop/src/renderer/lib/generatedFiles.ts index 31369d5adc8..f7aebe1f9b2 100644 --- a/apps/desktop/src/renderer/lib/generatedFiles.ts +++ b/apps/desktop/src/renderer/lib/generatedFiles.ts @@ -51,9 +51,16 @@ interface ToolUseLike { */ function dedupeKeyForPath(abs: string): string { const isWindowsShape = /^[a-zA-Z]:[\\/]/.test(abs) || abs.includes('\\'); + if (!isWindowsShape) return abs; // 斜杠也归一:`C:/x/a.md`(命令文本常见形态)与 `C:\x\a.md`(Write 记录)是 - // 同一文件,不折叠会重复出 chip。 - return isWindowsShape ? abs.replace(/\//g, '\\').toLowerCase() : abs; + // 同一文件,不折叠会重复出 chip。连续分隔符同理折叠:命令文本常是二次转义的 + // 包装串(如 powershell 包一层 node -e),提取出的 `C:\\x\\a.md` 与 `C:\x\a.md` + // 在 fs 层等价(Windows 归并重复分隔符),不折叠会对同一文件出两个 chip。 + // UNC 头部的 `\\` 是路径语义的一部分,保留。 + return abs + .replace(/\//g, '\\') + .replace(/(? Date: Fri, 7 Aug 2026 19:48:17 +0800 Subject: [PATCH 3/7] =?UTF-8?q?fix(desktop):=20=E5=B7=A5=E4=BD=9C=E5=8C=BA?= =?UTF-8?q?=E5=A4=96=E4=B8=B4=E6=97=B6=E6=96=87=E4=BB=B6=E5=86=99=E5=85=A5?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E5=82=AC=E7=94=9F=E7=A9=BA=E7=9A=84=E9=83=A8?= =?UTF-8?q?=E5=88=86=E5=8F=98=E6=9B=B4=E5=8D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一轮修复(f101b90c3)按「有无变更证据」隐藏了零文件 opaque-tool 卡,但 agent 写工作区外的临时文件(PR 描述、scratchpad、系统 Temp)会记一条 outside-workspace,该 reason 被归入「有变更证据」,零文件卡因此重现: 「文件变更未完整记录 +0 -0」,点审查仍是空面板。 两处修正: - 源头(captureKnownFileBefore):目标路径字面在工作区外是刻意的追踪范围 排除,不是捕获损失——静默跳过,不再记 outside-workspace,也不再把 纯工作区内的捕获降级为 partial。字面在内、realpath 逃逸(symlink) 的检查保持记 reason:那种情况工作区树表面被碰过,值得标注。 - 读侧自愈(hasReviewableTurnChanges):零文件判定集合加入 outside-workspace,已落盘的存量条目(以及 codex 外来 diff 块 fail-closed 产生的零文件条目)同样不再渲染死胡同卡。非零文件条目不受影响。 新增测试:工作区外已知写入不降级捕获/纯外部写入整轮不落卡/symlink 逃逸 仍标 partial(store 集成 3 例);渲染过滤用例扩入 outside-workspace。 Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: DavidShen --- .../__tests__/store.git-integration.test.ts | 97 +++++++++++++++++++ .../desktop/src/main/turn-change-set/store.ts | 8 +- .../buildRenderItemsKeyStability.test.ts | 5 +- apps/desktop/src/shared/turnChangeSet.ts | 14 ++- 4 files changed, 116 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/main/turn-change-set/__tests__/store.git-integration.test.ts b/apps/desktop/src/main/turn-change-set/__tests__/store.git-integration.test.ts index 4932a1a95f7..bbd9d004dcc 100644 --- a/apps/desktop/src/main/turn-change-set/__tests__/store.git-integration.test.ts +++ b/apps/desktop/src/main/turn-change-set/__tests__/store.git-integration.test.ts @@ -1203,6 +1203,103 @@ describe('turn change-set sidecar store', () => { }); }); + it('skips known writes literally outside the workspace without marking the capture incomplete', async () => { + const inWorkspace = path.join(workdir, 'kept.txt'); + const outside = path.join(root, 'agent-temp.md'); + await beginTurnChangeSet({ + sessionId: 'session-1', + anchorClientId: 'user-1', + provider: 'claude-code', + cwd: workdir, + }); + // Deliberate scope exclusion: temp files outside the workspace tree are not + // tracked and must not degrade the in-workspace capture to partial. + await captureKnownFileBefore({ + sessionId: 'session-1', + provider: 'claude-code', + cwd: workdir, + targetPath: outside, + }); + await fs.writeFile(outside, 'temp\n', 'utf8'); + await captureKnownFileBefore({ + sessionId: 'session-1', + provider: 'claude-code', + cwd: workdir, + targetPath: 'kept.txt', + }); + await fs.writeFile(inWorkspace, 'kept\n', 'utf8'); + await finalizeTurnChangeSet('session-1', null, 'complete'); + + const [summary] = await listTurnChangeSets('session-1'); + expect(summary).toMatchObject({ + state: 'complete', + incompleteReasons: [], + fileCount: 1, + }); + expect(summary?.files[0]?.path).toBe('kept.txt'); + }); + + it('drops a turn whose only known write was outside the workspace', async () => { + const outside = path.join(root, 'agent-temp.md'); + await beginTurnChangeSet({ + sessionId: 'session-1', + anchorClientId: 'user-1', + provider: 'claude-code', + cwd: workdir, + }); + await captureKnownFileBefore({ + sessionId: 'session-1', + provider: 'claude-code', + cwd: workdir, + targetPath: outside, + }); + await fs.writeFile(outside, 'temp\n', 'utf8'); + await finalizeTurnChangeSet('session-1', null, 'complete'); + + expect(await listTurnChangeSets('session-1')).toHaveLength(0); + }); + + it('still marks the capture incomplete when an in-workspace path escapes via symlink', async () => { + const escapeTarget = path.join(root, 'escape-target'); + await fs.mkdir(escapeTarget); + await fs.writeFile(path.join(escapeTarget, 'secret.txt'), 'secret\n', 'utf8'); + await fs.symlink( + escapeTarget, + path.join(workdir, 'link'), + process.platform === 'win32' ? 'junction' : 'dir', + ); + await beginTurnChangeSet({ + sessionId: 'session-1', + anchorClientId: 'user-1', + provider: 'claude-code', + cwd: workdir, + }); + // Literally inside the workspace, resolves outside: the workspace tree looks + // touched, so the incomplete reason must stay. + await captureKnownFileBefore({ + sessionId: 'session-1', + provider: 'claude-code', + cwd: workdir, + targetPath: 'link/secret.txt', + }); + const inWorkspace = path.join(workdir, 'kept.txt'); + await captureKnownFileBefore({ + sessionId: 'session-1', + provider: 'claude-code', + cwd: workdir, + targetPath: 'kept.txt', + }); + await fs.writeFile(inWorkspace, 'kept\n', 'utf8'); + await finalizeTurnChangeSet('session-1', null, 'complete'); + + const [summary] = await listTurnChangeSets('session-1'); + expect(summary).toMatchObject({ + state: 'partial', + incompleteReasons: expect.arrayContaining(['outside-workspace']), + fileCount: 1, + }); + }); + it.each([ ['absolute path', 'diff --git /outside.txt /outside.txt'], ['parent traversal', 'diff --git a/../escape.txt b/../escape.txt'], diff --git a/apps/desktop/src/main/turn-change-set/store.ts b/apps/desktop/src/main/turn-change-set/store.ts index c6211f665c5..f6e287396ce 100644 --- a/apps/desktop/src/main/turn-change-set/store.ts +++ b/apps/desktop/src/main/turn-change-set/store.ts @@ -918,7 +918,13 @@ export async function captureKnownFileBefore(input: KnownFileWriteCapture): Prom const pending = ensurePending(input.sessionId, input.provider, input.cwd); const target = safeRelativeTarget(input.cwd, input.targetPath); if (!target) { - addIncompleteReason(pending, 'outside-workspace'); + // A known write target literally outside the workspace (agent temp files, + // scratchpad, OS temp dirs) is a deliberate scope exclusion, not a capture + // loss: turn change tracking only covers the workspace tree. Recording + // 'outside-workspace' here spawned a dead-end "+0 -0" partial card for + // turns that never touched the workspace at all. The realpath escape check + // below still records the reason — there the workspace tree appears + // touched, which is worth flagging. return; } if (detectSensitivePath(target.relativePath, { allowEnvTemplates: true })) { diff --git a/apps/desktop/src/renderer/__tests__/buildRenderItemsKeyStability.test.ts b/apps/desktop/src/renderer/__tests__/buildRenderItemsKeyStability.test.ts index 45469646884..81b351e16f8 100644 --- a/apps/desktop/src/renderer/__tests__/buildRenderItemsKeyStability.test.ts +++ b/apps/desktop/src/renderer/__tests__/buildRenderItemsKeyStability.test.ts @@ -346,11 +346,12 @@ describe('buildRenderItems — key stability', () => { additions: 0, deletions: 0, }; - // Only "we might not have seen everything" reasons: review pane would be empty. + // Only "we might not have seen everything" or out-of-tracking-scope reasons: + // review pane would be empty. const noEvidence: TurnChangeSetSummary = { ...base, id: 'cs-noise', - incompleteReasons: ['opaque-tool', 'turn-failed', 'concurrent-workspace'], + incompleteReasons: ['opaque-tool', 'turn-failed', 'concurrent-workspace', 'outside-workspace'], }; // Proof that real changes existed but were not recorded: card must stay. const truncated: TurnChangeSetSummary = { diff --git a/apps/desktop/src/shared/turnChangeSet.ts b/apps/desktop/src/shared/turnChangeSet.ts index a057a591f58..cb41b4c3d2d 100644 --- a/apps/desktop/src/shared/turnChangeSet.ts +++ b/apps/desktop/src/shared/turnChangeSet.ts @@ -76,16 +76,20 @@ export interface PersistedTurnChangeSetV1 { } /** - * Reasons that only say "the capture may not have seen everything", without any - * evidence that a change actually happened (opaque tools ran, the turn failed, or - * another session overlapped). Reasons outside this set (diff-too-large, - * outside-workspace, sensitive-file, …) prove real changes existed but were not - * recorded. + * Reasons that never justify a standalone zero-file review card. Either the + * capture only "may not have seen everything" without evidence that a change + * happened (opaque tools ran, the turn failed, another session overlapped), or + * the affected paths are deliberately out of tracking scope ('outside-workspace': + * agent temp files, symlink escapes and fail-closed foreign diff blocks all + * concern content the review UI will never show). Reasons outside this set + * (diff-too-large, sensitive-file, …) prove in-scope changes existed but were + * not recorded. */ const NO_CHANGE_EVIDENCE_REASONS: ReadonlySet = new Set([ 'opaque-tool', 'turn-failed', 'concurrent-workspace', + 'outside-workspace', ]); /** From b716ed8fb1bf609033908fd7d0ec6d81100c2bbd Mon Sep 17 00:00:00 2001 From: DavidShen Date: Fri, 7 Aug 2026 20:12:53 +0800 Subject: [PATCH 4/7] =?UTF-8?q?fix(desktop):=20=E9=9B=B6=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E5=8F=98=E6=9B=B4=E5=8D=A1=E4=B8=8D=E5=86=8D=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E8=AF=AF=E5=AF=BC=E6=80=A7=20+0=20-0=20=E4=B8=8E=E7=A9=BA?= =?UTF-8?q?=E5=AE=A1=E6=9F=A5=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fileCount=0 但带变更证据(sensitive-file / diff-too-large / read-failed 等) 的部分变更卡,此前照常渲染 +0 -0 与「审查」按钮:数字会被误读成「没有 变化」,审查面板必然为空。这类卡的职责只是警示「有文件被更改但内容未 记录、无法在此撤销」,现在: - 增删统计只在录到文件时显示; - 「审查」按钮只在有可展示 diff 时显示(撤销按钮本就受 isReversible 约束, 零文件时不出现); - 副标题换用明确文案 partialNone(四语言),说明内容未记录、无法在此 审查或撤销。 有文件行的部分变更卡(partial + files>0)渲染不变。 Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: DavidShen --- .../components/chat/TurnChangesCard.tsx | 38 ++++++++++++------- .../chat/__tests__/TurnChangesCard.test.tsx | 26 +++++++++++++ .../src/renderer/i18n/locales/en/common.json | 1 + .../src/renderer/i18n/locales/ja/common.json | 1 + .../src/renderer/i18n/locales/ko/common.json | 1 + .../renderer/i18n/locales/zh-CN/common.json | 1 + 6 files changed, 54 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/renderer/components/chat/TurnChangesCard.tsx b/apps/desktop/src/renderer/components/chat/TurnChangesCard.tsx index 1a9e2f0114c..1035d3f0701 100644 --- a/apps/desktop/src/renderer/components/chat/TurnChangesCard.tsx +++ b/apps/desktop/src/renderer/components/chat/TurnChangesCard.tsx @@ -187,17 +187,23 @@ export function TurnChangesCard({ ? t('chat.turnChanges.title', { count: changeSet.fileCount }) : t('chat.turnChanges.partialTitle')}
-
- +{changeSet.additions}{' '} - -{changeSet.deletions} -
+ {files.length > 0 && ( + // 零文件卡的语义是「发生了变更但没录下内容」,+0 −0 会被误读成 + // 「没有变化」,所以增删统计只在录到文件时显示。 +
+ +{changeSet.additions}{' '} + -{changeSet.deletions} +
+ )} {changeSet.state === 'partial' && (
{t( - appliesCapturedSubset - ? 'chat.turnChanges.partialReversible' - : 'chat.turnChanges.partial', + files.length === 0 + ? 'chat.turnChanges.partialNone' + : appliesCapturedSubset + ? 'chat.turnChanges.partialReversible' + : 'chat.turnChanges.partial', )}
)} @@ -238,13 +244,17 @@ export function TurnChangesCard({ )} )} - + {files.length > 0 && ( + // 零文件卡没有可展示的 diff,审查面板必然为空;这类卡只承担 + // 「有变更但未记录」的警示职责,不提供死路入口。 + + )}
diff --git a/apps/desktop/src/renderer/components/chat/__tests__/TurnChangesCard.test.tsx b/apps/desktop/src/renderer/components/chat/__tests__/TurnChangesCard.test.tsx index 4fa59fd8e2a..52985c0d880 100644 --- a/apps/desktop/src/renderer/components/chat/__tests__/TurnChangesCard.test.tsx +++ b/apps/desktop/src/renderer/components/chat/__tests__/TurnChangesCard.test.tsx @@ -233,6 +233,32 @@ describe('TurnChangesCard file actions', () => { )); }); + it('renders a zero-file evidence card as a pure warning without +0 -0 or dead-end actions', () => { + // 零文件但带变更证据(如 sensitive-file / diff-too-large)的卡:内容没录下, + // +0 -0 会被误读成「没有变化」,「审查」必然打开空面板。只保留警示文案。 + render( + , + ); + + expect(screen.getByText('chat.turnChanges.partialTitle')).toBeTruthy(); + expect(screen.getByText('chat.turnChanges.partialNone')).toBeTruthy(); + expect(screen.queryByText(/\+0/)).toBeNull(); + expect(screen.queryByRole('button', { name: 'chat.turnChanges.review' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'chat.turnChanges.undoAria' })).toBeNull(); + }); + it('does not offer undo for a non-reversible patch', () => { render( Date: Fri, 7 Aug 2026 20:30:21 +0800 Subject: [PATCH 5/7] =?UTF-8?q?fix(desktop):=20detached=20=E5=8F=B3?= =?UTF-8?q?=E6=A0=8F=E8=B7=A8=E4=BC=9A=E8=AF=9D=E8=BD=AE=E6=AC=A1=E5=AE=A1?= =?UTF-8?q?=E6=9F=A5=E6=8C=89=E5=AE=BF=E4=B8=BB=E6=A1=B6=E8=A3=81=E5=86=B3?= =?UTF-8?q?=E4=B8=8E=E6=8E=92=E9=98=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 跨会话 open-turn-review(协同面板审查 worker 轮次)携带 worker sessionId (取数目标)与 lead hostSessionId(可见桶),但 RsbWindowController 的 canDispatchCommand 仍按 sessionId 与当前 context 比对——detached 形态下 context 停在 lead,worker 审查命令被误判 stale-context 拒发,协同审查 入口在 detached 右栏下点了没反应;延迟命令同样按 worker 会话入队, context 不会切到 worker,永远刷不出来。 新增 commandHostSessionId:open-turn-review 取 hostSessionId ?? sessionId, 其余命令保持自身 sessionId。裁决(canDispatchCommand)与 deferred 排队 (enqueueDeferredCommand)统一以宿主桶为键;hostSessionId 与当前 context 不符仍拒发,可见性边界不放宽。 新增 controller 测试 2 例:跨会话命令按 lead 桶 routed(错桶仍 stale-context)、allowOpen=false 时按 lead 桶入队并在 lead ready 后派发。 Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: DavidShen --- .../__tests__/controller.test.ts | 56 +++++++++++++++++++ .../main/right-sidebar-window/controller.ts | 20 +++++-- 2 files changed, 72 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/main/right-sidebar-window/__tests__/controller.test.ts b/apps/desktop/src/main/right-sidebar-window/__tests__/controller.test.ts index aaaf200c3df..f903f7d7e72 100644 --- a/apps/desktop/src/main/right-sidebar-window/__tests__/controller.test.ts +++ b/apps/desktop/src/main/right-sidebar-window/__tests__/controller.test.ts @@ -433,6 +433,62 @@ describe('setContext / routeCommand', () => { }); }); + it('跨会话 open-turn-review 按 hostSessionId(lead 桶)裁决,worker sessionId 不拒发', async () => { + // 协同面板审查 worker 轮次:command.sessionId 是取数目标 worker,可见桶是 + // lead(hostSessionId)。detached context 停在 lead 上,按 sessionId 裁决会 + // 误判 stale-context,worker 审查入口在 detached 形态下点了没反应。 + const h = makeHarness({ detached: true }); + h.controller.setContext(ctx); + h.controller.open(); + h.controller.markReady(); + + const command = { + type: 'open-turn-review' as const, + sessionId: 'worker-1', + changeSetIds: ['c1'], + selectedDiffId: null, + selectedPath: null, + requestNonce: 1, + hostSessionId: 's1', + }; + await expect( + h.controller.routeCommand({ command, allowOpen: true }), + ).resolves.toBe('routed'); + expect(h.sends.at(-1)).toEqual({ channel: 'cmd-channel', payload: command }); + + // hostSessionId 与当前 context 不符时仍是 stale-context(不能放宽成任意会话)。 + await expect( + h.controller.routeCommand({ + command: { ...command, hostSessionId: 'other-lead' }, + allowOpen: true, + }), + ).resolves.toBe('stale-context'); + }); + + it('跨会话 open-turn-review 延迟命令按 lead 桶入队,lead ready 后派发', async () => { + const h = makeHarness({ detached: true }); + h.controller.setContext(ctx); + + const command = { + type: 'open-turn-review' as const, + sessionId: 'worker-1', + changeSetIds: ['c1'], + selectedDiffId: null, + selectedPath: null, + requestNonce: 2, + hostSessionId: 's1', + }; + await expect( + h.controller.routeCommand({ command, allowOpen: false }), + ).resolves.toBe('queued'); + expect(h.windows).toHaveLength(0); + + // context 一直是 lead(s1),不会切到 worker;flush 必须按 lead 桶命中。 + h.controller.open(); + h.controller.markReady(); + expect(h.sends.at(-1)).toEqual({ channel: 'cmd-channel', payload: command }); + }); + it('context mismatch / unavailable 返回 stale-context,不开窗也不派发', async () => { const h = makeHarness({ detached: true }); h.controller.setContext({ ...ctx, sessionId: 's2' }); diff --git a/apps/desktop/src/main/right-sidebar-window/controller.ts b/apps/desktop/src/main/right-sidebar-window/controller.ts index f2415a3eb83..92b370bb401 100644 --- a/apps/desktop/src/main/right-sidebar-window/controller.ts +++ b/apps/desktop/src/main/right-sidebar-window/controller.ts @@ -61,6 +61,15 @@ export interface RsbWindowControllerDeps { const READY_TIMEOUT_MS = 8000; const MAX_DEFERRED_SESSIONS = 8; +/** + * command 的宿主桶 session —— 裁决可见性与 deferred 排队都以它为准。 + * open-turn-review 可跨会话(协同面板审查 worker 轮次:sessionId 是取数目标 + * worker,tab 落在 lead 的桶),其余命令宿主即自身 sessionId。 + */ +function commandHostSessionId(cmd: RsbWindowCommand): string { + return cmd.type === 'open-turn-review' ? (cmd.hostSessionId ?? cmd.sessionId) : cmd.sessionId; +} + export class RsbWindowController { private winRef: BrowserWindow | null = null; /** BrowserWindow.close() 到 closed 事件之间仍未 destroyed,不能继续当活 host。 */ @@ -209,7 +218,7 @@ export class RsbWindowController { return Boolean( this.lastContext?.available && this.lastContext.sessionId && - this.lastContext.sessionId === cmd.sessionId, + this.lastContext.sessionId === commandHostSessionId(cmd), ); } @@ -271,7 +280,10 @@ export class RsbWindowController { } private enqueueDeferredCommand(command: RsbWindowCommand): void { - const previous = this.deferredCommands.get(command.sessionId); + // 按宿主桶排队:跨会话 open-turn-review 属于 lead 的桶,须由 lead 上下文 + // flush;按 worker sessionId 入队会在 context 保持 lead 时永远刷不出来。 + const hostSessionId = commandHostSessionId(command); + const previous = this.deferredCommands.get(hostSessionId); if ( command.type === 'ensure-orca-workers-tab' && previous?.type === 'ensure-orca-workers-tab' && @@ -281,13 +293,13 @@ export class RsbWindowController { return; } if ( - !this.deferredCommands.has(command.sessionId) && + !this.deferredCommands.has(hostSessionId) && this.deferredCommands.size >= MAX_DEFERRED_SESSIONS ) { const oldest = this.deferredCommands.keys().next().value as string | undefined; if (oldest) this.deferredCommands.delete(oldest); } - this.deferredCommands.set(command.sessionId, command); + this.deferredCommands.set(hostSessionId, command); } private flushDeferredCommandsToDetachedHost(): void { From 27e7eba40158d2823c384816c28d7a7f05cc700b Mon Sep 17 00:00:00 2001 From: DavidShen Date: Fri, 7 Aug 2026 20:58:38 +0800 Subject: [PATCH 6/7] =?UTF-8?q?fix(desktop):=20=E4=BF=9D=E7=95=99=20outsid?= =?UTF-8?q?e-workspace=20=E9=9B=B6=E6=96=87=E4=BB=B6=E5=8D=A1=E7=9A=84?= =?UTF-8?q?=E5=8F=AF=E8=A7=81=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit review 反馈(#2048 r3735770063)属实:b416d6599 把 outside-workspace 归入 「无变更证据」集合后,当零文件条目仅有该 reason 时整卡被隐藏。但源头修复 之后,store 只会在可疑场景记录 outside-workspace——工作区内路径 realpath 经 symlink 逃逸、provider diff 块 fail-closed 拒收——这些恰恰是需要用户 看见的证据;字面在工作区外的目标在捕获时已静默跳过,不会再记 reason。 读侧分类回退:outside-workspace 移出 NO_CHANGE_EVIDENCE_REASONS,零文件 outside-workspace 条目重新渲染(经 b716ed8fb 已是纯警示条,无 +0 -0、无 死胡同按钮)。源头静默跳过保持不变,原始 bug(纯外部临时文件写入催生空卡) 不复发。渲染过滤测试拆分用例:cs-escape 仅含 outside-workspace,断言保留。 Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: DavidShen --- .../buildRenderItemsKeyStability.test.ts | 18 +++++++++++++----- apps/desktop/src/shared/turnChangeSet.ts | 16 +++++++--------- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/apps/desktop/src/renderer/__tests__/buildRenderItemsKeyStability.test.ts b/apps/desktop/src/renderer/__tests__/buildRenderItemsKeyStability.test.ts index 81b351e16f8..a04ad603517 100644 --- a/apps/desktop/src/renderer/__tests__/buildRenderItemsKeyStability.test.ts +++ b/apps/desktop/src/renderer/__tests__/buildRenderItemsKeyStability.test.ts @@ -346,12 +346,11 @@ describe('buildRenderItems — key stability', () => { additions: 0, deletions: 0, }; - // Only "we might not have seen everything" or out-of-tracking-scope reasons: - // review pane would be empty. + // Only "we might not have seen everything" reasons: review pane would be empty. const noEvidence: TurnChangeSetSummary = { ...base, id: 'cs-noise', - incompleteReasons: ['opaque-tool', 'turn-failed', 'concurrent-workspace', 'outside-workspace'], + incompleteReasons: ['opaque-tool', 'turn-failed', 'concurrent-workspace'], }; // Proof that real changes existed but were not recorded: card must stay. const truncated: TurnChangeSetSummary = { @@ -361,17 +360,26 @@ describe('buildRenderItems — key stability', () => { createdAt: 3, completedAt: 4, }; + // The store only records 'outside-workspace' for suspicious captures (symlink + // escapes, fail-closed provider diff blocks) — that evidence must stay visible. + const escaped: TurnChangeSetSummary = { + ...base, + id: 'cs-escape', + incompleteReasons: ['outside-workspace'], + createdAt: 5, + completedAt: 6, + }; const { items } = buildRenderItems( [mkUser('u1'), mkAssistant('a1'), mkUser('u2')], undefined, undefined, - { turnChangeSets: [noEvidence, truncated] }, + { turnChangeSets: [noEvidence, truncated, escaped] }, ); const cards = items.filter( (item): item is Extract => item.type === 'turn_changes', ); - expect(cards.map((card) => card.changeSet.id)).toEqual(['cs-too-large']); + expect(cards.map((card) => card.changeSet.id)).toEqual(['cs-too-large', 'cs-escape']); }); it('keeps opaque command artifacts as fallback chips without duplicating exact files', () => { diff --git a/apps/desktop/src/shared/turnChangeSet.ts b/apps/desktop/src/shared/turnChangeSet.ts index cb41b4c3d2d..71783d0433b 100644 --- a/apps/desktop/src/shared/turnChangeSet.ts +++ b/apps/desktop/src/shared/turnChangeSet.ts @@ -76,20 +76,18 @@ export interface PersistedTurnChangeSetV1 { } /** - * Reasons that never justify a standalone zero-file review card. Either the - * capture only "may not have seen everything" without evidence that a change - * happened (opaque tools ran, the turn failed, another session overlapped), or - * the affected paths are deliberately out of tracking scope ('outside-workspace': - * agent temp files, symlink escapes and fail-closed foreign diff blocks all - * concern content the review UI will never show). Reasons outside this set - * (diff-too-large, sensitive-file, …) prove in-scope changes existed but were - * not recorded. + * Reasons that only say "the capture may not have seen everything", without any + * evidence that a change actually happened (opaque tools ran, the turn failed, or + * another session overlapped). Reasons outside this set prove changes existed but + * were not recorded — including 'outside-workspace', which the store only records + * for suspicious cases (a workspace path whose realpath escapes via symlink, or a + * provider diff block rejected as unsafe); literal out-of-workspace targets are + * skipped at capture time without recording any reason. */ const NO_CHANGE_EVIDENCE_REASONS: ReadonlySet = new Set([ 'opaque-tool', 'turn-failed', 'concurrent-workspace', - 'outside-workspace', ]); /** From a0fee6b1be1a07e21cc9b7657179959a1cd9558a Mon Sep 17 00:00:00 2001 From: DavidShen Date: Fri, 7 Aug 2026 21:39:48 +0800 Subject: [PATCH 7/7] =?UTF-8?q?test(device-link):=20pong=20=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=BA=94=E7=AD=94=E6=8F=90=E5=89=8D=E5=88=B0=E5=BB=BA?= =?UTF-8?q?=E9=93=BE=E5=89=8D=E8=A3=85=E9=85=8D,=E4=BF=AE=20Windows=20CI?= =?UTF-8?q?=20=E6=97=B6=E5=BA=8F=20flake?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI 失败(run 31180575750,Windows 分片 1/2)定位:「慢可靠业务 handler 不 阻塞 pong」用例把确定性 pong 应答器装在 ack() + establishInboundReliableLink 之后,但心跳 interval 在 hello-ack 时就已启动(pingIntervalMs=8, pongMissLimit=1)。慢 runner 上建链期间的几次 await tick() 真实耗时可超两个 心跳周期,期间发出的 ping 无人应答,pongMiss 越限触发误断网,断言 terminated=false 失败。本地快机器建链耗时 <8ms 抓不到,与本 PR 改动无关, 是存量时序 flake。 修法:把 send 拦截(ping→同步回 pong)提前到 ack() 之前,建链全程心跳都有 确定性应答,彻底消除对 runner 速度的依赖。断言语义不变:慢业务 handler 若 真堵住帧处理,push 进来的 pong 不会被消费,pongMiss 照样断网,回归仍能抓住。 验证:单测全文件 103/103;目标用例单跑 3 次稳定通过;tsc --noEmit 通过; 全仓 test:unit 56 package PASS。 Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: DavidShen --- .../device-link/src/__tests__/client.test.ts | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/device-link/src/__tests__/client.test.ts b/packages/device-link/src/__tests__/client.test.ts index 0337a8e926a..adedf9bff42 100644 --- a/packages/device-link/src/__tests__/client.test.ts +++ b/packages/device-link/src/__tests__/client.test.ts @@ -592,6 +592,17 @@ describe('DeviceLinkClient', () => { h.client.start(); await tick(); const ws = h.current(); + // 确定性回 pong:监听出站 ping、同步应答,彻底消除对真实计时器调度的依赖。 + // 必须在 ack()(hello-ack 启动心跳)之前装上:装晚了,建链期间的若干 + // await tick() 在慢 CI/Windows 上可能耗掉两个 8ms 心跳周期,期间 ping + // 无人应答就已误判断网。语义不变:若慢业务 handler 真堵住帧处理,push + // 进来的 pong 不会被消费,pongMiss 照样触发断网,断言仍能抓住回归。 + const originalSend = ws.send.bind(ws); + ws.send = (data: string) => { + originalSend(data); + const env = JSON.parse(data) as Envelope; + if (env.kind === 'ping') ws.push({ v: PROTOCOL_VERSION, kind: 'pong' }); + }; ws.ack(); await establishInboundReliableLink(h, 'slow-stream'); @@ -611,16 +622,6 @@ describe('DeviceLinkClient', () => { data: JSON.stringify({ channel: 'maker:event', payload: { text: 'slow' } }), }, }); - // 确定性回 pong:监听出站 ping、同步应答,彻底消除对真实计时器调度的依赖 - // (旧写法用 4ms setInterval 自由跑,慢 CI/Windows 上会落后两个 8ms 心跳 - // 周期触发误断网)。语义不变:若慢业务 handler 真堵住帧处理,push 进来的 - // pong 不会被消费,pongMiss 照样触发断网,断言仍能抓住回归。 - const originalSend = ws.send.bind(ws); - ws.send = (data: string) => { - originalSend(data); - const env = JSON.parse(data) as Envelope; - if (env.kind === 'ping') ws.push({ v: PROTOCOL_VERSION, kind: 'pong' }); - }; await tick(); expect(release).toBeTypeOf('function');