Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
026e694
feat(web-shell): Bind GitHub PRs to sessions with sidebar badge and s…
wenshao Aug 20, 2026
7049f44
feat(web-shell): Support multiple PR bindings per session
wenshao Aug 20, 2026
e6f8263
Merge origin/main into feat/webshell-session-pr-binding
wenshao Aug 20, 2026
f608f8e
Merge origin/main into feat/webshell-session-pr-binding
wenshao Aug 20, 2026
8feef93
feat(web-shell): Show PR badges in the session overview and picker di…
wenshao Aug 20, 2026
31ea3da
Merge origin/main into feat/webshell-session-pr-binding
wenshao Aug 20, 2026
031f4fc
fix(web-shell): Match the overview PR badge color to the sidebar accent
wenshao Aug 20, 2026
a60b0db
fix(web-shell): Address review findings on PR bindings
wenshao Aug 20, 2026
d8900b1
Merge origin/main into feat/webshell-session-pr-binding
wenshao Aug 20, 2026
29e3a1f
fix(core): harden session pr sidecar persistence and moves (#9543)
qwen-code-dev-bot Aug 20, 2026
7e0e665
Merge branch 'main' into feat/webshell-session-pr-binding
wenshao Aug 21, 2026
056f866
Merge remote-tracking branch 'origin/main' into feat/webshell-session…
qwen-code-dev-bot Aug 21, 2026
3f66720
fix(serve): align session pr echoes with the persisted sidecar (#9543)
qwen-code-dev-bot Aug 21, 2026
76b892a
chore(desktop): regenerate bun.lock to match workspace versions
wenshao Aug 21, 2026
9375619
Merge remote-tracking branch 'wenshao/feat/webshell-session-pr-bindin…
wenshao Aug 21, 2026
5419305
fix(web-shell): address R2 review findings on PR bindings
wenshao Aug 21, 2026
9c3c171
Merge origin/main into feat/webshell-session-pr-binding
wenshao Aug 21, 2026
f489d2c
fix(serve): address R5 review findings on PR bindings
wenshao Aug 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions docs/design/2026-08-20-webshell-session-pr-binding.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Web Shell 会话绑定 GitHub PR 号

日期:2026-08-20
状态:已确认 MVP 范围

## 问题

Web Shell 同时运行 20+ 会话时,侧栏信息不足以回答"哪个会话对应 PR #N"。
当前链路全断:

1. `GitDialog.doCreatePr` 创建 PR 拿到 `{url, number}` 后只显示状态消息,不回写(`packages/web-shell/client/components/dialogs/GitDialog.tsx:502-553`)。
2. `DaemonSessionSummary` / `BridgeSessionSummary` 无 PR 字段;`updateSessionMetadata` 只放行 `displayName`。
3. 侧栏搜索只匹配标题和 sessionId(`WebShellSidebar.tsx:3289-3302`),不匹配分支名、worktree slug、PR 号。
4. 无任何持久化载体,daemon 重启后即使内存绑定也会丢。

## 方案

### 数据模型

`DaemonSessionSummary` 与 `BridgeSessionSummary`(镜像,需同步)增加:

```json
"prs": [{ "number": 9517, "url": "https://github.com/owner/repo/pull/9517" }]
```

- 一个会话可能创建多个 PR(stacked PR、连续修复),`prs` 按绑定时间排序(最后一个 = 最新),上限 10 个(超出丢弃最旧)。同号重复绑定刷新 url 并移到最新位。
- `number`:正整数;`url`:http(s) URL(badge/tooltip 直接作为链接目标渲染,拒绝 `javascript:` 等 scheme——route、bridge、SDK 校验器、sidecar 校验四层统一要求)。
- 字段可选、可缺省;不提供"清除"语义。
- 写入 API 保持单条:`updateSessionMetadata(sessionId, { pr: {number, url} })` 每次绑定一个,daemon 负责 upsert 进列表;读取/事件/响应均为完整 `prs` 数组。

### 写入端

- SDK `DaemonClient.updateSessionMetadata` 的 metadata 参数扩展 `pr?: { number: number; url: string }`(单条);响应解析完整 `prs` 数组。
- daemon 两个 PATCH metadata 路由(`/session/:id/metadata` 与 workspace 作用域版本)校验 `pr` 后透传,bridge 更新成功后将 sidecar upsert 的完整列表回显在响应里。
- bridge `updateSessionMetadata`(`packages/acp-bridge/src/bridge.ts`)先做全部校验再变更(组合请求不允许部分生效);upsert 进 live entry.prs(去重按 number,上限 10),`session_metadata_updated` SSE 事件 data 带完整 `prs`。
- ACP `session/update_metadata`(`acp-http/dispatch.ts`)同样把最新绑定 upsert 进 sidecar。
- `GitDialog.doCreatePr` 成功后:仅用 dialog 已有的 `sessionId`(`sessionIdRef.current`,即连接会话或 dialog 已为提交信息生成等操作解析出的会话)调用 `updateSessionMetadata(sessionId, { pr })`。**不调 `resolveSessionForWorkspace`**——它可能创建幽灵会话或误绑"最近会话"。写入失败仅降级为 console 警告,不影响 PR 创建成功的状态展示。

### 持久化

新增 sidecar `<chatsDir>/<sessionId>.pr.json`,复刻 worktree sidecar 模式:

- 新 core 服务 `packages/core/src/services/session-pr-service.ts`:`SessionPr` 接口、数组 schema 校验(`{prs: [...]}`,容忍 ENOENT/JSON 损坏)、`readSessionPrs` / `writeSessionPrs` / `upsertSessionPr`(按 number 去重、移到最新、cap 10)。
- `SessionService` 增加 `getPrSessionPathForArchiveState` 路径助手;归档/取消归档移动 sidecar、删除会话时清理(与 worktree sidecar 一一对应)。
- `session-list.ts` 的 `enrichPrSidecars` 回填 persisted summary 的 `prs`;live 会话的 entry.prs 只含本 daemon 生命周期内的绑定,回填时与 sidecar 历史按 number 合并(live 的 url 优先,live-only 的排最后)。

### 展示与搜索(web-shell 侧栏)

- `renderSessionRow`:会话行标题旁渲染小号 badge(`session.prs` 非空时),显示最新 PR 号,多于一个时追加 `+N`;点击经 `useExternalLinkOpener` 打开最新 PR(desktop webview 下 `target="_blank"` 会被静默丢弃);click/doubleClick/keydown 均 stopPropagation(双击 badge 不触发重命名)。
- `SessionDetailsTooltip`:列出全部绑定 PR(最新在前),各为外链。
- `filteredSessions` 匹配逻辑扩展:`label`、`sessionId` 之外,增加**任意一个**绑定 PR 号(输入 `9517` 或 `#9517` 都命中)、`branch.name`、`worktree.branch`、`worktree.slug`(`sessionMatchesGitQuery`,WebShellSidebar 与 WorkspaceSection 共用)。
- SSE 消费侧:web-shell 不直接消费 `session_metadata_updated` 更新 store;bridge 的 `markSessionCatalogChanged()` 触发 catalog revision bump,侧栏 live-state 轮询(2s 周期)发现后自动 refetch——badge 在绑定后 ~2s 内出现(与改名等其他客户端变更的传播机制一致)。
- i18n:新增 `sidebar.sessionPr` / `sidebar.sessionPrMultiple` 两个 key(EN/ZH)。

## 关键决策

- **绑定时机 = GitDialog 创建 PR 成功时**。Agent 在 shell 里自行 `gh pr create` 的路径无法拦截,MVP 不覆盖;用户主力流程是 GitDialog。
- **sidecar 而非 transcript 记录**:displayName 走 `custom_title` transcript 记录是因为标题属于会话内容流;PR 绑定是会话外部元数据,worktree sidecar 是同类先例,改动面更小。
- **多 PR 列表(cap 10)**:一个会话可能创建多个 PR(stacked PR、连续修复),只保留最新一个会让"按 PR 号反查会话"在这些场景失效。绑定按 number 去重、重复绑定移到最新位;badge 显示最新号 + `+N`,tooltip 列全部,搜索匹配任意一个。上限 10 防无界增长。
- **workspace 级打开 GitDialog(无会话上下文)时不回写**:dialog 没有已解析的会话就跳过,不报错;绝不通过 `resolveSessionForWorkspace` 创建新会话来绑定(会产生幽灵会话/误绑)。

## 影响文件

| 层 | 文件 |
| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| SDK 类型 | `packages/sdk-typescript/src/daemon/types.ts`(DaemonSessionSummary.pr) |
| SDK 事件 | `packages/sdk-typescript/src/daemon/events.ts`(MetadataUpdated data + 校验) |
| SDK 客户端 | `packages/sdk-typescript/src/daemon/DaemonClient.ts`(updateSessionMetadata 参数) |
| bridge 类型 | `packages/acp-bridge/src/bridgeTypes.ts`(BridgeSessionSummary.pr、metadata 参数) |
| bridge | `packages/acp-bridge/src/bridge.ts`(updateSessionMetadata 校验/存储/广播) |
| core | `packages/core/src/services/session-pr-service.ts`(新增)+ SessionService 路径助手/归档移动/删除清理 |
| daemon 路由 | `packages/cli/src/serve/routes/session.ts`(两个 PATCH 路由校验 + sidecar 写入)、`acp-http/dispatch.ts`(ACP `session/update_metadata` 的 sidecar 写入) |
| daemon 列表 | `packages/cli/src/serve/server/session-list.ts`(enrichPrSidecars) |
| web-shell | `GitDialog.tsx`(回写)、`WebShellSidebar.tsx`(badge + 搜索)、`SessionDetailsTooltip.tsx`(PR 行)、locale 文件 |
| 测试 | 上述各层的 collocated 单测 |

## 范围边界(明确不做)

- 服务端分页过滤(20+ 会话规模客户端搜索足够;`sourceType/sourceId` 过滤管道是将来扩展的样板)。
- 历史会话迁移;CLI `--worktree=#<pr>` 的 `pr-<n>` slug 由搜索匹配 slug 顺带覆盖。
- 纯 branch 会话 branch 信息重启丢失的独立 bug,另行处理。
- Agent shell 内 `gh pr create` 的自动发现。

## 开放问题

无。
120 changes: 120 additions & 0 deletions packages/acp-bridge/src/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26362,6 +26362,126 @@ describe('createAcpSessionBridge', () => {
await bridge.shutdown();
});

it('stores a pr binding, exposes it in summaries, and publishes an event', async () => {
const handles: Array<{ killed: boolean }> = [];
const factory: ChannelFactory = async () => {
const h = makeChannel();
handles.push(h);
return h.channel;
};
const bridge = makeBridge({ channelFactory: factory });
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });

const events: BridgeEvent[] = [];
const sub = bridge.subscribeEvents(session.sessionId);
const drain = (async () => {
for await (const ev of sub) events.push(ev);
})();
await new Promise((r) => setImmediate(r));

const pr = { number: 9517, url: 'https://github.com/o/r/pull/9517' };
const effective = bridge.updateSessionMetadata(session.sessionId, {
pr,
});

expect(effective.prs).toEqual([pr]);
expect(bridge.getSessionSummary(session.sessionId).prs).toEqual([pr]);
await new Promise((r) => setImmediate(r));
const metaEvent = events.find(
(e) =>
e.type === 'session_metadata_updated' &&
(e.data as { prs?: unknown }).prs !== undefined,
);
expect(metaEvent).toBeDefined();
expect((metaEvent?.data as { prs: Array<typeof pr> }).prs).toEqual([pr]);

await bridge.closeSession(session.sessionId);
await drain;
await bridge.shutdown();
});

it('accumulates multiple bindings and re-binding moves a number to latest', async () => {
const bridge = makeBridge({
channelFactory: async () => makeChannel().channel,
});
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });

const prA = { number: 9500, url: 'https://github.com/o/r/pull/9500' };
const prB = { number: 9517, url: 'https://github.com/o/r/pull/9517' };
bridge.updateSessionMetadata(session.sessionId, { pr: prA });
const effective = bridge.updateSessionMetadata(session.sessionId, {
pr: prB,
});
expect(effective.prs).toEqual([prA, prB]);

const prA2 = {
number: 9500,
url: 'https://github.com/o/r/pull/9500?v=2',
};
const rebound = bridge.updateSessionMetadata(session.sessionId, {
pr: prA2,
});
expect(rebound.prs).toEqual([prB, prA2]);

await bridge.closeSession(session.sessionId);
await bridge.shutdown();
});

it('does not republish when the same pr is bound again', async () => {
const bridge = makeBridge({
channelFactory: async () => makeChannel().channel,
});
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });

const events: BridgeEvent[] = [];
const sub = bridge.subscribeEvents(session.sessionId);
const drain = (async () => {
for await (const ev of sub) events.push(ev);
})();
await new Promise((r) => setImmediate(r));

const pr = { number: 9517, url: 'https://github.com/o/r/pull/9517' };
bridge.updateSessionMetadata(session.sessionId, { pr });
bridge.updateSessionMetadata(session.sessionId, { pr });

await new Promise((r) => setImmediate(r));
const prEvents = events.filter(
(e) =>
e.type === 'session_metadata_updated' &&
(e.data as { prs?: unknown }).prs !== undefined,
);
expect(prEvents).toHaveLength(1);

await bridge.closeSession(session.sessionId);
await drain;
await bridge.shutdown();
});

it.each([
[
'non-integer number',
{ number: 1.5, url: 'https://github.com/o/r/pull/1' },
],
['missing url', { number: 1 }],
['empty url', { number: 1, url: '' }],
['non-http url', { number: 1, url: 'javascript:alert(1)' }],
['null', null],
])('rejects an invalid pr: %s', async (_label, pr) => {
Comment thread
wenshao marked this conversation as resolved.
Resolved
const bridge = makeBridge({
channelFactory: async () => makeChannel().channel,
});
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });

expect(() =>
bridge.updateSessionMetadata(session.sessionId, {
pr: pr as { number: number; url: string },
}),
).toThrow(InvalidSessionMetadataError);

await bridge.closeSession(session.sessionId);
await bridge.shutdown();
});

it('throws SessionNotFoundError for unknown session', () => {
const bridge = makeBridge();
expect(() =>
Expand Down
58 changes: 57 additions & 1 deletion packages/acp-bridge/src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ import type {
BridgeRestoredSession,
BridgeSessionGoal,
BridgeSessionSummary,
SessionPrInfo,
BridgeTurnStatus,
BridgeSessionCatalogVersion,
BridgePendingInteraction,
Expand Down Expand Up @@ -962,6 +963,8 @@ interface SessionEntry {
worktree?: { slug: string; path: string; branch: string };
/** Branch metadata, when created with branch param. */
branch?: { name: string; baseBranch: string };
/** GitHub PRs bound via updateSessionMetadata, in binding order. */
prs?: SessionPrInfo[];
channel: AcpChannel;
connection: ClientSideConnection;
/** Per-session event bus drives `GET /session/:id/events`. */
Expand Down Expand Up @@ -1246,6 +1249,8 @@ function writeServeDebugLine(message: string): void {
}

const MAX_DISPLAY_NAME_LENGTH = 256;
/** Bound on the per-session PR binding list; oldest are dropped beyond it. */
const MAX_SESSION_PRS = 10;
Comment thread
wenshao marked this conversation as resolved.
Resolved

/**
* Upper bound on how many prompt content blocks the bridge echoes per
Expand Down Expand Up @@ -3628,6 +3633,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
pendingInteractions: [...entry.pendingInteractions.values()],
...(entry.worktree ? { worktree: entry.worktree } : {}),
...(entry.branch ? { branch: entry.branch } : {}),
...(entry.prs && entry.prs.length > 0 ? { prs: entry.prs } : {}),
};
};
// Pending + resolved permission state lives in
Expand Down Expand Up @@ -9598,6 +9604,26 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
context?.clientId !== undefined
? resolveTrustedClientId(entry, context.clientId)
: undefined;
// Validate everything before mutating anything: a combined
// displayName+pr request must not partially apply when the pr is
// invalid.
if (metadata.pr !== undefined) {
const pr = metadata.pr as unknown;
if (
pr === null ||
typeof pr !== 'object' ||
typeof (pr as SessionPrInfo).number !== 'number' ||
!Number.isInteger((pr as SessionPrInfo).number) ||
(pr as SessionPrInfo).number <= 0 ||
typeof (pr as SessionPrInfo).url !== 'string' ||
!/^https?:\/\//i.test((pr as SessionPrInfo).url)
Comment thread
wenshao marked this conversation as resolved.
Resolved
) {
throw new InvalidSessionMetadataError(
'pr',
'must be an object with a positive integer `number` and an http(s) `url`',
);
Comment thread
wenshao marked this conversation as resolved.
Resolved
}
}
if (metadata.displayName !== undefined) {
if (
typeof metadata.displayName !== 'string' ||
Expand Down Expand Up @@ -9664,7 +9690,37 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
}
}
}
return { displayName: entry.displayName };
if (metadata.pr !== undefined) {
// Already validated above, before any mutation.
const bound = metadata.pr;
Comment thread
wenshao marked this conversation as resolved.
Resolved
Comment thread
wenshao marked this conversation as resolved.
Resolved
const existing = entry.prs ?? [];
Comment thread
wenshao marked this conversation as resolved.
Resolved
const latest = existing[existing.length - 1];
if (latest?.number === bound.number && latest.url === bound.url) {
// Same binding repeated — no change, no event.
} else {
// Re-binding a number refreshes it and moves it to latest.
entry.prs = [
...existing.filter((p) => p.number !== bound.number),
{ number: bound.number, url: bound.url },
].slice(-MAX_SESSION_PRS);
markSessionCatalogChanged();
Comment thread
wenshao marked this conversation as resolved.
Resolved
Comment thread
wenshao marked this conversation as resolved.
Resolved
try {
entry.events.publish({
type: 'session_metadata_updated',
data: { sessionId, prs: entry.prs },
...(metadataOriginatorClientId
? { originatorClientId: metadataOriginatorClientId }
: {}),
});
} catch {
/* bus already closed */
}
}
}
return {
displayName: entry.displayName,
...(entry.prs && entry.prs.length > 0 ? { prs: entry.prs } : {}),
Comment thread
wenshao marked this conversation as resolved.
Resolved
};
},

async getSessionArtifacts(sessionId, context) {
Expand Down
15 changes: 14 additions & 1 deletion packages/acp-bridge/src/bridgeTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -685,6 +685,11 @@ export interface BridgeSessionSummary {
worktree?: { slug: string; path: string; branch: string };
/** Present when the session was created with a new branch. */
branch?: { name: string; baseBranch: string };
/**
* GitHub PRs bound to the session, in binding order (last = latest). A
* session can produce several PRs (stacked or follow-up work).
*/
prs?: SessionPrInfo[];
}

/**
Expand Down Expand Up @@ -715,8 +720,16 @@ export interface BridgeSessionGoal {
} | null;
}

export interface SessionPrInfo {
number: number;
url: string;
}

export interface SessionMetadataUpdate {
displayName?: string;
pr?: SessionPrInfo;
/** Full binding list after the update (return value only; ignored on input). */
prs?: SessionPrInfo[];
}

export interface CloseSessionOpts {
Expand Down Expand Up @@ -1413,7 +1426,7 @@ export interface AcpSessionBridge {
): Promise<void>;

/**
* Update mutable session metadata. Currently supports `displayName` only.
* Update mutable session metadata. Supports `displayName` and `pr`.
* Throws `SessionNotFoundError` for unknown ids.
*/
updateSessionMetadata(
Expand Down
14 changes: 14 additions & 0 deletions packages/cli/src/serve/acp-http/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
WorkspaceMemoryFileTooLargeError,
WorkspaceMemoryWriteTimeoutError,
writeWorkspaceContextFile,
upsertSessionPr,
type SessionArchiveState,
type SubagentLevel,
IMAGE_CAPABILITY,
Expand Down Expand Up @@ -2908,6 +2909,19 @@ export class AcpDispatcher {
>[1],
this.sessionCtx(conn, sessionId, loopback),
);
// The bridge keeps the binding in live memory only; persist it
// as a sidecar so it survives daemon restarts, matching the
// REST metadata routes.
const latestPr = result?.prs?.[result.prs.length - 1];
if (latestPr) {
Comment thread
wenshao marked this conversation as resolved.
Resolved
const service = new SessionService(this.boundWorkspace, {
runtimeBaseDir: this.sessionRuntimeBaseDir,
});
await upsertSessionPr(
Comment thread
wenshao marked this conversation as resolved.
Resolved
service.getPrSessionPathForArchiveState(sessionId, 'active'),
Comment thread
wenshao marked this conversation as resolved.
Resolved
latestPr,
);
}
} finally {
this.invalidateSessionLists(['active']);
}
Expand Down
Loading
Loading