Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/src/main/right-sidebar-window/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,13 +127,22 @@ 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,
changeSetIds: r.changeSetIds as string[],
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') {
Expand Down
34 changes: 34 additions & 0 deletions apps/desktop/src/renderer/__tests__/generatedFiles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 落在本轮窗口就被当成产物。
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
});
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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(
<SidebarHostSessionProvider sessionId="lead-1">
<TurnChangesCard sessionId="session-1" changeSet={CHANGE_SET} />
</SidebarHostSessionProvider>,
);

fireEvent.click(screen.getByRole('button', { name: /src\/test\.ts/ }));

expect(mocks.openTurnReview).toHaveBeenCalledWith(
'session-1',
['change-1'],
{ selectedDiffId: 'file-1', hostSessionId: 'lead-1' },
);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,33 +11,47 @@ 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<void> {
const requestNonce = opts.requestNonce ?? ++nextRequestNonce;
const hostSessionId = opts.hostSessionId ?? sessionId;
const command = {
type: 'open-turn-review' as const,
sessionId,
changeSetIds,
selectedDiffId: opts.selectedDiffId ?? null,
selectedPath: opts.selectedPath ?? null,
Comment thread
greptile-apps[bot] marked this conversation as resolved.
requestNonce,
hostSessionId,
Comment thread
DavidShenXD marked this conversation as resolved.
};
const routeResult = await routeSidebarCommand(command);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
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<string, unknown> : {}),
turnTarget: {
changeSetIds,
selectedDiffId: opts.selectedDiffId ?? null,
selectedPath: opts.selectedPath ?? null,
requestNonce,
// 目标会话与宿主桶不同(跨会话审查 worker 的轮次)时,review 插件按它取数。
targetSessionId: sessionId,
},
}));
requestRightSidebarVisibility('open', { sessionId });
requestRightSidebarVisibility('open', { sessionId: hostSessionId });
}
Original file line number Diff line number Diff line change
@@ -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<string | null>(null);

export function SidebarHostSessionProvider({
sessionId,
children,
}: {
sessionId: string;
children: ReactNode;
}) {
return (
<SidebarHostSessionContext.Provider value={sessionId}>
{children}
</SidebarHostSessionContext.Provider>
);
}

/** 内嵌在 RSB tab 里时返回宿主(lead)sessionId;主实例返回 null。 */
export function useSidebarHostSessionId(): string | null {
return useContext(SidebarHostSessionContext);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -157,6 +158,9 @@ function OrcaWorkersTabBody({
);

return (
// 内嵌 worker 流里的审查等入口据此把 RSB tab 开到 lead 的可见桶,
// 而不是 worker 自己的(协同视图下不可见的)桶。
<SidebarHostSessionProvider sessionId={ctx.sessionId}>
<OrcaWorkerPanel
leadSessionId={ctx.sessionId}
deviceId={leadSession?.deviceLinkDeviceId}
Expand All @@ -172,6 +176,7 @@ function OrcaWorkersTabBody({
onSelectionIntentCleared={handleSelectionIntentCleared}
onSearchJumpConsumed={handleSearchJumpConsumed}
/>
</SidebarHostSessionProvider>
);
}

Expand Down
Loading