From e36c361c2d961e5183e22dce0a52cf4d6dacbc5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= Date: Mon, 3 Aug 2026 19:38:24 +0800 Subject: [PATCH 01/16] feat(web-shell): show channel sessions in sidebar --- ...08-03-web-shell-channel-session-sidebar.md | 40 ++++++++++++ .../components/sidebar/WebShellSidebar.tsx | 60 +++++++++++++----- ...WebShellSidebar.workspace-removal.test.tsx | 62 +++++++++++++++++++ .../components/sidebar/WorkspaceSection.tsx | 21 ++----- .../web-shell/client/e2e/utils/mockDaemon.ts | 12 +++- .../client/e2e/web-shell.channels.spec.ts | 48 ++++++++++++++ packages/web-shell/client/i18n.tsx | 6 ++ 7 files changed, 214 insertions(+), 35 deletions(-) create mode 100644 docs/design/2026-08-03-web-shell-channel-session-sidebar.md diff --git a/docs/design/2026-08-03-web-shell-channel-session-sidebar.md b/docs/design/2026-08-03-web-shell-channel-session-sidebar.md new file mode 100644 index 00000000000..0bbf0e498a0 --- /dev/null +++ b/docs/design/2026-08-03-web-shell-channel-session-sidebar.md @@ -0,0 +1,40 @@ +# Web Shell channel sessions in the sidebar + +## Motivation + +Daemon-managed channels create ordinary workspace sessions with +`sourceType: "channel"`, but the Web Shell sidebar intentionally requests only +the `default` session catalog. A session started from DingTalk, Feishu, or +another channel therefore cannot be opened from the sidebar even though it is +stored in the selected workspace. + +## Design + +Add a two-option source switch above the sidebar's project session list: + +- **Tasks** lists `sourceType: "default"` and remains the initial selection. +- **Channels** lists `sourceType: "channel"`. + +The switch is shown only when the daemon advertises +`session_source_metadata`. Older daemons keep the current unfiltered request +and do not show a control they cannot support. + +The selected source is applied consistently to active, pinned, archived, and +secondary-workspace session requests. Existing session rows, workspace +sections, grouping, search, polling, and open-session actions are reused. + +## Boundaries + +- Channel configuration and runtime management are unchanged. +- Session source metadata and daemon list APIs are unchanged. +- Session Overview and Split View keep their existing default-session scope. +- The switch is in-memory UI state and resets to Tasks on page reload. + +## Verification + +- Assert the source switch is gated by `session_source_metadata`. +- Assert Tasks is initially selected and requests `sourceType: "default"`. +- Assert selecting Channels requests `sourceType: "channel"` for primary and + workspace-qualified lists. +- Run the sidebar and workspace-section unit tests, Web Shell build, and + TypeScript typecheck. diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx index dadcda720f3..1169619ebb0 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx @@ -35,6 +35,8 @@ import { ChevronRightIcon, Columns2Icon, LayoutGridIcon, + ListTodoIcon, + MessageCircleIcon, EllipsisVerticalIcon, ArchiveIcon, ArchiveRestoreIcon, @@ -60,6 +62,7 @@ import { WebShellThemeId, type WebShellTheme } from '../../themeContext'; import { useI18n } from '../../i18n'; import { Input } from '../ui/input'; import { Button } from '../ui/button'; +import { Tabs, TabsList, TabsTrigger } from '../ui/tabs'; import { Field, FieldGroup, FieldLabel } from '../ui/field'; import { Select, @@ -90,7 +93,6 @@ import { import { SESSION_LIST_PAGE_SIZE, SESSION_ORGANIZATION_FEATURE, - WEB_SHELL_SESSION_SOURCE_TYPE, } from '../../constants/sessions'; import styles from './WebShellSidebar.module.css'; @@ -114,6 +116,8 @@ const GROUP_MENU_MARGIN = 8; const CUSTOM_GROUP_COLOR_OPTION = '__custom__'; const DEFAULT_CUSTOM_GROUP_COLOR: DaemonSessionGroupHexColor = '#416ef5'; +type SidebarSessionSource = 'default' | 'channel'; + function getSessionIdentity( sessionId: string, workspaceCwd: string | undefined, @@ -565,6 +569,11 @@ export function WebShellSidebar({ const sourceMetadataEnabled = Boolean( connection.capabilities?.features?.includes('session_source_metadata'), ); + const [sessionSource, setSessionSource] = + useState('default'); + const selectedSessionSource = sourceMetadataEnabled + ? sessionSource + : undefined; const sessionArchiveEnabled = Boolean( connection.capabilities?.features?.includes('session_archive'), ); @@ -606,9 +615,7 @@ export function WebShellSidebar({ enabled: includePrimaryWorkspaceSessions, pageSize: SESSION_LIST_PAGE_SIZE, archiveState: 'active', - ...(sourceMetadataEnabled - ? { sourceType: WEB_SHELL_SESSION_SOURCE_TYPE } - : {}), + ...(selectedSessionSource ? { sourceType: selectedSessionSource } : {}), ...(organizationEnabled ? { view: 'organized' as const, group: 'all' } : {}), @@ -629,9 +636,7 @@ export function WebShellSidebar({ enabled: organizationEnabled && includePrimaryWorkspaceSessions, pageSize: SESSION_LIST_PAGE_SIZE, archiveState: 'active', - ...(sourceMetadataEnabled - ? { sourceType: WEB_SHELL_SESSION_SOURCE_TYPE } - : {}), + ...(selectedSessionSource ? { sourceType: selectedSessionSource } : {}), view: 'organized', group: 'pinned', }); @@ -655,9 +660,7 @@ export function WebShellSidebar({ includePrimaryWorkspaceSessions, pageSize: SESSION_LIST_PAGE_SIZE, archiveState: 'archived', - ...(sourceMetadataEnabled - ? { sourceType: WEB_SHELL_SESSION_SOURCE_TYPE } - : {}), + ...(selectedSessionSource ? { sourceType: selectedSessionSource } : {}), ...(organizationEnabled ? { view: 'organized' as const, group: 'all' } : {}), @@ -1065,8 +1068,8 @@ export function WebShellSidebar({ .listWorkspaceSessions({ pageSize: SESSION_LIST_PAGE_SIZE, archiveState: 'active', - ...(sourceMetadataEnabled - ? { sourceType: WEB_SHELL_SESSION_SOURCE_TYPE } + ...(selectedSessionSource + ? { sourceType: selectedSessionSource } : {}), view: 'organized', group: 'pinned', @@ -1090,7 +1093,7 @@ export function WebShellSidebar({ }, [ displayedWorkspaces, organizationEnabled, - sourceMetadataEnabled, + selectedSessionSource, workspace.client, workspaceSessionsReloadToken, ]); @@ -1142,8 +1145,8 @@ export function WebShellSidebar({ .listWorkspaceSessions({ pageSize: SESSION_LIST_PAGE_SIZE, archiveState: 'archived', - ...(sourceMetadataEnabled - ? { sourceType: WEB_SHELL_SESSION_SOURCE_TYPE } + ...(selectedSessionSource + ? { sourceType: selectedSessionSource } : {}), ...(organizationEnabled ? { view: 'organized' as const, group: 'all' } @@ -1185,7 +1188,7 @@ export function WebShellSidebar({ organizationEnabled, secondaryArchivedReloadToken, sessionArchiveEnabled, - sourceMetadataEnabled, + selectedSessionSource, workspace.client, workspaceQualifiedRestCoreEnabled, workspaceSessionsReloadToken, @@ -4073,6 +4076,29 @@ export function WebShellSidebar({
+ {!collapsed && sourceMetadataEnabled && ( + + setSessionSource(value as SidebarSessionSource) + } + > + + + + {t('sidebar.sessionSource.tasks')} + + + + {t('sidebar.sessionSource.channels')} + + + + )} {!collapsed && pinnedSessions.length > 0 && ( <>
@@ -4182,7 +4208,7 @@ export function WebShellSidebar({ noSessionsLabel={t('sidebar.noSessions')} loadErrorLabel={t('sidebar.loadFailed')} organizationEnabled={organizationEnabled} - sourceMetadataEnabled={sourceMetadataEnabled} + sourceType={selectedSessionSource} ungroupedLabel={t('sidebar.groupUngrouped')} onRenameGroup={ canOrganizeWorkspace(ws.cwd) diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.workspace-removal.test.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.workspace-removal.test.tsx index 429c77e3306..7fdfbb13100 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.workspace-removal.test.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.workspace-removal.test.tsx @@ -2890,6 +2890,68 @@ describe('WebShellSidebar primary workspace header', () => { }); }); +describe('WebShellSidebar session source switch', () => { + it('switches primary and workspace-qualified lists from tasks to channels', async () => { + renderSidebar(); + + const sourceTabs = Array.from( + container.querySelectorAll('[role="tab"]'), + ); + const tasksTab = sourceTabs.find( + (button) => button.textContent?.trim() === 'Tasks', + ); + const channelsTab = sourceTabs.find( + (button) => button.textContent?.trim() === 'Channels', + ); + expect(tasksTab?.getAttribute('data-state')).toBe('active'); + expect(channelsTab).toBeDefined(); + expect( + useSessions.mock.calls.some( + ([options]) => options?.sourceType === 'default', + ), + ).toBe(true); + + await act(async () => { + channelsTab!.dispatchEvent( + new MouseEvent('mousedown', { bubbles: true, button: 0 }), + ); + channelsTab!.click(); + await Promise.resolve(); + }); + + expect(channelsTab?.getAttribute('data-state')).toBe('active'); + expect( + useSessions.mock.calls.some( + ([options]) => options?.sourceType === 'channel', + ), + ).toBe(true); + + await expandWorkspace('other'); + expect( + listWorkspaceSessions.mock.calls.some( + ([options]) => options?.sourceType === 'channel', + ), + ).toBe(true); + }); + + it('hides the switch and keeps legacy session requests unfiltered', () => { + connection.capabilities = { + ...capabilities, + features: capabilities.features.filter( + (feature) => feature !== 'session_source_metadata', + ), + }; + renderSidebar(); + + expect(container.querySelector('[aria-label="Session source"]')).toBeNull(); + expect( + useSessions.mock.calls.every( + ([options]) => options?.sourceType === undefined, + ), + ).toBe(true); + }); +}); + describe('WebShellSidebar archived session export', () => { const exportResult = { content: '

exported

', diff --git a/packages/web-shell/client/components/sidebar/WorkspaceSection.tsx b/packages/web-shell/client/components/sidebar/WorkspaceSection.tsx index cf78062db78..172a4064345 100644 --- a/packages/web-shell/client/components/sidebar/WorkspaceSection.tsx +++ b/packages/web-shell/client/components/sidebar/WorkspaceSection.tsx @@ -17,10 +17,7 @@ import { FolderClosedIcon, FolderOpenIcon } from 'lucide-react'; import { GitBranchIndicator } from '../GitBranchIndicator'; import { BranchPickerPopover } from '../BranchPickerPopover'; import { useI18n } from '../../i18n'; -import { - SESSION_LIST_PAGE_SIZE, - WEB_SHELL_SESSION_SOURCE_TYPE, -} from '../../constants/sessions'; +import { SESSION_LIST_PAGE_SIZE } from '../../constants/sessions'; import { readWorkspaceCollapsedGroupIds, writeWorkspaceCollapsedGroupIds, @@ -71,7 +68,7 @@ interface WorkspaceSectionProps { noSessionsLabel: string; loadErrorLabel: string; organizationEnabled: boolean; - sourceMetadataEnabled?: boolean; + sourceType?: string; ungroupedLabel: string; formatTime: (iso: string) => string; searchQuery?: string; @@ -113,7 +110,7 @@ export function WorkspaceSection({ noSessionsLabel, loadErrorLabel, organizationEnabled, - sourceMetadataEnabled = false, + sourceType, ungroupedLabel, formatTime, searchQuery = '', @@ -172,9 +169,7 @@ export function WorkspaceSection({ .listWorkspaceSessions({ pageSize: SESSION_LIST_PAGE_SIZE, archiveState: 'active', - ...(sourceMetadataEnabled - ? { sourceType: WEB_SHELL_SESSION_SOURCE_TYPE } - : {}), + ...(sourceType ? { sourceType } : {}), ...(organizationEnabled ? { view: 'organized' as const, group: 'all' } : {}), @@ -187,13 +182,7 @@ export function WorkspaceSection({ console.warn('[WorkspaceSection] session poll failed:', err); setLoadError(true); } - }, [ - client, - disabled, - organizationEnabled, - sourceMetadataEnabled, - workspace.cwd, - ]); + }, [client, disabled, organizationEnabled, sourceType, workspace.cwd]); useEffect(() => { if (!renderSessions || disabled || !organizationEnabled) { diff --git a/packages/web-shell/client/e2e/utils/mockDaemon.ts b/packages/web-shell/client/e2e/utils/mockDaemon.ts index 7f63408ad4c..12b4a713215 100644 --- a/packages/web-shell/client/e2e/utils/mockDaemon.ts +++ b/packages/web-shell/client/e2e/utils/mockDaemon.ts @@ -823,10 +823,18 @@ async function handleDaemonRoute( // `group=all` (and missing group) returns the full active list. The UI // excludes pinned rows from organized sections via `excludePinned`. const group = searchParams.get('group'); + const sourceType = searchParams.get('sourceType'); + const sourceSessions = sourceType + ? scenario.sessions.filter( + (session) => + session.sourceType === sourceType || + (sourceType === 'default' && session.sourceType === undefined), + ) + : scenario.sessions; const sessions = group === 'pinned' - ? scenario.sessions.filter((session) => Boolean(session.isPinned)) - : scenario.sessions; + ? sourceSessions.filter((session) => Boolean(session.isPinned)) + : sourceSessions; await json(route, { sessions }); return; } diff --git a/packages/web-shell/client/e2e/web-shell.channels.spec.ts b/packages/web-shell/client/e2e/web-shell.channels.spec.ts index 07df572ee23..147d058e357 100644 --- a/packages/web-shell/client/e2e/web-shell.channels.spec.ts +++ b/packages/web-shell/client/e2e/web-shell.channels.spec.ts @@ -11,6 +11,54 @@ import { replayCompleteEvent, } from './utils/mockDaemon'; +test('shows channel sessions in the sidebar channel catalog', async ({ + page, +}, testInfo) => { + const scenario = createWebShellDaemonScenario({ + capabilities: { + features: [ + 'session_events', + 'permission_vote', + 'session_permission_vote', + 'session_scope_override', + 'session_source_metadata', + ], + }, + sessions: [ + { + sessionId: 'task-session', + displayName: 'Web Shell task', + sourceType: 'default', + }, + { + sessionId: 'dingtalk-session', + displayName: 'DingTalk conversation', + sourceType: 'channel', + sourceId: 'release-bot', + }, + ], + }); + const daemon = await installMockDaemon(page, scenario, { + baseURL: String(testInfo.project.use.baseURL), + }); + + await page.goto(`/session/${encodeURIComponent(scenario.sessionId)}`); + await expect(page.locator('[data-web-shell-root]')).toBeVisible(); + const connection = await daemon.sse.waitForConnection(scenario.sessionId); + await daemon.sendEvent( + replayCompleteEvent({ sessionId: connection.sessionId }), + ); + await expect(page.getByText('Loading...')).toHaveCount(0); + + await expect(page.getByText('Web Shell task', { exact: true })).toBeVisible(); + await expect(page.getByText('DingTalk conversation')).toHaveCount(0); + await page.getByRole('tab', { name: 'Channels' }).click(); + await expect( + page.getByText('DingTalk conversation', { exact: true }), + ).toBeVisible(); + await expect(page.getByText('Web Shell task')).toHaveCount(0); +}); + test('creates and deletes a typed Channel configuration', async ({ page, }, testInfo) => { diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 825dfaf179c..8629ee019ef 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -1072,6 +1072,9 @@ const EN: Messages = { 'sidebar.newWorktreeTask': 'New worktree task', 'sidebar.plugins': 'Plugins', 'sidebar.channels': 'Channels', + 'sidebar.sessionSource': 'Session source', + 'sidebar.sessionSource.tasks': 'Tasks', + 'sidebar.sessionSource.channels': 'Channels', 'sidebar.project': 'Project', 'sidebar.pinnedSessions': 'Pinned', 'sidebar.workspaceSelectLabel': 'Workspace', @@ -3694,6 +3697,9 @@ const ZH: Messages = { 'sidebar.newWorktreeTask': '新建 Worktree 任务', 'sidebar.plugins': '插件', 'sidebar.channels': '频道', + 'sidebar.sessionSource': '会话来源', + 'sidebar.sessionSource.tasks': '任务', + 'sidebar.sessionSource.channels': '频道', 'sidebar.project': '项目', 'sidebar.pinnedSessions': '置顶', 'sidebar.workspaceSelectLabel': '工作区', From b71723b5ab7ce76b3746b906c23597f42b8921c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= Date: Mon, 3 Aug 2026 22:48:19 +0800 Subject: [PATCH 02/16] feat(web-shell): configure channel session scope --- ...6-08-03-web-shell-channel-session-scope.md | 40 +++++++++++++ .../commands/channel/channel-registry.test.ts | 21 +++++++ .../src/commands/channel/channel-registry.ts | 38 +++++++++++-- .../src/serve/channel-settings-store.test.ts | 22 +++++++ .../cli/src/serve/channel-settings-store.ts | 2 +- .../channels/ChannelEditorDialog.test.tsx | 22 +++++++ .../channels/ChannelEditorDialog.tsx | 57 ++++++++++++++++--- .../channels/channel-editor-state.test.ts | 23 ++++++++ .../channels/channel-editor-state.ts | 4 +- .../client/e2e/web-shell.channels.spec.ts | 17 ++++++ packages/web-shell/client/i18n.tsx | 17 ++++++ 11 files changed, 247 insertions(+), 16 deletions(-) create mode 100644 docs/design/2026-08-03-web-shell-channel-session-scope.md diff --git a/docs/design/2026-08-03-web-shell-channel-session-scope.md b/docs/design/2026-08-03-web-shell-channel-session-scope.md new file mode 100644 index 00000000000..e3f5b7642dd --- /dev/null +++ b/docs/design/2026-08-03-web-shell-channel-session-scope.md @@ -0,0 +1,40 @@ +# Web Shell channel session scope + +## Motivation + +Channel runtimes already route incoming messages according to `sessionScope`, +but the Web Shell channel editor only renders platform-specific management +fields. Users can therefore configure credentials and access policy, but cannot +choose which conversations share an agent session. + +## Design + +Add the existing shared `sessionScope` setting to every manageable channel type +in the daemon channel catalog. Preserve a plugin-provided field when one exists; +otherwise advertise an enum with the runtime-supported values and the plugin's +default scope. + +Render the field in a dedicated session section of the channel editor. New and +legacy configurations show their effective default, and saving writes the +selected value through the existing channel upsert request. + +The available scopes are: + +- `user`: one session per sender and chat. +- `thread`: one session per routing thread, falling back to the chat. +- `chat_thread`: one session per chat and nested thread. +- `single`: one session shared by the entire channel instance. + +## Compatibility + +The runtime router and persisted configuration format are unchanged. Existing +configurations without `sessionScope` retain their current plugin default until +they are edited and saved. Unmanageable channel types do not advertise the +field. + +## Verification + +- Assert catalog defaults for DingTalk and GitHub. +- Assert the management store accepts every runtime-supported scope. +- Assert new and legacy editor drafts use the effective default. +- Verify the Web Shell can select and persist a non-default scope end to end. diff --git a/packages/cli/src/commands/channel/channel-registry.test.ts b/packages/cli/src/commands/channel/channel-registry.test.ts index 5b68d249a8e..7ddfc2e91ed 100644 --- a/packages/cli/src/commands/channel/channel-registry.test.ts +++ b/packages/cli/src/commands/channel/channel-registry.test.ts @@ -26,6 +26,16 @@ describe('channel registry', () => { required: true, }), ); + expect( + catalog.find((entry) => entry.type === 'dingtalk')?.fields, + ).toContainEqual( + expect.objectContaining({ + key: 'sessionScope', + kind: 'enum', + required: true, + default: 'user', + }), + ); for (const type of ['github', 'gitlab'] as const) { const fields = catalog.find((entry) => entry.type === type)?.fields; expect(fields).toContainEqual( @@ -56,6 +66,17 @@ describe('channel registry', () => { }), ); } + expect( + catalog.find((entry) => entry.type === 'github')?.fields, + ).toContainEqual( + expect.objectContaining({ + key: 'sessionScope', + default: 'chat_thread', + }), + ); + expect( + catalog.find((entry) => entry.type === 'telegram')?.fields, + ).not.toContainEqual(expect.objectContaining({ key: 'sessionScope' })); expect(JSON.stringify(catalog)).not.toContain('createChannel'); }); }); diff --git a/packages/cli/src/commands/channel/channel-registry.ts b/packages/cli/src/commands/channel/channel-registry.ts index 090b5540334..e26c36ddd44 100644 --- a/packages/cli/src/commands/channel/channel-registry.ts +++ b/packages/cli/src/commands/channel/channel-registry.ts @@ -1,6 +1,7 @@ import type { ChannelConfigFieldDescriptor, ChannelPlugin, + SessionScope, } from '@qwen-code/channel-base'; export interface ChannelTypeDescriptor { @@ -13,6 +14,16 @@ export interface ChannelTypeDescriptor { const registry = new Map(); let builtinsPromise: Promise | null = null; +const SESSION_SCOPE_OPTIONS: ReadonlyArray<{ + value: SessionScope; + label: string; +}> = [ + { value: 'user', label: 'Per user and chat' }, + { value: 'thread', label: 'Per thread' }, + { value: 'chat_thread', label: 'Per chat and thread' }, + { value: 'single', label: 'One shared session' }, +]; + function ensureBuiltins(): Promise { if (!builtinsPromise) { builtinsPromise = (async () => { @@ -69,12 +80,29 @@ export async function supportedChannelCatalog(): Promise< ChannelTypeDescriptor[] > { await ensureBuiltins(); - return [...registry.values()].map( - ({ channelType, displayName, management }) => ({ + return [...registry.values()].map((plugin) => { + const { channelType, displayName, management } = plugin; + const fields = management?.fields ?? []; + return { type: channelType, displayName, manageable: management !== undefined, - fields: management?.fields ?? [], - }), - ); + fields: + management && !fields.some((field) => field.key === 'sessionScope') + ? [ + ...fields, + { + key: 'sessionScope', + label: 'Session scope', + kind: 'enum', + required: true, + default: plugin.defaultSessionScope ?? 'user', + description: + 'Controls which incoming conversations share one agent session.', + options: SESSION_SCOPE_OPTIONS, + }, + ] + : fields, + }; + }); } diff --git a/packages/cli/src/serve/channel-settings-store.test.ts b/packages/cli/src/serve/channel-settings-store.test.ts index 07ada91ee78..e647045fbfd 100644 --- a/packages/cli/src/serve/channel-settings-store.test.ts +++ b/packages/cli/src/serve/channel-settings-store.test.ts @@ -169,6 +169,28 @@ describe('WorkspaceChannelSettingsStore', () => { ).toBe('$BOT_TOKEN'); }); + it('accepts chat-and-thread session scope', async () => { + const store = new WorkspaceChannelSettingsStore(workspace); + + await store.upsert('bot', { + expectedRevision: store.snapshot().revision, + config: { + type: 'management-validation-test', + clientId: 'client-id', + sessionScope: 'chat_thread', + }, + }); + + expect( + ( + readWorkspaceSettings()['channels'] as Record< + string, + Record + > + )['bot']?.['sessionScope'], + ).toBe('chat_thread'); + }); + it('replaces and clears secrets only through explicit operations', async () => { writeWorkspaceSettings(`{ "$version": 4, diff --git a/packages/cli/src/serve/channel-settings-store.ts b/packages/cli/src/serve/channel-settings-store.ts index d4458fc302b..70a6168fd60 100644 --- a/packages/cli/src/serve/channel-settings-store.ts +++ b/packages/cli/src/serve/channel-settings-store.ts @@ -143,7 +143,7 @@ function assertSharedField(key: string, value: unknown): boolean { senderPolicy: new Set(['allowlist', 'pairing', 'open']), dmPolicy: new Set(['open', 'disabled']), groupPolicy: new Set(['disabled', 'allowlist', 'open']), - sessionScope: new Set(['user', 'thread', 'single']), + sessionScope: new Set(['user', 'thread', 'chat_thread', 'single']), dispatchMode: new Set(['steer', 'followup', 'collect']), blockStreaming: new Set(['on', 'off']), }; diff --git a/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx b/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx index 07054c667c2..7069ef000e2 100644 --- a/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx +++ b/packages/web-shell/client/components/channels/ChannelEditorDialog.test.tsx @@ -33,6 +33,19 @@ const DINGTALK: DaemonChannelTypeDescriptor = { kind: 'secret', required: true, }, + { + key: 'sessionScope', + label: 'Session scope', + kind: 'enum', + required: true, + default: 'user', + options: [ + { value: 'user', label: 'Per user and chat' }, + { value: 'thread', label: 'Per thread' }, + { value: 'chat_thread', label: 'Per chat and thread' }, + { value: 'single', label: 'One shared session' }, + ], + }, ], }; @@ -152,6 +165,14 @@ describe('ChannelEditorDialog', () => { expect(clear).toBeDefined(); }); + it('shows the effective session scope in its own section', async () => { + await renderDialog({ instance: INSTANCE }); + + expect(document.body.textContent).toContain('Session'); + expect(document.body.textContent).toContain('Session scope'); + expect(document.body.textContent).toContain('Per user and chat'); + }); + it('submits a new instance with typed fields and the current revision', async () => { const onSave = vi.fn().mockResolvedValue(undefined); await renderDialog({ onSave }); @@ -181,6 +202,7 @@ describe('ChannelEditorDialog', () => { config: { type: 'dingtalk', clientId: 'ding-client-id', + sessionScope: 'user', senderPolicy: 'pairing', }, secrets: { diff --git a/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx b/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx index 75326c5dfdf..804b87abf52 100644 --- a/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx +++ b/packages/web-shell/client/components/channels/ChannelEditorDialog.tsx @@ -91,6 +91,10 @@ const FIELD_LABEL_KEYS: Record> = { }, }; +const COMMON_FIELD_LABEL_KEYS: Record = { + sessionScope: 'channels.editor.field.sessionScope', +}; + export interface ChannelEditorDialogProps { open: boolean; descriptor: DaemonChannelTypeDescriptor; @@ -204,13 +208,17 @@ export function ChannelEditorDialog({ setSubmitError(undefined); }, [descriptor, instance, open]); + const fieldLabelKey = (field: DaemonChannelConfigFieldDescriptor) => + FIELD_LABEL_KEYS[descriptor.type]?.[field.key] ?? + COMMON_FIELD_LABEL_KEYS[field.key]; + const fieldLabel = (field: DaemonChannelConfigFieldDescriptor) => { - const key = FIELD_LABEL_KEYS[descriptor.type]?.[field.key]; + const key = fieldLabelKey(field); return key ? t(key) : field.label; }; const fieldDescription = (field: DaemonChannelConfigFieldDescriptor) => { - const labelKey = FIELD_LABEL_KEYS[descriptor.type]?.[field.key]; + const labelKey = fieldLabelKey(field); if (labelKey) { const descKey = `${labelKey}.description`; const translated = t(descKey); @@ -219,6 +227,19 @@ export function ChannelEditorDialog({ return field.description; }; + const fieldOptionLabel = ( + field: DaemonChannelConfigFieldDescriptor, + option: { value: string; label: string }, + ) => { + const labelKey = fieldLabelKey(field); + if (labelKey) { + const optionKey = `${labelKey}.option.${option.value}`; + const translated = t(optionKey); + if (translated !== optionKey) return translated; + } + return option.label; + }; + const validationMessage = ( field: DaemonChannelConfigFieldDescriptor | undefined, code: ChannelEditorValidationCode, @@ -432,7 +453,7 @@ export function ChannelEditorDialog({ {field.options?.map((option) => ( - {option.label} + {fieldOptionLabel(field, option)} ))} @@ -519,6 +540,13 @@ export function ChannelEditorDialog({ ); }; + const sessionScopeField = descriptor.fields.find( + (field) => field.key === 'sessionScope', + ); + const platformFields = descriptor.fields.filter( + (field) => field.key !== 'sessionScope', + ); + return ( @@ -596,12 +624,23 @@ export function ChannelEditorDialog({ -
-

- {t('channels.editor.section.credentials')} -

- {descriptor.fields.map(renderField)} -
+ {platformFields.length > 0 ? ( +
+

+ {t('channels.editor.section.credentials')} +

+ {platformFields.map(renderField)} +
+ ) : null} + + {sessionScopeField ? ( +
+

+ {t('channels.editor.section.session')} +

+ {renderField(sessionScopeField)} +
+ ) : null} {(() => { const descriptorPolicy = hasDescriptorSenderPolicy(descriptor); diff --git a/packages/web-shell/client/components/channels/channel-editor-state.test.ts b/packages/web-shell/client/components/channels/channel-editor-state.test.ts index fc15e9540ff..ae0dfd9c8fd 100644 --- a/packages/web-shell/client/components/channels/channel-editor-state.test.ts +++ b/packages/web-shell/client/components/channels/channel-editor-state.test.ts @@ -34,6 +34,19 @@ const DINGTALK: DaemonChannelTypeDescriptor = { required: true, envResolvable: true, }, + { + key: 'sessionScope', + label: 'Session scope', + kind: 'enum', + required: true, + default: 'user', + options: [ + { value: 'user', label: 'Per user and chat' }, + { value: 'thread', label: 'Per thread' }, + { value: 'chat_thread', label: 'Per chat and thread' }, + { value: 'single', label: 'One shared session' }, + ], + }, ], }; @@ -70,6 +83,7 @@ describe('Channel editor state', () => { config: { type: 'dingtalk', clientId: 'ding-client-id', + sessionScope: 'user', senderPolicy: 'pairing', }, secrets: { @@ -103,6 +117,15 @@ describe('Channel editor state', () => { }); }); + it('shows the effective scope default for a legacy instance', () => { + const instance = configuredInstance(); + delete instance.config.sessionScope; + + const draft = createChannelEditorDraft(DINGTALK, instance); + + expect(draft.values.sessionScope).toBe('user'); + }); + it('supports explicitly clearing a stored secret', () => { const instance = configuredInstance(); const draft = createChannelEditorDraft(DINGTALK, instance); diff --git a/packages/web-shell/client/components/channels/channel-editor-state.ts b/packages/web-shell/client/components/channels/channel-editor-state.ts index 70c9b484f2c..7cd84bf2995 100644 --- a/packages/web-shell/client/components/channels/channel-editor-state.ts +++ b/packages/web-shell/client/components/channels/channel-editor-state.ts @@ -71,7 +71,9 @@ function initialFieldValue( } if (field.kind === 'enum') { if (typeof value === 'string' && value) return value; - return instance ? '' : (field.default ?? field.options?.[0]?.value ?? ''); + return instance && field.key !== 'sessionScope' + ? '' + : (field.default ?? field.options?.[0]?.value ?? ''); } return typeof value === 'string' ? value : ''; } diff --git a/packages/web-shell/client/e2e/web-shell.channels.spec.ts b/packages/web-shell/client/e2e/web-shell.channels.spec.ts index 147d058e357..337dc730c0c 100644 --- a/packages/web-shell/client/e2e/web-shell.channels.spec.ts +++ b/packages/web-shell/client/e2e/web-shell.channels.spec.ts @@ -95,6 +95,19 @@ test('creates and deletes a typed Channel configuration', async ({ required: true, envResolvable: true, }, + { + key: 'sessionScope', + label: 'Session scope', + kind: 'enum', + required: true, + default: 'user', + options: [ + { value: 'user', label: 'Per user and chat' }, + { value: 'thread', label: 'Per thread' }, + { value: 'chat_thread', label: 'Per chat and thread' }, + { value: 'single', label: 'One shared session' }, + ], + }, ], }, { @@ -144,6 +157,8 @@ test('creates and deletes a typed Channel configuration', async ({ await page.getByLabel('Instance name').fill('release-bot'); await page.getByLabel('Client ID (AppKey)').fill('ding-client-id'); await page.getByLabel('Client Secret (AppSecret)').fill('ding-client-secret'); + await page.getByLabel('Session scope').click(); + await page.getByRole('option', { name: 'Per thread' }).click(); await page.getByRole('button', { name: 'Save' }).click(); await expect( @@ -165,6 +180,7 @@ test('creates and deletes a typed Channel configuration', async ({ config: { type: 'dingtalk', clientId: 'ding-client-id', + sessionScope: 'thread', senderPolicy: 'pairing', }, secrets: { @@ -181,6 +197,7 @@ test('creates and deletes a typed Channel configuration', async ({ await expect( page.getByRole('heading', { name: 'Edit DingTalk' }), ).toBeVisible(); + await expect(page.getByLabel('Session scope')).toHaveText('Per thread'); await expect(page.getByText('Ada', { exact: true })).toBeVisible(); await expect(page.getByText('ABCD1234', { exact: true })).toBeVisible(); await page diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 8629ee019ef..9f2550e60e6 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -2439,10 +2439,19 @@ const EN: Messages = { 'Update public settings or explicitly change stored credentials.', 'channels.editor.section.identity': 'Identity', 'channels.editor.section.credentials': 'Credentials', + 'channels.editor.section.session': 'Session', 'channels.editor.section.access': 'Access policy', 'channels.editor.instanceName': 'Instance name', 'channels.editor.instanceNamePlaceholder': 'e.g. release-bot', 'channels.editor.environmentReference': '$ENV_VAR supported', + 'channels.editor.field.sessionScope': 'Session scope', + 'channels.editor.field.sessionScope.description': + 'Controls which incoming conversations share one agent session.', + 'channels.editor.field.sessionScope.option.user': 'Per user and chat', + 'channels.editor.field.sessionScope.option.thread': 'Per thread', + 'channels.editor.field.sessionScope.option.chat_thread': + 'Per chat and thread', + 'channels.editor.field.sessionScope.option.single': 'One shared session', 'channels.editor.field.dingtalk.clientId': 'Client ID (AppKey)', 'channels.editor.field.dingtalk.clientSecret': 'Client Secret (AppSecret)', 'channels.editor.field.wecom.botId': 'Bot ID', @@ -4962,10 +4971,18 @@ const ZH: Messages = { 'channels.editor.editDescription': '更新公开配置,或明确更改已保存的凭据。', 'channels.editor.section.identity': '频道标识', 'channels.editor.section.credentials': '应用凭据', + 'channels.editor.section.session': '会话', 'channels.editor.section.access': '准入策略', 'channels.editor.instanceName': '实例名称', 'channels.editor.instanceNamePlaceholder': '例如 release-bot', 'channels.editor.environmentReference': '支持 $ENV_VAR', + 'channels.editor.field.sessionScope': '会话作用域', + 'channels.editor.field.sessionScope.description': + '控制哪些来源的消息共享同一个 Agent 会话。', + 'channels.editor.field.sessionScope.option.user': '按用户和对话', + 'channels.editor.field.sessionScope.option.thread': '按话题', + 'channels.editor.field.sessionScope.option.chat_thread': '按对话和话题', + 'channels.editor.field.sessionScope.option.single': '整个频道共享', 'channels.editor.field.dingtalk.clientId': 'Client ID(原 AppKey)', 'channels.editor.field.dingtalk.clientSecret': 'Client Secret(原 AppSecret)', From c587b583ad765eb13b428dff27ef6b1fb5e07b34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= Date: Mon, 3 Aug 2026 23:18:57 +0800 Subject: [PATCH 03/16] fix(web-shell): refresh channel session transcripts --- ...08-03-web-shell-channel-session-sidebar.md | 12 ++++ packages/acp-bridge/src/bridge.test.ts | 64 +++++++++++++++++++ packages/acp-bridge/src/bridge.ts | 32 ++++++++-- packages/acp-bridge/src/bridgeTypes.ts | 2 + .../acp-bridge/src/transcript-replay.test.ts | 33 ++++++++++ packages/acp-bridge/src/transcript-replay.ts | 2 +- .../channels/base/src/ChannelAgentBridge.ts | 9 ++- .../channels/base/src/ChannelBase.test.ts | 20 +++++- packages/channels/base/src/ChannelBase.ts | 1 + .../base/src/DaemonChannelBridge.test.ts | 33 ++++++++++ .../channels/base/src/DaemonChannelBridge.ts | 19 +++++- .../acp-integration/session/Session.test.ts | 20 ++++++ .../src/acp-integration/session/Session.ts | 17 ++++- .../src/ui/utils/resumeHistoryUtils.test.ts | 9 +++ .../cli/src/ui/utils/resumeHistoryUtils.ts | 2 +- .../core/src/services/sessionService.test.ts | 24 +++++++ packages/core/src/services/sessionService.ts | 17 ++++- .../components/sidebar/WebShellSidebar.tsx | 10 ++- ...WebShellSidebar.workspace-removal.test.tsx | 32 ++++++++++ .../client/e2e/web-shell.channels.spec.ts | 10 +++ 20 files changed, 351 insertions(+), 17 deletions(-) diff --git a/docs/design/2026-08-03-web-shell-channel-session-sidebar.md b/docs/design/2026-08-03-web-shell-channel-session-sidebar.md index 0bbf0e498a0..15b281180dc 100644 --- a/docs/design/2026-08-03-web-shell-channel-session-sidebar.md +++ b/docs/design/2026-08-03-web-shell-channel-session-sidebar.md @@ -22,6 +22,15 @@ and do not show a control they cannot support. The selected source is applied consistently to active, pinned, archived, and secondary-workspace session requests. Existing session rows, workspace sections, grouping, search, polling, and open-session actions are reused. +Because channel sessions can be created by external messages without a Web +Shell mutation event, the expanded Channels list uses the active-session poll +interval instead of the 30-second idle interval. + +Channel adapters still prepend their model-facing instructions and contextual +history. The daemon prompt carries the user-authored text separately as +transcript display metadata, so live and replayed Web Shell messages do not +expose that hidden context and channel session titles derive from the same +visible text. ## Boundaries @@ -36,5 +45,8 @@ sections, grouping, search, polling, and open-session actions are reused. - Assert Tasks is initially selected and requests `sourceType: "default"`. - Assert selecting Channels requests `sourceType: "channel"` for primary and workspace-qualified lists. +- Assert the Channels list polls on the active-session interval. +- Assert channel prompts preserve full model context while recording only the + user-authored text for transcript display. - Run the sidebar and workspace-section unit tests, Web Shell build, and TypeScript typecheck. diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index f1c0ef27165..bdc6c7c6945 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -5717,6 +5717,23 @@ describe('createAcpSessionBridge', () => { await bridge.shutdown(); }); + it('strips channel display metadata from non-channel sessions', async () => { + const handle = makeChannel(); + const bridge = makeBridge({ channelFactory: async () => handle.channel }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + await bridge.sendPrompt(session.sessionId, { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'model text' }], + _meta: { 'qwen.daemon.promptDisplayText': 'hidden transcript text' }, + } as PromptRequest); + + expect( + handle.agent.promptCalls[0]?._meta?.['qwen.daemon.promptDisplayText'], + ).toBeUndefined(); + await bridge.shutdown(); + }); + it('strips spoofed delivery metadata and injects only trusted context', async () => { const handle = makeChannel(); const bridge = makeBridge({ channelFactory: async () => handle.channel }); @@ -6183,6 +6200,53 @@ describe('createAcpSessionBridge', () => { await bridge.shutdown(); }); + it('echoes only channel display text while forwarding full model context', async () => { + const handle = makeChannel({ + promptImpl: () => ({ stopReason: 'end_turn' }), + }); + const bridge = makeBridge({ channelFactory: async () => handle.channel }); + const session = await bridge.spawnOrAttach({ + workspaceCwd: WS_A, + sourceType: 'channel', + }); + const abort = new AbortController(); + const events = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + }); + const userChunk = (async () => { + for await (const event of events) { + if (event.type !== 'session_update') continue; + const update = ( + event.data as { + update?: { sessionUpdate?: string; content?: unknown }; + } + ).update; + if (update?.sessionUpdate === 'user_message_chunk') return update; + } + throw new Error('no user_message_chunk observed'); + })(); + + await bridge.sendPrompt(session.sessionId, { + sessionId: session.sessionId, + prompt: [ + { type: 'text', text: 'internal channel instructions\n\nhello' }, + ], + _meta: { 'qwen.daemon.promptDisplayText': 'hello' }, + } as PromptRequest); + + await expect(userChunk).resolves.toMatchObject({ + content: { type: 'text', text: 'hello' }, + }); + expect(handle.agent.promptCalls[0]).toMatchObject({ + prompt: [ + { type: 'text', text: 'internal channel instructions\n\nhello' }, + ], + _meta: { 'qwen.daemon.promptDisplayText': 'hello' }, + }); + abort.abort(); + await bridge.shutdown(); + }); + it('echoes one user_message_chunk per content block (multi-modal)', async () => { const factory: ChannelFactory = async () => makeChannel({ promptImpl: () => ({ stopReason: 'end_turn' }) }).channel; diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index ce456a588cc..2b97b607b00 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -102,6 +102,7 @@ import { CHANNEL_STARTUP_PROFILE_META_KEY, CHANNEL_STARTUP_PROFILE_VERSION, DAEMON_CHANNEL_DELIVERY_META_KEY, + DAEMON_PROMPT_DISPLAY_TEXT_META_KEY, LOAD_REPLAY_BULK_MODE, LOAD_REPLAY_HIDE_INHERITED_META_KEY, LOAD_REPLAY_META_KEY, @@ -930,15 +931,25 @@ function echoPromptToSessionBus( // contract — cheaper than a thrown `TypeError` mid-echo. const prompt = req.prompt; if (!Array.isArray(prompt) || prompt.length === 0) return; + const displayText = + entry.sourceType === 'channel' && + typeof req._meta?.[DAEMON_PROMPT_DISPLAY_TEXT_META_KEY] === 'string' + ? req._meta[DAEMON_PROMPT_DISPLAY_TEXT_META_KEY] + : undefined; + let displayTextPublished = false; const serverTimestamp = Date.now(); const blockCount = Math.min(prompt.length, MAX_ECHO_CONTENT_BLOCKS); for (let i = 0; i < blockCount; i += 1) { const part = prompt[i]; if (!part || typeof part !== 'object' || Array.isArray(part)) continue; - // Every `ContentBlock` variant (text, image, audio, resource) is - // published to the bus verbatim. The SDK's `normalizeDaemonEvent` - // accepts any `content` shape; rich rendering of non-text blocks is - // the consumer's responsibility. + let displayPart = part; + if (displayText !== undefined && part.type === 'text') { + if (displayTextPublished) continue; + displayTextPublished = true; + displayPart = { ...part, text: displayText }; + } + // Non-text blocks are published verbatim. Channel text uses the display + // projection so hidden model context never reaches transcript consumers. try { entry.events.publish({ type: 'session_update', @@ -947,7 +958,7 @@ function echoPromptToSessionBus( sessionId: req.sessionId, update: { sessionUpdate: 'user_message_chunk', - content: part, + content: displayPart, // `_meta` lives inside the `update` object rather than at // envelope level. `_meta` is a standard JSON-RPC/MCP extension // field permitted alongside spec fields, the SDK normalizer @@ -5655,6 +5666,12 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { copy._meta && typeof copy._meta === 'object' ? { ...copy._meta } : {}; + const promptDisplayText = + entry.sourceType === 'channel' && + typeof meta[DAEMON_PROMPT_DISPLAY_TEXT_META_KEY] === + 'string' + ? meta[DAEMON_PROMPT_DISPLAY_TEXT_META_KEY] + : undefined; delete meta[DAEMON_RETRY_META_KEY]; delete meta[INVOCATION_CONTEXT_META_KEY]; delete meta[PRIVATE_PARENT_CAPABILITY_META_KEY]; @@ -5663,6 +5680,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { // below) re-arms it after this strip. delete meta[DAEMON_CONTINUE_META_KEY]; delete meta[DAEMON_CHANNEL_DELIVERY_META_KEY]; + delete meta[DAEMON_PROMPT_DISPLAY_TEXT_META_KEY]; if (isRetry) { meta[DAEMON_RETRY_META_KEY] = true; } @@ -5673,6 +5691,10 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { meta[DAEMON_CHANNEL_DELIVERY_META_KEY] = context.channelDelivery; } + if (promptDisplayText !== undefined) { + meta[DAEMON_PROMPT_DISPLAY_TEXT_META_KEY] = + promptDisplayText; + } meta[INVOCATION_CONTEXT_META_KEY] = invocationContext; if (Object.keys(meta).length > 0) { copy._meta = meta; diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index 53d4ccd8d7a..83d55502d56 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -564,6 +564,8 @@ export interface BridgeClientRequestContext { } export const DAEMON_CHANNEL_DELIVERY_META_KEY = 'qwen.daemon.channelDelivery'; +export const DAEMON_PROMPT_DISPLAY_TEXT_META_KEY = + 'qwen.daemon.promptDisplayText'; /** * Returned from `recordHeartbeat`. `lastSeenAt` is the server-side diff --git a/packages/acp-bridge/src/transcript-replay.test.ts b/packages/acp-bridge/src/transcript-replay.test.ts index 3431904ecbe..96b4e40468c 100644 --- a/packages/acp-bridge/src/transcript-replay.test.ts +++ b/packages/acp-bridge/src/transcript-replay.test.ts @@ -282,6 +282,39 @@ describe('createTranscriptReplayMachine', () => { expect(projected).toHaveLength(2); }); + it('does not replay hidden text when an image-only prompt has empty display text', () => { + const projected = updates( + createTranscriptReplayMachine(), + record('user-empty-display', 'user', { + message: { + role: 'user', + parts: [ + { + inlineData: { + data: 'abc', + mimeType: 'image/png', + }, + }, + { text: 'internal channel instructions' }, + ], + }, + systemPayload: { displayText: '' }, + }), + ); + + expect(projected).toMatchObject([ + { + sessionUpdate: 'user_message_chunk', + content: { + type: 'image', + data: 'abc', + mimeType: 'image/png', + }, + }, + ]); + expect(projected).toHaveLength(1); + }); + it('strips a trailing whole-part tagged block when displayText is absent', () => { const projected = updates( createTranscriptReplayMachine(), diff --git a/packages/acp-bridge/src/transcript-replay.ts b/packages/acp-bridge/src/transcript-replay.ts index 23adc7dea1b..701c9eb9a07 100644 --- a/packages/acp-bridge/src/transcript-replay.ts +++ b/packages/acp-bridge/src/transcript-replay.ts @@ -559,7 +559,7 @@ class DefaultTranscriptReplayMachine implements TranscriptReplayMachine { ? payload['displayText'] : undefined; yield* this.projectMessageParts( - displayText + displayText !== undefined ? this.withUserPromptDisplayText(record, displayText) : this.withoutTrailingUserPromptSubmitContext(record), 'user', diff --git a/packages/channels/base/src/ChannelAgentBridge.ts b/packages/channels/base/src/ChannelAgentBridge.ts index 4f6d166f6c7..3e426fcf3f2 100644 --- a/packages/channels/base/src/ChannelAgentBridge.ts +++ b/packages/channels/base/src/ChannelAgentBridge.ts @@ -90,6 +90,13 @@ export interface ChannelAgentBridgeSessionOptions { sourceId?: string; } +export interface ChannelAgentBridgePromptOptions { + imageBase64?: string; + imageMimeType?: string; + /** User-authored text shown in transcripts when `text` includes hidden context. */ + displayText?: string; +} + export interface ChannelAgentBridge { readonly availableCommands: AvailableCommand[]; getAvailableCommands?(sessionId: string): AvailableCommand[]; @@ -115,7 +122,7 @@ export interface ChannelAgentBridge { prompt( sessionId: string, text: string, - options?: { imageBase64?: string; imageMimeType?: string }, + options?: ChannelAgentBridgePromptOptions, ): Promise; cancelSession(sessionId: string): Promise; /** Release a bridge-owned session that will not be routed to a caller. */ diff --git a/packages/channels/base/src/ChannelBase.test.ts b/packages/channels/base/src/ChannelBase.test.ts index 1799d109968..07c44258379 100644 --- a/packages/channels/base/src/ChannelBase.test.ts +++ b/packages/channels/base/src/ChannelBase.test.ts @@ -7759,7 +7759,11 @@ describe('ChannelBase', () => { await ch.handleInbound(envelope({ text: '/schedule list' })); - expect(bridge.prompt).toHaveBeenCalledWith('s-1', '/schedule list', {}); + expect(bridge.prompt).toHaveBeenCalledWith('s-1', '/schedule list', { + displayText: '/schedule list', + imageBase64: undefined, + imageMimeType: undefined, + }); expect(ch.sent).toEqual([{ chatId: 'chat1', text: 'agent response' }]); }); @@ -9941,6 +9945,19 @@ describe('ChannelBase', () => { expect(secondPrompt).not.toContain('Be concise.'); }); + it('keeps channel context out of the user-facing prompt text', async () => { + const ch = createChannel({ instructions: 'Be concise.' }); + + await ch.handleInbound(envelope({ text: 'hello' })); + + const [sessionId, modelText, options] = ( + bridge.prompt as ReturnType + ).mock.calls[0]!; + expect(sessionId).toEqual(expect.any(String)); + expect(modelText).toContain('Be concise.'); + expect(options).toMatchObject({ displayText: 'hello' }); + }); + it('prepends channel boundary metadata after custom instructions once per session', async () => { const ch = createChannel({ instructions: 'Be concise.', @@ -13390,6 +13407,7 @@ describe('ChannelBase', () => { await ch.handleInbound(envelope({ text: '!echo hello' })); expect(bridge.prompt).toHaveBeenCalledWith('s-1', '!echo hello', { + displayText: '!echo hello', imageBase64: undefined, imageMimeType: undefined, }); diff --git a/packages/channels/base/src/ChannelBase.ts b/packages/channels/base/src/ChannelBase.ts index 10450bcf359..ecac0f40cae 100644 --- a/packages/channels/base/src/ChannelBase.ts +++ b/packages/channels/base/src/ChannelBase.ts @@ -5530,6 +5530,7 @@ export abstract class ChannelBase { const response = await promptBridge.prompt(sessionId, promptToSend, { imageBase64, imageMimeType, + displayText: promptText, }); await this.settleCancelRequested(promptState); diff --git a/packages/channels/base/src/DaemonChannelBridge.test.ts b/packages/channels/base/src/DaemonChannelBridge.test.ts index e4a3122a9c8..02d8903df8c 100644 --- a/packages/channels/base/src/DaemonChannelBridge.test.ts +++ b/packages/channels/base/src/DaemonChannelBridge.test.ts @@ -1865,6 +1865,39 @@ describe('DaemonChannelBridge', () => { bridge.stop(); }); + it('forwards a distinct user-facing prompt text in daemon metadata', async () => { + const events = new EventQueue(); + const session = createFakeSession(events); + const bridge = new DaemonChannelBridge({ + cwd: '/repo', + sessionFactory: vi.fn().mockResolvedValue(session), + }); + + await bridge.start(); + await bridge.newSession('/repo'); + + const promptPromise = bridge.prompt( + 'session-1', + 'internal context\n\nhello', + { + displayText: 'hello', + }, + ); + await waitFor(() => expect(session.prompt).toHaveBeenCalledOnce()); + expect(session.prompt).toHaveBeenCalledWith( + { + prompt: [{ type: 'text', text: 'internal context\n\nhello' }], + _meta: { 'qwen.daemon.promptDisplayText': 'hello' }, + }, + expect.any(AbortSignal), + ); + + events.push(turnCompleteEvent()); + await promptPromise; + events.close(); + bridge.stop(); + }); + it('aborts in-flight prompts when the bridge stops', async () => { const events = new EventQueue(); const session = createFakeSession(events); diff --git a/packages/channels/base/src/DaemonChannelBridge.ts b/packages/channels/base/src/DaemonChannelBridge.ts index afcd6c8606a..a5fd71deee2 100644 --- a/packages/channels/base/src/DaemonChannelBridge.ts +++ b/packages/channels/base/src/DaemonChannelBridge.ts @@ -7,6 +7,7 @@ import type { AvailableCommand, BridgeSessionInfo, ChannelAgentBridge, + ChannelAgentBridgePromptOptions, ChannelAgentBridgeSessionOptions, ChannelLoopToolHandler, ToolCallEvent, @@ -19,6 +20,7 @@ import { import type { SessionScope } from './types.js'; const MAX_RESPONDED_PERMISSION_REQUESTS = 256; +const DAEMON_PROMPT_DISPLAY_TEXT_META_KEY = 'qwen.daemon.promptDisplayText'; export interface DaemonChannelEvent { id?: number; @@ -35,6 +37,7 @@ export interface DaemonChannelSessionClient { prompt( req: { prompt: Array>; + _meta?: Record; }, signal?: AbortSignal, ): Promise<{ stopReason?: string; [key: string]: unknown }>; @@ -346,7 +349,7 @@ export class DaemonChannelBridge async prompt( sessionId: string, text: string, - options?: { imageBase64?: string; imageMimeType?: string }, + options?: ChannelAgentBridgePromptOptions, ): Promise { const session = this.ensureSession(sessionId); if (this.activePrompts.has(sessionId)) { @@ -404,7 +407,19 @@ export class DaemonChannelBridge prompt.push({ type: 'text', text }); try { - const result = await session.prompt({ prompt }, controller.signal); + const result = await session.prompt( + { + prompt, + ...(options?.displayText !== undefined + ? { + _meta: { + [DAEMON_PROMPT_DISPLAY_TEXT_META_KEY]: options.displayText, + }, + } + : {}), + }, + controller.signal, + ); // Prefer turn_complete for deterministic chunk collection (SSE path). // Fall back to one event-loop tick for non-SSE prompt paths (blocking // HTTP, non-202 responses) where turn_complete never arrives. diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index b7e9498ff8e..bf046493bd1 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -4377,6 +4377,26 @@ describe('Session', () => { ); }); + it('records daemon prompt display text separately from model context', async () => { + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValue(createEmptyStream()); + + await session.prompt({ + sessionId: 'test-session-id', + prompt: [ + { type: 'text', text: 'internal channel instructions\n\nhello' }, + ], + _meta: { 'qwen.daemon.promptDisplayText': 'hello' }, + }); + + expect(mockChatRecordingService.recordUserMessage).toHaveBeenCalledWith( + 'internal channel instructions\n\nhello', + undefined, + { displayText: 'hello' }, + ); + }); + it('degrades an oversized inline image to a text placeholder before sending to the model', async () => { const ENV_KEY = 'QWEN_CODE_MAX_INLINE_MEDIA_BYTES'; const original = process.env[ENV_KEY]; diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 39a91ec35cc..037e90d9b6e 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -172,6 +172,7 @@ import { NOT_CURRENTLY_GENERATING_CANCEL_MESSAGE } from '@qwen-code/acp-bridge/b // so a rename can't desync caller and answerer into a silent -32601 latch. import { DAEMON_CHANNEL_DELIVERY_META_KEY, + DAEMON_PROMPT_DISPLAY_TEXT_META_KEY, MID_TURN_QUEUE_DRAIN_METHOD, TODO_STOP_GUARD_CONTINUATION_CLAIM_METHOD, } from '@qwen-code/acp-bridge/bridgeTypes'; @@ -2820,6 +2821,11 @@ export class Session implements SessionContext { .filter((block) => block.type === 'text') .map((block) => (block.type === 'text' ? block.text : '')) .join(' '); + const promptDisplayText = + typeof promptMetadata?.[DAEMON_PROMPT_DISPLAY_TEXT_META_KEY] === + 'string' + ? promptMetadata[DAEMON_PROMPT_DISPLAY_TEXT_META_KEY] + : undefined; // Log user prompt logUserPrompt( @@ -2904,9 +2910,14 @@ export class Session implements SessionContext { this.#getCurrentChat().stripOrphanedUserEntriesFromHistory(); } else { // record user message for session management - this.config - .getChatRecordingService() - ?.recordUserMessage(promptText); + const recordingService = this.config.getChatRecordingService(); + if (recordingService && promptDisplayText !== undefined) { + recordingService.recordUserMessage(promptText, undefined, { + displayText: promptDisplayText, + }); + } else { + recordingService?.recordUserMessage(promptText); + } } // Check if the input contains a slash command diff --git a/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts b/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts index 0a6bbdcd993..82208943a3f 100644 --- a/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts +++ b/packages/cli/src/ui/utils/resumeHistoryUtils.test.ts @@ -236,6 +236,15 @@ describe('resumeHistoryUtils', () => { expect(items).toEqual([{ id: 1_001, type: 'user', text: 'my prompt' }]); }); + it('does not fall back to hidden text when displayText is empty', () => { + const items = buildUserItems({ + type: 'user', + message: { parts: [{ text: 'internal channel instructions' }] }, + systemPayload: { displayText: '' }, + }); + expect(items).toEqual([]); + }); + it('prefers displayText over the tag-strip fallback', () => { // Fixture where the two branches disagree: without displayText the // tag-strip path would expose the middle "expanded extra" part. diff --git a/packages/cli/src/ui/utils/resumeHistoryUtils.ts b/packages/cli/src/ui/utils/resumeHistoryUtils.ts index 385898bf9de..c143d052a11 100644 --- a/packages/cli/src/ui/utils/resumeHistoryUtils.ts +++ b/packages/cli/src/ui/utils/resumeHistoryUtils.ts @@ -50,7 +50,7 @@ function extractUserRecordDisplayText( record: ConversationRecord['messages'][number], ): string { const payload = record.systemPayload as UserPromptRecordPayload | undefined; - if (payload?.displayText) { + if (payload?.displayText !== undefined) { return payload.displayText; } const parts = (record.message?.parts as Part[] | undefined) ?? []; diff --git a/packages/core/src/services/sessionService.test.ts b/packages/core/src/services/sessionService.test.ts index b9c9a6467af..6817b53fb5f 100644 --- a/packages/core/src/services/sessionService.test.ts +++ b/packages/core/src/services/sessionService.test.ts @@ -364,6 +364,30 @@ describe('SessionService', () => { expect(result.items[0].gitBranch).toBe('main'); }); + it('should use recorded display text for the session list prompt', async () => { + readdirSyncSpy.mockReturnValue([ + `${sessionIdA}.jsonl`, + ] as unknown as Array>); + statSyncSpy.mockReturnValue({ + mtimeMs: Date.now(), + isFile: () => true, + } as fs.Stats); + vi.mocked(jsonl.readLines).mockResolvedValue([ + { + ...recordA1, + message: { + role: 'user', + parts: [{ text: 'internal channel instructions\n\nhello' }], + }, + systemPayload: { displayText: 'hello' }, + }, + ]); + + const result = await sessionService.listSessions(); + + expect(result.items[0].prompt).toBe('hello'); + }); + it('should NOT populate messageCount during listing', async () => { // Listing must avoid the full-file readline that counting requires // — message counts are now lazy and provided by diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index 9396ef75640..5a800e1208d 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -21,6 +21,7 @@ import type { FileHistorySnapshotRecordPayload, TitleSource, UiTelemetryRecordPayload, + UserPromptRecordPayload, } from './chatRecordingService.js'; import type { FileHistorySnapshot } from './fileHistoryService.js'; import { @@ -843,8 +844,22 @@ export class SessionService { private extractFirstPromptFromRecords(records: ChatRecord[]): string { for (const record of records) { if (record.type !== 'user') continue; + const payload = record.systemPayload as + | UserPromptRecordPayload + | undefined; + if (payload?.displayText !== undefined) { + const displayText = payload.displayText; + if (displayText) { + return displayText.length > 200 + ? `${displayText.slice(0, 200)}...` + : displayText; + } + continue; + } const prompt = this.extractPromptText(record.message); - if (prompt) return prompt; + if (prompt) { + return prompt.length > 200 ? `${prompt.slice(0, 200)}...` : prompt; + } } return ''; } diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx index 1169619ebb0..60c962f7747 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx @@ -1412,7 +1412,7 @@ export function WebShellSidebar({ useEffect(() => { if (!projectExpanded && !hasRunningSession) return; const pollInterval = - hasRunningSession && !error + (hasRunningSession || selectedSessionSource === 'channel') && !error ? ACTIVE_SESSION_POLL_INTERVAL_MS : IDLE_SESSION_POLL_INTERVAL_MS; const intervalId = window.setInterval(() => { @@ -1423,7 +1423,13 @@ export function WebShellSidebar({ }); }, pollInterval); return () => window.clearInterval(intervalId); - }, [error, hasRunningSession, projectExpanded, reload]); + }, [ + error, + hasRunningSession, + projectExpanded, + reload, + selectedSessionSource, + ]); const prevReloadTokenRef = useRef(sessionListReloadToken); useEffect(() => { diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.workspace-removal.test.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.workspace-removal.test.tsx index 7fdfbb13100..8053e6d51ab 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.workspace-removal.test.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.workspace-removal.test.tsx @@ -2950,6 +2950,38 @@ describe('WebShellSidebar session source switch', () => { ), ).toBe(true); }); + + it('polls channel sessions on the active-session interval', async () => { + const setIntervalSpy = vi.spyOn(window, 'setInterval'); + renderSidebar(); + await ensureWorkspaceExpanded('project'); + const channelsTab = Array.from( + container.querySelectorAll('[role="tab"]'), + ).find((button) => button.textContent?.trim() === 'Channels'); + + await act(async () => { + channelsTab!.dispatchEvent( + new MouseEvent('mousedown', { bubbles: true, button: 0 }), + ); + channelsTab!.click(); + await Promise.resolve(); + }); + expect(channelsTab?.getAttribute('data-state')).toBe('active'); + const activePoll = setIntervalSpy.mock.calls.findLast( + ([, timeout]) => timeout === 2_000, + ); + expect(activePoll).toBeDefined(); + active.reload.mockClear(); + + await act(async () => { + const callback = activePoll![0]; + expect(callback).toBeTypeOf('function'); + if (typeof callback === 'function') callback(); + await Promise.resolve(); + }); + + expect(active.reload).toHaveBeenCalledOnce(); + }); }); describe('WebShellSidebar archived session export', () => { diff --git a/packages/web-shell/client/e2e/web-shell.channels.spec.ts b/packages/web-shell/client/e2e/web-shell.channels.spec.ts index 337dc730c0c..065a9ef3985 100644 --- a/packages/web-shell/client/e2e/web-shell.channels.spec.ts +++ b/packages/web-shell/client/e2e/web-shell.channels.spec.ts @@ -57,6 +57,16 @@ test('shows channel sessions in the sidebar channel catalog', async ({ page.getByText('DingTalk conversation', { exact: true }), ).toBeVisible(); await expect(page.getByText('Web Shell task')).toHaveCount(0); + + scenario.sessions.push({ + sessionId: 'new-dingtalk-session', + displayName: 'New DingTalk conversation', + sourceType: 'channel', + sourceId: 'release-bot', + }); + await expect( + page.getByText('New DingTalk conversation', { exact: true }), + ).toBeVisible({ timeout: 5_000 }); }); test('creates and deletes a typed Channel configuration', async ({ From 2ad35da0b5d56761367b5d08bef629ee4f52a917 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=8B=E7=AB=9F?= Date: Mon, 3 Aug 2026 23:45:15 +0800 Subject: [PATCH 04/16] feat(web-shell): group channel sessions by platform --- ...08-03-web-shell-channel-session-sidebar.md | 16 ++ .../WebShellSidebar.collapse-persist.test.tsx | 1 + .../components/sidebar/WebShellSidebar.tsx | 110 +++++++++--- ...WebShellSidebar.workspace-removal.test.tsx | 168 ++++++++++++++++++ .../sidebar/WorkspaceSection.test.tsx | 76 ++++++++ .../components/sidebar/WorkspaceSection.tsx | 76 ++++++++ .../sidebar/channelSessionGroups.test.ts | 83 +++++++++ .../sidebar/channelSessionGroups.ts | 46 +++++ .../sidebar/collapsedSessionSections.ts | 2 +- .../client/e2e/web-shell.channels.spec.ts | 80 +++++++++ packages/web-shell/client/i18n.tsx | 2 + 11 files changed, 632 insertions(+), 28 deletions(-) create mode 100644 packages/web-shell/client/components/sidebar/channelSessionGroups.test.ts create mode 100644 packages/web-shell/client/components/sidebar/channelSessionGroups.ts diff --git a/docs/design/2026-08-03-web-shell-channel-session-sidebar.md b/docs/design/2026-08-03-web-shell-channel-session-sidebar.md index 15b281180dc..e79ae6186e3 100644 --- a/docs/design/2026-08-03-web-shell-channel-session-sidebar.md +++ b/docs/design/2026-08-03-web-shell-channel-session-sidebar.md @@ -26,6 +26,17 @@ Because channel sessions can be created by external messages without a Web Shell mutation event, the expanded Channels list uses the active-session poll interval instead of the 30-second idle interval. +When the selected workspace also advertises `channel_management`, the Channels +catalog joins each session's immutable channel instance name (`sourceId`) to +the current channel configuration and groups sessions by `config.type`. The +type catalog supplies the platform label, so multiple instances of the same +platform share one collapsible section. Sessions whose instance no longer +exists remain visible under Other channels. If the catalog is unavailable, the +list keeps its existing fallback instead of hiding sessions. Channel type grouping +overrides user-defined session groups in the Channels view; Tasks keeps its +existing organization behavior. Secondary workspaces resolve their own +workspace-scoped channel catalog. + Channel adapters still prepend their model-facing instructions and contextual history. The daemon prompt carries the user-authored text separately as transcript display metadata, so live and replayed Web Shell messages do not @@ -38,6 +49,8 @@ visible text. - Session source metadata and daemon list APIs are unchanged. - Session Overview and Split View keep their existing default-session scope. - The switch is in-memory UI state and resets to Tasks on page reload. +- Channel type classification reflects the current workspace configuration; + sessions do not persist a historical platform type. ## Verification @@ -46,6 +59,9 @@ visible text. - Assert selecting Channels requests `sourceType: "channel"` for primary and workspace-qualified lists. - Assert the Channels list polls on the active-session interval. +- Assert multiple instances of one platform share a collapsible type section, + other platform sessions remain separate, pinned sessions stay in their type + section, and unmatched sessions remain under Other channels. - Assert channel prompts preserve full model context while recording only the user-authored text for transcript display. - Run the sidebar and workspace-section unit tests, Web Shell build, and diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.collapse-persist.test.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.collapse-persist.test.tsx index eb2e55edb07..16db70ef2cb 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.collapse-persist.test.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.collapse-persist.test.tsx @@ -83,6 +83,7 @@ vi.mock('@qwen-code/webui/daemon-react-sdk', () => ({ useActions: () => ({ renameSession: vi.fn() }), useWorkspace: () => workspace, useWorkspaceActions: () => workspaceActions, + useChannels: () => ({ data: undefined, catalog: [], channels: {} }), useSessions: (options?: { archiveState?: string; group?: string }) => { if (options?.archiveState === 'archived') return archived; if (options?.group === 'pinned') return pinned; diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx index 60c962f7747..7f1911e1236 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.tsx @@ -12,6 +12,7 @@ import { } from 'react'; import { useActions, + useChannels, useConnection, useSessions, useWorkspace, @@ -84,6 +85,7 @@ import { DialogShell } from '../dialogs/DialogShell'; import { WorkspaceSection } from './WorkspaceSection'; import { SessionGroupSection } from './SessionGroupSection'; import { SessionDetailsSubmenu } from './SessionDetailsSubmenu'; +import { groupSessionsByChannelType } from './channelSessionGroups'; import { resolveSessionDetailsCollisionBoundary } from './sessionDetailsCollisionBoundary'; import { isPrimaryCollapsedSectionId, @@ -574,6 +576,18 @@ export function WebShellSidebar({ const selectedSessionSource = sourceMetadataEnabled ? sessionSource : undefined; + const channelGroupingEnabled = Boolean( + selectedSessionSource === 'channel' && + workspace.capabilities?.features.includes('channel_management'), + ); + const { + data: channelCatalogData, + catalog: channelTypeCatalog, + channels: channelInstances, + } = useChannels({ + autoLoad: channelGroupingEnabled, + enabled: channelGroupingEnabled, + }); const sessionArchiveEnabled = Boolean( connection.capabilities?.features?.includes('session_archive'), ); @@ -2547,7 +2561,10 @@ export function WebShellSidebar({ const filteredSessions = useMemo(() => { const query = searchQuery.trim().toLowerCase(); - const unpinnedSessions = sessions.filter((session) => !session.isPinned); + const unpinnedSessions = + selectedSessionSource === 'channel' + ? sessions + : sessions.filter((session) => !session.isPinned); const nextSessions = query ? unpinnedSessions.filter((session) => { const label = getSessionLabel(session).toLowerCase(); @@ -2571,7 +2588,27 @@ export function WebShellSidebar({ (createdTimeById.get(b.sessionId) ?? 0) - (createdTimeById.get(a.sessionId) ?? 0), ); - }, [organizationEnabled, searchQuery, sessions]); + }, [organizationEnabled, searchQuery, selectedSessionSource, sessions]); + + const channelSessionSections = useMemo( + () => + selectedSessionSource === 'channel' && channelCatalogData + ? groupSessionsByChannelType( + filteredSessions, + channelTypeCatalog, + channelInstances, + t('sidebar.channelType.other'), + ) + : null, + [ + channelCatalogData, + channelInstances, + channelTypeCatalog, + filteredSessions, + selectedSessionSource, + t, + ], + ); const sessionSections = useMemo(() => { if (!organizationEnabled) return []; @@ -3430,12 +3467,27 @@ export function WebShellSidebar({ } if ( filteredSessions.length === 0 && - (searchQuery.trim() || + (channelSessionSections !== null || + searchQuery.trim() || !organizationEnabled || sessionSections.length === 0) ) { return
{t('sidebar.searchEmpty')}
; } + if (channelSessionSections) { + return channelSessionSections.map((section) => ( + toggleSessionSection(section.id)} + > + {section.sessions.map((session) => renderSessionRow(session))} + + )); + } if (!organizationEnabled) { return filteredSessions.map((session) => renderSessionRow(session)); } @@ -3476,6 +3528,7 @@ export function WebShellSidebar({ }, [ collapsedSessionSectionIds, canOrganizeWorkspace, + channelSessionSections, error, filteredSessions, groupBusy, @@ -4105,30 +4158,32 @@ export function WebShellSidebar({ )} - {!collapsed && pinnedSessions.length > 0 && ( - <> -
- -
- {pinnedExpanded && ( -
- {pinnedSessions.map((session) => - renderSessionRow(session, { - readOnly: isActiveSessionReadOnly(session), - }), - )} + {!collapsed && + selectedSessionSource !== 'channel' && + pinnedSessions.length > 0 && ( + <> +
+
- )} - - )} + {pinnedExpanded && ( +
+ {pinnedSessions.map((session) => + renderSessionRow(session, { + readOnly: isActiveSessionReadOnly(session), + }), + )} +
+ )} + + )} {!collapsed && !hideProjectHeader && (
) : visibleSessions.length === 0 ? ( -
{noSessionsLabel}
+ // A source switch swaps the query key; until the new source's + // page settles there is no data yet, so the "no sessions" notice + // would flash for a whole fetch round-trip. + sessionsLoading && sessionsPage === undefined ? null : ( +
{noSessionsLabel}
+ ) ) : channelSessionGroups ? ( <> {channelSessionGroups.map((group) => ( diff --git a/packages/web-shell/client/e2e/utils/mockDaemon.ts b/packages/web-shell/client/e2e/utils/mockDaemon.ts index 9e351489825..6db21aed4b8 100644 --- a/packages/web-shell/client/e2e/utils/mockDaemon.ts +++ b/packages/web-shell/client/e2e/utils/mockDaemon.ts @@ -541,6 +541,27 @@ function readRequestBody(raw: string | null): unknown { } } +// Mirror production query modes: `group=pinned` is the pinned bucket; +// `group=all` (and missing group) returns the full active list. The UI +// excludes pinned rows from organized sections via `excludePinned`. +function filterScenarioSessions( + scenario: WebShellDaemonScenario, + searchParams: URLSearchParams, +): DaemonSessionSummary[] { + const group = searchParams.get('group'); + const sourceType = searchParams.get('sourceType'); + const sourceSessions = sourceType + ? scenario.sessions.filter( + (session) => + session.sourceType === sourceType || + (sourceType === 'default' && session.sourceType === undefined), + ) + : scenario.sessions; + return group === 'pinned' + ? sourceSessions.filter((session) => Boolean(session.isPinned)) + : sourceSessions; +} + function isDaemonPath(path: string): boolean { return ( path === '/health' || @@ -565,6 +586,7 @@ function isDaemonPath(path: string): boolean { /^\/workspaces\/[^/]+\/channels\/[^/]+\/pairing-approvals\/?$/.test(path) || /^\/workspaces\/[^/]+\/channels\/[^/]+\/?$/.test(path) || /^\/workspace\/.+\/sessions\/?$/.test(path) || + /^\/workspaces\/[^/]+\/sessions\/?$/.test(path) || /^\/workspace\/.+\/session-groups\/?$/.test(path) || /^\/workspaces\/.+\/git\/?$/.test(path) || /^\/workspaces\/.+\/git\/(branches|checkout|branch|push|pull|commit|diff|log)\/?$/.test( @@ -636,7 +658,11 @@ function isDaemonRoute(method: string, path: string): boolean { ) { return true; } - if (method === 'GET' && /^\/workspace\/.+\/sessions\/?$/.test(path)) { + if ( + method === 'GET' && + (/^\/workspace\/.+\/sessions\/?$/.test(path) || + /^\/workspaces\/[^/]+\/sessions\/?$/.test(path)) + ) { return true; } if (method === 'GET' && /^\/workspace\/.+\/session-groups\/?$/.test(path)) { @@ -847,24 +873,14 @@ async function handleDaemonRoute( await json(route, workspaceMcpResources(scenario, serverName)); return; } - if (method === 'GET' && /^\/workspace\/.+\/sessions\/?$/.test(path)) { - // Mirror production query modes: `group=pinned` is the pinned bucket; - // `group=all` (and missing group) returns the full active list. The UI - // excludes pinned rows from organized sections via `excludePinned`. - const group = searchParams.get('group'); - const sourceType = searchParams.get('sourceType'); - const sourceSessions = sourceType - ? scenario.sessions.filter( - (session) => - session.sourceType === sourceType || - (sourceType === 'default' && session.sourceType === undefined), - ) - : scenario.sessions; - const sessions = - group === 'pinned' - ? sourceSessions.filter((session) => Boolean(session.isPinned)) - : sourceSessions; - await json(route, { sessions }); + if ( + method === 'GET' && + (/^\/workspace\/.+\/sessions\/?$/.test(path) || + /^\/workspaces\/[^/]+\/sessions\/?$/.test(path)) + ) { + await json(route, { + sessions: filterScenarioSessions(scenario, searchParams), + }); return; } if (method === 'GET' && /^\/workspace\/.+\/session-groups\/?$/.test(path)) { From 84db931ed67e8ead281620ad7adcbe08c5805bce Mon Sep 17 00:00:00 2001 From: "qwen-code-autofix[bot]" Date: Tue, 11 Aug 2026 21:40:51 +0000 Subject: [PATCH 14/16] test(web-shell): pin round-8 sidebar behavior fixes (#8457) --- ...WebShellSidebar.workspace-removal.test.tsx | 26 ++++++++++ .../sidebar/WorkspaceSection.test.tsx | 48 ++++++++++++++----- 2 files changed, 62 insertions(+), 12 deletions(-) diff --git a/packages/web-shell/client/components/sidebar/WebShellSidebar.workspace-removal.test.tsx b/packages/web-shell/client/components/sidebar/WebShellSidebar.workspace-removal.test.tsx index c90debcf99d..50c2329a32f 100644 --- a/packages/web-shell/client/components/sidebar/WebShellSidebar.workspace-removal.test.tsx +++ b/packages/web-shell/client/components/sidebar/WebShellSidebar.workspace-removal.test.tsx @@ -74,6 +74,7 @@ const { const exportArchivedSession = vi.fn(); const sessionActions = { renameSession: vi.fn() }; const channelState = { + error: undefined as Error | undefined, data: undefined as | { catalog: Array<{ @@ -781,6 +782,7 @@ beforeEach(() => { active.loading = false; active.error = null; archived.sessions.length = 0; + channelState.error = undefined; channelState.data = undefined; channelState.catalog = []; channelState.channels = {}; @@ -3490,6 +3492,30 @@ describe('WebShellSidebar session source switch', () => { expect(clearIntervalSpy).toHaveBeenCalledWith(activePollId); }); + it('backs off the channel catalog poll while the channels hook errors', async () => { + const channelCapabilities = { + ...capabilities, + features: [...capabilities.features, 'channel_management'], + }; + connection.capabilities = channelCapabilities; + workspace.capabilities = channelCapabilities; + channelState.error = new Error('channels endpoint down'); + const setIntervalSpy = vi.spyOn(window, 'setInterval'); + + renderSidebar(); + await ensureWorkspaceExpanded('project'); + await switchSessionSource('Channels'); + + // A persistently failing channels endpoint must not be re-requested on + // the 2s active cadence; the poll downshifts like the sibling pollers. + expect( + setIntervalSpy.mock.calls.some(([, timeout]) => timeout === 2_000), + ).toBe(false); + expect( + setIntervalSpy.mock.calls.some(([, timeout]) => timeout === 30_000), + ).toBe(true); + }); + it('keeps a flat channel list when channel metadata is unavailable', async () => { active.sessions.push({ sessionId: 'legacy-channel-session', diff --git a/packages/web-shell/client/components/sidebar/WorkspaceSection.test.tsx b/packages/web-shell/client/components/sidebar/WorkspaceSection.test.tsx index c7847f32eec..75d3915025b 100644 --- a/packages/web-shell/client/components/sidebar/WorkspaceSection.test.tsx +++ b/packages/web-shell/client/components/sidebar/WorkspaceSection.test.tsx @@ -336,7 +336,39 @@ describe('WorkspaceSection label', () => { ).toHaveLength(1); }); + it('does not flash the empty notice while a fresh source settles', async () => { + const client = { + workspaceByCwd: vi.fn(() => ({ + workspaceGit, + listWorkspaceSessionsPage: vi.fn( + () => new Promise<{ sessions: DaemonSessionSummary[] }>(() => {}), + ), + listSessionGroups: vi.fn().mockResolvedValue({ groups: [] }), + })), + } as unknown as DaemonClient; + + renderSection({ client, expanded: true, sourceType: 'channel' }); + await flush(); + + // The new query key's fetch is in flight with no settled page yet, so + // the section renders nothing instead of "No sessions" for the + // round-trip. + expect(container.textContent).not.toContain('No sessions'); + }); + it('groups a secondary workspace with its own channel catalog', async () => { + const listSessionGroups = vi.fn().mockResolvedValue({ + groups: [ + { + id: 'organization-group', + name: 'Organization group', + color: 'blue', + order: 0, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }, + ], + }); const client = { workspaceByCwd: vi.fn(() => ({ workspaceGit, @@ -360,18 +392,7 @@ describe('WorkspaceSection label', () => { }, ], }), - listSessionGroups: vi.fn().mockResolvedValue({ - groups: [ - { - id: 'organization-group', - name: 'Organization group', - color: 'blue', - order: 0, - createdAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-01T00:00:00.000Z', - }, - ], - }), + listSessionGroups, workspaceChannelTypes: vi.fn().mockResolvedValue([ { type: 'dingtalk', @@ -427,6 +448,9 @@ describe('WorkspaceSection label', () => { expect( container.querySelector('section[aria-label="Organization group"]'), ).toBeNull(); + // Channel mode discards the organization sections, so the catalog fetch + // must be skipped too, mirroring the sidebar's own org prefetch gates. + expect(listSessionGroups).not.toHaveBeenCalled(); }); it('renders channel sessions flat while the channel catalog failed to load', async () => { From 13b771d7e315299d361041fd72c4636472a3398c Mon Sep 17 00:00:00 2001 From: "qwen-code-autofix[bot]" Date: Wed, 12 Aug 2026 06:31:02 +0000 Subject: [PATCH 15/16] fix(channels): address review round-9 findings (#8457) --- packages/acp-bridge/src/bridge.test.ts | 1 + .../channels/base/src/ChannelBase.test.ts | 21 +++++++++++++++++++ packages/channels/base/src/ChannelBase.ts | 11 +++++++++- packages/channels/qqbot/src/QQChannel.ts | 2 +- packages/channels/qqbot/src/events.test.ts | 18 ++++++++++++++++ 5 files changed, 51 insertions(+), 2 deletions(-) diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index 6e59dc85f36..edb4133e789 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -8432,6 +8432,7 @@ describe('createAcpSessionBridge', () => { const replayAttach = bridge.loadSession({ sessionId: first.sessionId, workspaceCwd: WS_A, + historyReplay: 'response', historyPageSize: 10, clientId: 'rejected-load-client', }); diff --git a/packages/channels/base/src/ChannelBase.test.ts b/packages/channels/base/src/ChannelBase.test.ts index 02e041afcd3..e2c3e829266 100644 --- a/packages/channels/base/src/ChannelBase.test.ts +++ b/packages/channels/base/src/ChannelBase.test.ts @@ -10273,6 +10273,27 @@ describe('ChannelBase', () => { expect(options).toMatchObject({ displayText: 'hello' }); }); + it('neutralizes display-unsafe controls in the raw-text display fallback', async () => { + const ch = createChannel(); + const rlo = String.fromCharCode(0x202e); // bidi override (trojan-source) + const bel = String.fromCharCode(0x07); // C0 control + // Adapters that never set displayText fall back to the raw text; the + // projection must neutralize it before it reaches the session bus, + // transcript, and session previews. + await ch.handleInbound( + envelope({ text: `line1${rlo}${bel}\nline2${'A'.repeat(9000)}` }), + ); + + const [, , options] = (bridge.prompt as ReturnType).mock + .calls[0]!; + const displayText = (options as { displayText: string }).displayText; + // Controls are replaced, the real newline survives, and the projection + // is capped by code point. + expect(displayText.startsWith('line1 \nline2')).toBe(true); + expect(displayText).not.toContain(rlo); + expect(Array.from(displayText)).toHaveLength(8000); + }); + it('prepends channel boundary metadata after custom instructions once per session', async () => { const ch = createChannel({ instructions: 'Be concise.', diff --git a/packages/channels/base/src/ChannelBase.ts b/packages/channels/base/src/ChannelBase.ts index 9fd6d7454a3..c47dc5b0a75 100644 --- a/packages/channels/base/src/ChannelBase.ts +++ b/packages/channels/base/src/ChannelBase.ts @@ -47,6 +47,7 @@ import { sanitizePromptText, sanitizePromptPath, sanitizeLogText, + sanitizeDisplayText, truncateCodePoints, PROMPT_UNSAFE_INVISIBLES, } from './sanitize.js'; @@ -331,6 +332,7 @@ const COMMAND_TOKEN_RE = new RegExp(`^[${COMMAND_TOKEN_CHARS}]+(?:@\\S+)?$`); const LOOP_ADD_RE = /^"([^"]+)"\s+(.+)$/su; const MAX_LOOP_JOBS_PER_TARGET = 10; const MAX_LOOP_PROMPT_CHARS = 4000; +const MAX_DISPLAY_PROJECTION_CHARS = 8000; /** * The command-providing surface of a bridge. AcpBridge runs a single agent and @@ -5039,7 +5041,14 @@ export abstract class ChannelBase { await this.recordObservedContact(envelope); this.onObservedContact(envelope); } - const displayText = envelope.displayText ?? envelope.text; + // Adapters that never set `displayText` fall back to the raw message + // text; sanitize at this boundary so attacker-controlled bidi/zero-width/ + // control chars cannot reach the session-bus echo, recorded transcript, + // or session previews. + const displayText = sanitizeDisplayText( + envelope.displayText ?? envelope.text, + MAX_DISPLAY_PROJECTION_CHARS, + ); let memoryIntent: ResolvedChannelMemoryIntent | null = parseChannelMemoryIntent(envelope.text); diff --git a/packages/channels/qqbot/src/QQChannel.ts b/packages/channels/qqbot/src/QQChannel.ts index c8f75a0d0a5..d7f896ea676 100644 --- a/packages/channels/qqbot/src/QQChannel.ts +++ b/packages/channels/qqbot/src/QQChannel.ts @@ -2340,7 +2340,7 @@ export class QQChannel extends ChannelBase { const effectiveIsAtBot = forceAtMention ?? isAtBot; - const isSlash = effectiveIsAtBot && safeDisplayText.startsWith('/'); + const isSlash = effectiveIsAtBot && safeCleanText.startsWith('/'); // Deliberately NOT hard-blocking bot messages — QQ Bot API may deliver // self-echoes or other bot messages. Instead, tag with [bot] prefix so the diff --git a/packages/channels/qqbot/src/events.test.ts b/packages/channels/qqbot/src/events.test.ts index 78e0e09dbc2..2cd103f8e65 100644 --- a/packages/channels/qqbot/src/events.test.ts +++ b/packages/channels/qqbot/src/events.test.ts @@ -524,6 +524,24 @@ describe('handleGroup', () => { expect(env['text']).toBe('/status'); }); + it('其他成员 mention 后的斜杠命令仍被识别', async () => { + const ch = makeChannel(); + const pvt = ch as unknown as QQChannelRaw; + pvt['handleGroup']( + makeGroupEvent({ + content: '<@OPENID_BOT> <@OPENID_ALICE> /schedule list', + mentions: [ + { member_openid: 'bot-openid', is_you: true }, + { member_openid: 'alice-openid', is_you: false }, + ], + }), + ); + await vi.advanceTimersByTimeAsync(600); + const env = mockHandleInbound.mock.calls[0][0] as Record; + expect(env['text']).toBe('/schedule list'); + expect(env['displayText']).toBe('<@OPENID_ALICE> /schedule list'); + }); + it('重复消息不触发', async () => { const ch = makeChannel(); const pvt = ch as unknown as QQChannelRaw; From 06336a25cb0856d03c2cc3e9ffabbd1260bac886 Mon Sep 17 00:00:00 2001 From: "qwen-code-autofix[bot]" Date: Wed, 12 Aug 2026 11:46:01 +0000 Subject: [PATCH 16/16] fix(web-shell): pin Other channels group after platform sections (#8457) --- .../sidebar/channelSessionGroups.test.ts | 35 +++++++++++++++++-- .../sidebar/channelSessionGroups.ts | 12 +++++-- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/packages/web-shell/client/components/sidebar/channelSessionGroups.test.ts b/packages/web-shell/client/components/sidebar/channelSessionGroups.test.ts index 7cb5dbbf048..925cb429c71 100644 --- a/packages/web-shell/client/components/sidebar/channelSessionGroups.test.ts +++ b/packages/web-shell/client/components/sidebar/channelSessionGroups.test.ts @@ -30,6 +30,10 @@ function instance(name: string, type: string): DaemonChannelInstanceSnapshot { }; } +function groupIds(groups: ReturnType) { + return groups.map((group) => group.id); +} + describe('groupSessionsByChannelType', () => { it('combines channel instances of the same type in first-seen order', () => { const groups = groupSessionsByChannelType( @@ -90,16 +94,41 @@ describe('groupSessionsByChannelType', () => { sessions: sessions.map((item) => item.sessionId), })), ).toEqual([ + { + id: 'channel-type:dingtalk', + label: 'DingTalk', + sessions: ['d1'], + }, { id: 'channel-type-fallback', label: 'Other channels', sessions: ['orphan-constructor', 'orphan-proto'], }, + ]); + }); + + it('pins the fallback group after every resolved platform section', () => { + // The fallback section is emitted in session iteration order unless pinned, + // so an orphan seen before any platform session would otherwise render + // between two real platform sections. + const groups = groupSessionsByChannelType( + [ + session('legacy', 'retired-instance'), + session('f1', 'feishu'), + session('d1', 'ding-one'), + ], + catalog, { - id: 'channel-type:dingtalk', - label: 'DingTalk', - sessions: ['d1'], + feishu: instance('feishu', 'feishu'), + 'ding-one': instance('ding-one', 'dingtalk'), }, + 'Other channels', + ); + + expect(groupIds(groups)).toEqual([ + 'channel-type:feishu', + 'channel-type:dingtalk', + 'channel-type-fallback', ]); }); diff --git a/packages/web-shell/client/components/sidebar/channelSessionGroups.ts b/packages/web-shell/client/components/sidebar/channelSessionGroups.ts index 8da23c154ff..9d946da90a5 100644 --- a/packages/web-shell/client/components/sidebar/channelSessionGroups.ts +++ b/packages/web-shell/client/components/sidebar/channelSessionGroups.ts @@ -10,6 +10,8 @@ export interface ChannelSessionGroup { sessions: DaemonSessionSummary[]; } +const FALLBACK_GROUP_ID = 'channel-type-fallback'; + export function groupSessionsByChannelType( sessions: readonly DaemonSessionSummary[], catalog: DaemonChannelTypeCatalog, @@ -34,7 +36,7 @@ export function groupSessionsByChannelType( typeof configuredType === 'string' ? configuredType.trim() || undefined : undefined; - const id = type ? `channel-type:${type}` : 'channel-type-fallback'; + const id = type ? `channel-type:${type}` : FALLBACK_GROUP_ID; const existing = groups.get(id); if (existing) { existing.sessions.push(session); @@ -47,5 +49,11 @@ export function groupSessionsByChannelType( }); } - return [...groups.values()]; + const ordered = [...groups.values()]; + const fallback = groups.get(FALLBACK_GROUP_ID); + // Keep the unresolved "Other channels" section after every named platform + // instead of letting first-seen session order wedge it between two of them. + return fallback === undefined + ? ordered + : [...ordered.filter((group) => group !== fallback), fallback]; }