feat(web-shell): link channel event rows to their sessions (#16375)

* feat(web-shell): link channel event rows to their sessions

Operators investigating a bound-session delivery or a published reply had
to copy the session id out of the journal and find the session by hand.
Journal rows that name a session now offer an "Open session" button on the
Channel events page, reusing the existing in-app session navigation path.

GitHub task rows previously journaled no session at all, even though the
bound delivery target is known at every transition. They now record the
target session and, from the binding snapshot, its owner workspace, so
cross-workspace targets open correctly; outbound rows additionally stamp
the channel's own workspace. The new journal/SDK field is optional: older
records fall back to the journal's workspace, which is correct for the
channel's reply session, the only session kind older records name.

* docs: correct the locked-workspace claim in the session-link design

The open-session handler's locked-workspace guard only applies to
browser-notification clicks dispatched on a dedicated EventTarget;
window-dispatched in-page links (markdown session links, and the new
events-page button) are not subject to it. The design said the jump
"honors locked-workspace mode" and that locked shells "ignore
cross-workspace jumps" — both inaccurate. State the observed behavior:
in a locked shell the button behaves exactly like an existing markdown
session link.
This commit is contained in:
Shaojin Wen authored and GitHub committed 2026-11-05 18:09:28 +08:00
1 parent 5d5591ed91
commit 481bd948de
12 files changed
+500

No files matched your search

@@ -0,0 +1,111 @@
# Channel Events Session Link
[English](2026-11-05-channel-events-session-link.md) | [简体中文](2026-11-05-channel-events-session-link.zh-CN.md)
Status: implemented (2026-11-05)
## Problem
The Web Shell Channel events page shows what a Channel did (inbound,
outbound, task, poll), but a row that refers to a concrete session offers no
way to open that session. Operators investigating a bound-session delivery
(PR events injected into a PR-bound session, #16117) or a published reply
must copy the session id and find the session manually.
## Current state
- The journal record (`ChannelEventRecord` in
`packages/channels/base/src/channel-events.ts`) and the SDK shape
(`DaemonChannelEvent` in `packages/sdk-typescript/src/daemon/types.ts`)
already carry an optional `sessionId`, and the daemon route
`GET .../channels/events` passes records through unchanged.
- GitHub outbound (publication audit) rows already record the `sessionId`
of the Channel's own reply session.
- GitHub task rows do NOT record a session, even when the task delivers
into a PR-bound session (`task.delivery.sessionId` is known at every
transition).
- The page renders GitHub web links only; no session navigation exists.
- The app shell already has a session navigation path: the
`qwen:open-session` window event with `{ sessionId, workspaceCwd? }`
detail, used by markdown `qwen-session://` links and turn notifications.
## Goals
- Every journal row that names a session offers a one-click jump to it.
- Bound-session delivery task rows name the target session, including its
owner workspace, so cross-workspace targets open correctly.
## Non-goals
- No change to delivery, dedupe, or recovery semantics.
- No session links for rows without a session identity (inbound skips,
polls, public-path tasks before a reply session exists).
- No new daemon route; the journal remains the only data source.
## Solution
1. Journal: add an optional `sessionWorkspaceCwd` field to
`ChannelEventRecord` (normalized like other text fields, capped at 1024
characters) and to the SDK `DaemonChannelEvent`.
2. GitHub adapter:
- `claimInboundTask` (accepted) and `transitionInboundTask` (every later
state) attach `sessionId` and, when the binding snapshot knows it,
`sessionWorkspaceCwd` for bound-session deliveries, via one shared
`boundJournalIdentity` helper. The identity is read from the UPDATED
task record so retargets and gone-fallbacks journal the session the
transition actually refers to.
- `recordPublicationAudit` stamps `sessionWorkspaceCwd` with the
Channel's own workspace (`config.cwd`), which owns the reply session.
3. Web Shell `ChannelEventsPage`: rows with `sessionId` render an icon
button ("Open session" / "打开会话") that dispatches `qwen:open-session`
with `{ sessionId, workspaceCwd: event.sessionWorkspaceCwd ??
selectedWorkspaceCwd }`. The app shell closes the panel and loads the
session through the existing navigation path.
## Design decisions
- Workspace attribution rides on the record instead of a client-side
lookup: bound delivery is explicitly cross-workspace, and the daemon's
session load route resolves to the primary workspace when no workspace
is given, so a link without the owner would fail for secondary-workspace
targets. The binding snapshot (`PrBindingSummary.workspaceCwd`) is
already in the worker's memory at journal time; no extra I/O.
- Fallback for records without `sessionWorkspaceCwd` (older journals) is
the journal's own workspace: correct for the Channel's reply session,
the only session kind older records name.
- A frozen delivery can outlive its binding (restored task, gone session);
such rows keep `sessionId` without a workspace rather than guessing.
- The jump reuses `qwen:open-session` instead of a URL: it is the same
in-app path as markdown session links and needs no router changes. The
handler's locked-workspace guard applies to browser-notification clicks
(dispatched on a dedicated EventTarget), not to window-dispatched
in-page links, so in a locked-workspace shell the button behaves exactly
like an existing markdown session link.
## Constraints and risks
- Additive optional fields only; older daemons/clients interoperate
unchanged. Journal normalization drops invalid values.
- The workspace lookup is best-effort decoration; a miss degrades to the
fallback workspace, never breaks delivery.
- In locked-workspace shells the jump is not separately restricted: it
shares the window-event path of markdown session links, whose dispatches
bypass the notification-only locked-workspace guard.
## Validation
- Unit: `channel-events.test.ts` (round-trip and normalization of the new
field), `GithubAdapter.test.ts` (bound task rows carry the binding's
session and workspace; public-path task rows carry neither),
`ChannelEventsPage.test.tsx` (button renders only with a session; click
dispatches the navigation event with the record's workspace, falling
back to the selected workspace).
- Build and typecheck: `npm run build && npm run typecheck`.
## Acceptance criteria
- A bound-session delivery produces task rows whose "Open session" button
opens the target session, including when it lives in another workspace.
- An outbound row's button opens the Channel's reply session.
- Rows without a session render no button; existing GitHub links and the
visuals fixture are unchanged.
@@ -0,0 +1,94 @@
# 频道事件页的会话跳转链接
[English](2026-11-05-channel-events-session-link.md) | [简体中文](2026-11-05-channel-events-session-link.zh-CN.md)
状态:已实现(2026-11-05)
## 问题
Web Shell 的频道事件页展示频道做了什么(入站、出站、任务、轮询),但指向
具体会话的事件行没有提供打开该会话的入口。排查 bound-session 投递(PR 事件
注入绑定了该 PR 的会话,#16117)或已发表回复的操作者,必须手工复制会话 id
再去寻找会话。
## 现状
- 日志记录(`packages/channels/base/src/channel-events.ts` 的
`ChannelEventRecord`)与 SDK 形状(`packages/sdk-typescript/src/daemon/types.ts`
的 `DaemonChannelEvent`)已带可选 `sessionId`,daemon 路由
`GET .../channels/events` 原样透传记录。
- GitHub 出站(发表审计)行已记录频道自身回复会话的 `sessionId`。
- GitHub 任务行不记录会话,即使任务投递进了 PR 绑定会话(每次流转时
`task.delivery.sessionId` 都是已知的)。
- 页面只渲染 GitHub web 链接,没有会话导航。
- 应用壳已有会话导航路径:`qwen:open-session` window 事件,detail 为
`{ sessionId, workspaceCwd? }`,markdown 的 `qwen-session://` 链接与
turn 通知都在用。
## 目标
- 每条点名了会话的日志行都提供一键跳转。
- bound-session 投递的任务行点名目标会话及其所属工作区,跨工作区目标也能
正确打开。
## 非目标
- 不改投递、去重、恢复语义。
- 没有会话身份的行(入站跳过、轮询、回复会话尚不存在的公共路径任务)不加
会话链接。
- 不新增 daemon 路由;日志仍是唯一数据源。
## 方案
1. 日志:给 `ChannelEventRecord` 增加可选字段 `sessionWorkspaceCwd`(与其
他文本字段一样归一化,上限 1024 字符),并同步到 SDK 的
`DaemonChannelEvent`。
2. GitHub 适配器:
- `claimInboundTask`(accepted)与 `transitionInboundTask`(其后每个状
态)通过共用的 `boundJournalIdentity` 辅助方法,为 bound-session 投递
附上 `sessionId`,并在绑定快照知道时附上 `sessionWorkspaceCwd`。身份
取自更新后的任务记录,改绑与 gone 回退记录的才是该次流转真正指向的
会话。
- `recordPublicationAudit` 将 `sessionWorkspaceCwd` 写为频道自身工作区
(`config.cwd`)——回复会话就属于该工作区。
3. Web Shell `ChannelEventsPage`:带 `sessionId` 的行渲染图标按钮("Open
session" / "打开会话"),点击派发 `qwen:open-session`,detail 为
`{ sessionId, workspaceCwd: event.sessionWorkspaceCwd ??
selectedWorkspaceCwd }`。应用壳关闭面板并经现有导航路径加载会话。
## 设计决策
- 工作区归属随记录携带,而不是客户端反查:bound 投递明确支持跨工作区,而
daemon 的会话加载路由在不带工作区时解析到 primary 工作区,缺少归属的链
接对次级工作区目标会失败。绑定快照(`PrBindingSummary.workspaceCwd`)在
记录日志时已在 worker 内存中,不产生额外 I/O。
- 无 `sessionWorkspaceCwd` 的旧记录回退到日志自身的工作区:对频道回复会话
(旧记录唯一会点名的会话种类)是正确的。
- 冻结的投递可能比绑定活得更久(恢复的任务、gone 会话);这类行保留
`sessionId` 而不带工作区,不做猜测。
- 跳转复用 `qwen:open-session` 而不是 URL:与 markdown 会话链接同一条应用
内路径,无需改路由。处理器里的锁定工作区守卫只作用于浏览器通知点击(派
发到专用 EventTarget),不作用于 window 派发的页内链接,因此锁定工作区
的壳里该按钮与既有 markdown 会话链接行为完全一致。
## 约束与风险
- 仅新增可选字段;旧 daemon/客户端互操作不受影响。日志归一化会丢弃非法值。
- 工作区查询是尽力而为的装饰;未命中只回退工作区,绝不影响投递。
- 锁定工作区的壳不会对该跳转做额外限制:它与 markdown 会话链接共用 window
事件路径,而这类派发不受仅针对通知的锁定工作区守卫约束。
## 验证
- 单测:`channel-events.test.ts`(新字段的往返与归一化)、
`GithubAdapter.test.ts`(bound 任务行携带绑定的会话与工作区;公共路径任
务行两者皆无)、`ChannelEventsPage.test.tsx`(仅有会话时渲染按钮;点击
派发导航事件并带记录的工作区,缺失时回退所选工作区)。
- 构建与类型检查:`npm run build && npm run typecheck`。
## 验收标准
- bound-session 投递产生的任务行,其"打开会话"按钮能打开目标会话,目标在
另一工作区时亦然。
- 出站行的按钮能打开频道的回复会话。
- 无会话的行不渲染按钮;既有 GitHub 链接与 visuals 夹具不变。
+6
View File
@@ -609,6 +609,12 @@ management is available (`channel_management`), and each Channel keeps its
journal under `~/.qwen/channels/<workspace>/` with one rotated file, so it
never grows without bound.
Rows that refer to a concrete session carry an **Open session** button:
outbound rows open the Channel's own reply session, and GitHub task rows for
bound-session delivery open the PR-bound session that received the event —
including when that session lives in another registered workspace. Rows
without a session (polls, skipped inbound messages) show no button.
## Running
```bash
@@ -78,6 +78,37 @@ describe('channel events journal', async () => {
});
});
it('round-trips the session identity and its owner workspace', async () => {
appendChannelEvent('bot', cwd, {
type: 'task',
outcome: 'running',
sessionId: 'session-1',
sessionWorkspaceCwd: '/work/other',
});
const { events } = await readChannelEvents({
scopeDirs: [channelEventScopeDir(cwd)],
});
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({
sessionId: 'session-1',
sessionWorkspaceCwd: '/work/other',
});
});
it('drops a non-string session owner workspace', async () => {
const record = normalizeChannelEventRecord({
at: new Date().toISOString(),
channel: 'bot',
type: 'task',
outcome: 'running',
sessionId: 'session-1',
sessionWorkspaceCwd: 42,
});
expect(record).toMatchObject({ sessionId: 'session-1' });
expect(record).not.toHaveProperty('sessionWorkspaceCwd');
});
it('filters by channel and type and reports truncation', async () => {
append(1, 'bot');
append(2, 'other');
@@ -76,6 +76,13 @@ export interface ChannelEventRecord {
number?: number;
actor?: string;
sessionId?: string;
/**
* Workspace owning `sessionId`, when the writer knows it. Bound-session
* delivery can target a session in another workspace, and a client needs
* the owner to open it; absent on records written before this field
* existed, where clients fall back to the journal's own workspace.
*/
sessionWorkspaceCwd?: string;
messageId?: string;
durationMs?: number;
error?: string;
@@ -186,6 +193,8 @@ export function normalizeChannelEventRecord(
if (actor) record.actor = actor;
const sessionId = text(raw['sessionId'], 120);
if (sessionId) record.sessionId = sessionId;
const sessionWorkspaceCwd = text(raw['sessionWorkspaceCwd'], 1024);
if (sessionWorkspaceCwd) record.sessionWorkspaceCwd = sessionWorkspaceCwd;
const messageId = text(raw['messageId'], 120);
if (messageId) record.messageId = messageId;
const durationMs = finiteNumber(raw['durationMs']);
@@ -7741,6 +7741,72 @@ describe('GithubChannel bound-session delivery and PR state watch', () => {
expect(existsSync(inboundTaskPath())).toBe(false);
});
it('journals the bound target session and its workspace on task rows', async () => {
await initChannel({ deliverToBoundSessions: true }, { boundSessions });
// A workspace different from the channel's own proves the journal
// carries the BINDING's owner, not the channel cwd.
boundSessions.listPrBindings.mockResolvedValue({
bindings: [makeBinding({ workspaceCwd: '/work/bound' })],
partial: false,
});
mockOctokit.paginate
.mockResolvedValueOnce([prNotification()])
.mockResolvedValueOnce([makeComment()]);
await pollOnce();
const journalPath = inboundTaskPath().replace(
'github-inbound-tasks.json',
'channel-events.jsonl',
);
const events = readFileSync(journalPath, 'utf-8')
.split('\n')
.filter((line) => line.length > 0)
.map((line) => JSON.parse(line) as Record<string, unknown>);
const taskEvents = events.filter((event) => event['type'] === 'task');
expect(taskEvents.length).toBeGreaterThan(0);
for (const event of taskEvents) {
expect(event).toMatchObject({
sessionId: 'bound-session',
sessionWorkspaceCwd: '/work/bound',
});
}
// The acceptance and the delivered mark both name the target.
expect(taskEvents.map((event) => event['outcome'])).toEqual(
expect.arrayContaining(['accepted', 'running']),
);
});
it('keeps public-path task rows free of a session identity', async () => {
await initChannel({ deliverToBoundSessions: true }, { boundSessions });
// No binding matches this PR, and the complete snapshot proves
// absence, so the event takes the public channel path.
boundSessions.listPrBindings.mockResolvedValue({
bindings: [],
partial: false,
});
mockOctokit.paginate
.mockResolvedValueOnce([prNotification()])
.mockResolvedValueOnce([makeComment()]);
await pollOnce();
const journalPath = inboundTaskPath().replace(
'github-inbound-tasks.json',
'channel-events.jsonl',
);
const events = readFileSync(journalPath, 'utf-8')
.split('\n')
.filter((line) => line.length > 0)
.map((line) => JSON.parse(line) as Record<string, unknown>);
const taskEvents = events.filter((event) => event['type'] === 'task');
expect(taskEvents.length).toBeGreaterThan(0);
for (const event of taskEvents) {
expect(event).not.toHaveProperty('sessionId');
expect(event).not.toHaveProperty('sessionWorkspaceCwd');
}
});
it('strips the aggregate no-reply instruction for bound delivery', async () => {
await initChannel({ deliverToBoundSessions: true }, { boundSessions });
mockOctokit.paginate
@@ -3157,6 +3157,7 @@ export class GithubChannel extends PollingChannelBase<GithubCursor> {
number: task.issueNumber,
messageId: task.source.messageId,
actor: envelope.senderId,
...this.boundJournalIdentity(task.delivery),
});
return task;
}
@@ -3831,6 +3832,10 @@ export class GithubChannel extends PollingChannelBase<GithubCursor> {
thread: task.source.threadId,
number: task.issueNumber,
messageId: task.source.messageId,
// Read the delivery from the UPDATED record: a retarget or a gone
// fallback clears it mid-run, and the journal row must name the
// session the transition actually refers to.
...this.boundJournalIdentity(task.delivery),
...(updates.attempts !== undefined
? { detail: { attempts: updates.attempts } }
: {}),
@@ -3838,6 +3843,27 @@ export class GithubChannel extends PollingChannelBase<GithubCursor> {
});
}
/**
* Session identity for journal rows about a bound delivery, so the events
* page can link the row to the session that received the event. The
* binding snapshot is the only place the target's workspace is known; a
* frozen delivery on a restored task can outlive its binding, in which
* case the row keeps the sessionId without a workspace.
*/
private boundJournalIdentity(delivery: InboundTaskRecord['delivery']): {
sessionId?: string;
sessionWorkspaceCwd?: string;
} {
if (delivery?.kind !== 'bound-session') return {};
const workspaceCwd = this.lastBindings.find(
(binding) => binding.sessionId === delivery.sessionId,
)?.workspaceCwd;
return {
sessionId: delivery.sessionId,
...(workspaceCwd ? { sessionWorkspaceCwd: workspaceCwd } : {}),
};
}
private removeInboundTask(taskId: string): void {
this.updateInboundTasks((records) =>
records.filter((record) => record.id !== taskId),
@@ -3958,6 +3984,8 @@ export class GithubChannel extends PollingChannelBase<GithubCursor> {
...(record.threadId ? { thread: record.threadId } : {}),
...(record.number !== undefined ? { number: record.number } : {}),
sessionId: record.sessionId,
// The reply session is the Channel's own, created in its workspace.
sessionWorkspaceCwd: this.config.cwd,
...(record.sourceMessageId ? { messageId: record.sourceMessageId } : {}),
...(record.actor ? { actor: record.actor } : {}),
...(record.failureError ? { error: record.failureError } : {}),
@@ -5052,6 +5052,13 @@ export interface DaemonChannelEvent {
number?: number;
actor?: string;
sessionId?: string;
/**
* Workspace owning `sessionId`, when the Channel knew it at write time.
* Bound-session delivery can target a session in another workspace;
* clients need this to open it. Absent on older records, where the
* journal's own workspace is the fallback.
*/
sessionWorkspaceCwd?: string;
messageId?: string;
durationMs?: number;
error?: string;
@@ -200,6 +200,17 @@
color: var(--muted-foreground);
}
.sessionButton {
width: 24px;
height: 24px;
flex: 0 0 24px;
color: var(--muted-foreground);
}
.sessionButton:hover {
color: var(--foreground);
}
.error {
grid-column: 1 / -1;
color: var(--destructive);
@@ -550,6 +550,111 @@ describe('ChannelEventsPage', () => {
expect(event?.defaultPrevented).toBe(false);
});
it('opens the associated session in the workspace the event names', async () => {
eventsState.current.data = {
truncated: false,
events: [
{
at: '2026-11-02T02:00:00.000Z',
channel: 'gh-bot',
type: 'task',
outcome: 'running',
repository: 'owner/repo',
thread: 'pr:42',
number: 42,
sessionId: 'session-bound',
sessionWorkspaceCwd: '/work/other',
},
],
};
await renderPage();
const button = container.querySelector<HTMLButtonElement>(
'button[aria-label="Open session"]',
);
expect(button).toBeTruthy();
let detail: unknown;
const listener = (event: Event) => {
detail = (event as CustomEvent).detail;
};
window.addEventListener('qwen:open-session', listener);
try {
await act(async () => {
button!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
} finally {
window.removeEventListener('qwen:open-session', listener);
}
expect(detail).toEqual({
sessionId: 'session-bound',
workspaceCwd: '/work/other',
});
});
it('falls back to the selected workspace for records without an owner', async () => {
// Outbound rows written before sessionWorkspaceCwd existed carry only
// the sessionId; the reply session lives in the journal's workspace.
eventsState.current.data = {
truncated: false,
events: [
{
at: '2026-11-02T02:00:00.000Z',
channel: 'gh-bot',
type: 'outbound',
outcome: 'posted',
repository: 'owner/repo',
thread: 'pr:42',
number: 42,
sessionId: 'session-reply',
},
],
};
await renderPage();
const button = container.querySelector<HTMLButtonElement>(
'button[aria-label="Open session"]',
);
expect(button).toBeTruthy();
let detail: unknown;
const listener = (event: Event) => {
detail = (event as CustomEvent).detail;
};
window.addEventListener('qwen:open-session', listener);
try {
await act(async () => {
button!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
} finally {
window.removeEventListener('qwen:open-session', listener);
}
expect(detail).toEqual({
sessionId: 'session-reply',
workspaceCwd: '/workspace/demo',
});
});
it('does not offer a session jump for events without a session', async () => {
eventsState.current.data = {
truncated: false,
events: [
{
at: '2026-11-02T02:00:00.000Z',
channel: 'gh-bot',
type: 'inbound',
outcome: 'accepted',
repository: 'owner/repo',
thread: 'pr:42',
number: 42,
},
],
};
await renderPage();
expect(
container.querySelector('button[aria-label="Open session"]'),
).toBeNull();
});
it('shows how many further notifications a collapsed skip stands for', async () => {
eventsState.current.data = {
truncated: false,
@@ -9,6 +9,7 @@ import {
ActivityIcon,
AlertCircleIcon,
ArrowLeftIcon,
MessageSquareIcon,
RefreshCwIcon,
} from 'lucide-react';
import type { DaemonWorkspaceCapability } from '@qwen-code/sdk/daemon';
@@ -401,6 +402,35 @@ export function ChannelEventsPage({
})}
</span>
) : null}
{event.sessionId ? (
<Button
variant="ghost"
size="icon"
className={styles.sessionButton}
aria-label={t('channelEvents.openSession')}
title={t('channelEvents.openSession')}
onClick={() => {
// Same navigation path as markdown session
// links and turn notifications. Older
// records lack the owner workspace; the
// journal's own workspace is the fallback
// (correct for the Channel's own reply
// session).
window.dispatchEvent(
new CustomEvent('qwen:open-session', {
detail: {
sessionId: event.sessionId,
workspaceCwd:
event.sessionWorkspaceCwd ??
workspaceCwd,
},
}),
);
}}
>
<MessageSquareIcon />
</Button>
) : null}
</span>
{event.error ? (
<span className={styles.error}>{event.error}</span>
@@ -46,6 +46,7 @@ export const CHANNEL_EVENTS_MESSAGES_EN: Record<string, ChannelEventsMessage> =
'channelEvents.moreFiltered': (v) =>
`${v?.count ?? 0} more filtered this poll`,
'channelEvents.openTarget': (v) => `Open ${v?.target ?? ''} on GitHub`,
'channelEvents.openSession': 'Open session',
};
export const CHANNEL_EVENTS_MESSAGES_ZH: Record<string, ChannelEventsMessage> =
@@ -83,4 +84,5 @@ export const CHANNEL_EVENTS_MESSAGES_ZH: Record<string, ChannelEventsMessage> =
'channelEvents.notifications': (v) => `${v?.count ?? 0} 条通知`,
'channelEvents.moreFiltered': (v) => `本轮另有 ${v?.count ?? 0} 条被过滤`,
'channelEvents.openTarget': (v) => `在 GitHub 上打开 ${v?.target ?? ''}`,
'channelEvents.openSession': '打开会话',
};