diff --git a/docs/design/2026-11-05-channel-events-session-link.md b/docs/design/2026-11-05-channel-events-session-link.md new file mode 100644 index 00000000000..d649837b32d --- /dev/null +++ b/docs/design/2026-11-05-channel-events-session-link.md @@ -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. diff --git a/docs/design/2026-11-05-channel-events-session-link.zh-CN.md b/docs/design/2026-11-05-channel-events-session-link.zh-CN.md new file mode 100644 index 00000000000..b5d6a39fb4b --- /dev/null +++ b/docs/design/2026-11-05-channel-events-session-link.zh-CN.md @@ -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 夹具不变。 diff --git a/docs/users/features/channels/overview.md b/docs/users/features/channels/overview.md index c49a099225d..b0d67742ebc 100644 --- a/docs/users/features/channels/overview.md +++ b/docs/users/features/channels/overview.md @@ -609,6 +609,12 @@ management is available (`channel_management`), and each Channel keeps its journal under `~/.qwen/channels//` 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 diff --git a/packages/channels/base/src/channel-events.test.ts b/packages/channels/base/src/channel-events.test.ts index 51e4bbd51a3..ff05276f0b4 100644 --- a/packages/channels/base/src/channel-events.test.ts +++ b/packages/channels/base/src/channel-events.test.ts @@ -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'); diff --git a/packages/channels/base/src/channel-events.ts b/packages/channels/base/src/channel-events.ts index b2510cd2660..a55ceb3facb 100644 --- a/packages/channels/base/src/channel-events.ts +++ b/packages/channels/base/src/channel-events.ts @@ -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']); diff --git a/packages/channels/github/src/GithubAdapter.test.ts b/packages/channels/github/src/GithubAdapter.test.ts index e2a0e922d78..e45af2d32b2 100644 --- a/packages/channels/github/src/GithubAdapter.test.ts +++ b/packages/channels/github/src/GithubAdapter.test.ts @@ -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); + 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); + 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 diff --git a/packages/channels/github/src/GithubAdapter.ts b/packages/channels/github/src/GithubAdapter.ts index 5b253469cf9..292afa5da2b 100644 --- a/packages/channels/github/src/GithubAdapter.ts +++ b/packages/channels/github/src/GithubAdapter.ts @@ -3157,6 +3157,7 @@ export class GithubChannel extends PollingChannelBase { 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 { 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 { }); } + /** + * 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 { ...(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 } : {}), diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 8f65d435c13..e2a57fafffc 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -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; diff --git a/packages/web-shell/client/components/channels/ChannelEventsPage.module.css b/packages/web-shell/client/components/channels/ChannelEventsPage.module.css index ea5a6fd541d..b9578693232 100644 --- a/packages/web-shell/client/components/channels/ChannelEventsPage.module.css +++ b/packages/web-shell/client/components/channels/ChannelEventsPage.module.css @@ -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); diff --git a/packages/web-shell/client/components/channels/ChannelEventsPage.test.tsx b/packages/web-shell/client/components/channels/ChannelEventsPage.test.tsx index 6ff8ab0f4a3..0047b69325f 100644 --- a/packages/web-shell/client/components/channels/ChannelEventsPage.test.tsx +++ b/packages/web-shell/client/components/channels/ChannelEventsPage.test.tsx @@ -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( + '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( + '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, diff --git a/packages/web-shell/client/components/channels/ChannelEventsPage.tsx b/packages/web-shell/client/components/channels/ChannelEventsPage.tsx index fc9ef48b142..066e017ee4b 100644 --- a/packages/web-shell/client/components/channels/ChannelEventsPage.tsx +++ b/packages/web-shell/client/components/channels/ChannelEventsPage.tsx @@ -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({ })} ) : null} + {event.sessionId ? ( + + ) : null} {event.error ? ( {event.error} diff --git a/packages/web-shell/client/components/channels/channel-events-messages.ts b/packages/web-shell/client/components/channels/channel-events-messages.ts index d869915521d..261e3af08ca 100644 --- a/packages/web-shell/client/components/channels/channel-events-messages.ts +++ b/packages/web-shell/client/components/channels/channel-events-messages.ts @@ -46,6 +46,7 @@ export const CHANNEL_EVENTS_MESSAGES_EN: Record = '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 = @@ -83,4 +84,5 @@ export const CHANNEL_EVENTS_MESSAGES_ZH: Record = 'channelEvents.notifications': (v) => `${v?.count ?? 0} 条通知`, 'channelEvents.moreFiltered': (v) => `本轮另有 ${v?.count ?? 0} 条被过滤`, 'channelEvents.openTarget': (v) => `在 GitHub 上打开 ${v?.target ?? ''}`, + 'channelEvents.openSession': '打开会话', };