Show WhatsApp channel peer next to Agent Preset in session header (4.9.1-ops.20).

Plugins inject conversation.session.header.actions; resolve sessionId back to the bound chat and render nickname/phone or group title.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-05 19:10:07 +08:00
parent 2718586fda
commit 37742043a9
15 changed files with 852 additions and 326 deletions

View file

@ -0,0 +1,95 @@
import * as React from 'react';
import { h, localizeText } from './i18n.js';
import {
unwrapRpcResult,
WHATSAPP_ENDPOINTS,
} from './channels/whatsapp/api.js';
const CHANNEL_LABELS = Object.freeze({
whatsapp: 'WhatsApp',
feishu: '飞书',
dingtalk: '钉钉',
wecom: '企业微信',
weixin: '微信',
qq: 'QQ',
telegram: 'Telegram',
discord: 'Discord',
slack: 'Slack',
office: 'AI Office',
});
/**
* Session-header chip: channel peer for IM-bound conversations.
* Renders nothing when the current session is not bound to a channel chat.
* @param {{
* sessionId: string,
* resolveChannelPeer?: (sessionId: string, signal?: AbortSignal) => Promise<object|null>,
* }} props
*/
export function ChannelPeerLabel({ sessionId, resolveChannelPeer }) {
const [peer, setPeer] = React.useState(null);
React.useEffect(() => {
if (typeof resolveChannelPeer !== 'function' || typeof sessionId !== 'string' || !sessionId) {
setPeer(null);
return undefined;
}
const controller = new AbortController();
let active = true;
void (async () => {
try {
const next = await resolveChannelPeer(sessionId, controller.signal);
if (active && !controller.signal.aborted) setPeer(next && typeof next === 'object' ? next : null);
} catch {
if (active && !controller.signal.aborted) setPeer(null);
}
})();
return () => {
active = false;
controller.abort();
};
}, [sessionId, resolveChannelPeer]);
if (!peer?.label) return null;
const channelName = CHANNEL_LABELS[peer.channel] || peer.channel || 'IM';
const title = peer.kind === 'group'
? `${channelName} · ${peer.label}`
: `${channelName} · ${peer.label}`;
return h('span', {
className: 'dim-channelPeerLabel',
title,
'aria-label': localizeText('渠道用户') + ` · ${title}`,
},
h('span', { className: 'dim-channelPeerChannel', 'aria-hidden': 'true' }, channelName),
h('span', { className: 'dim-channelPeerText' }, peer.label));
}
/**
* Resolve the channel peer for a session via WhatsApp (ops-first).
* Returns null when the session is not bound to a WhatsApp conversation.
* @param {(endpoint: string, payload: object, signal?: AbortSignal) => Promise<unknown>} whatsappRpcCall
* @param {string} sessionId
* @param {AbortSignal} [signal]
*/
export async function resolveWhatsappChannelPeer(whatsappRpcCall, sessionId, signal) {
if (typeof whatsappRpcCall !== 'function') return null;
const result = await whatsappRpcCall(
WHATSAPP_ENDPOINTS.resolveChannelPeer,
{ sessionId },
signal,
);
const value = unwrapRpcResult(result);
if (!value || typeof value !== 'object') return null;
const label = typeof value.label === 'string' ? value.label.trim() : '';
if (!label) return null;
return {
channel: typeof value.channel === 'string' ? value.channel : 'whatsapp',
botId: typeof value.botId === 'string' ? value.botId : '',
kind: value.kind === 'group' ? 'group' : 'direct',
label,
phone: typeof value.phone === 'string' ? value.phone : null,
pushName: typeof value.pushName === 'string' ? value.pushName : null,
groupTitle: typeof value.groupTitle === 'string' ? value.groupTitle : null,
};
}

View file

@ -22,6 +22,7 @@ export const WHATSAPP_ENDPOINTS = Object.freeze({
setWorkspace: 'bot.workspace.set',
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
setContextEnhancement: 'bot.context-enhancement.set',
resolveChannelPeer: 'bot.session.channel-peer',
});
const PROVISION_STATES = new Set(['starting', 'pending', 'connecting', 'connected', 'failed', 'cancelled']);

View file

@ -11,6 +11,7 @@ const EN = Object.freeze({
'机器人设置页签': 'Bot settings tabs',
'投递设置': 'Delivery settings',
'访问设置': 'Access settings',
'渠道用户': 'Channel user',
"(不可用)": " (unavailable)",
"群 Agent Preset": "Group Agent Preset",
"跟随全局": "Follow global",

View file

@ -44,6 +44,10 @@ import { WhatsappSettingsTab } from './channels/whatsapp/index.js';
import { installWhatsappStyles } from './channels/whatsapp/styles.js';
import { en, h, IM_LOCALE_NAMESPACE, setImTranslator, zh } from './i18n.js';
import { BotSettingsContext } from './channel-card-meta.js';
import {
ChannelPeerLabel,
resolveWhatsappChannelPeer,
} from './channel-peer-label.js';
import {
DELIVERY_RPC_CHANNEL,
DeliveryTargetSettingsPage,
@ -376,6 +380,18 @@ export function apply(ctx) {
pickDirectory: () => callWorkspaceDirectoryApi(ctx, 'pickDirectory'),
});
const resolveChannelPeer = (sessionId, signal) =>
resolveWhatsappChannelPeer(whatsappRpcCall, sessionId, signal);
ctx.slots.inject('conversation.session.header.actions', () => ctx.slots.register({
name: 'conversation.session.header.actions',
id: 'im-channel-peer',
// Immediately after Agent Preset (−10); schedule/jobs follow at 10+.
order: 0,
locale: IM_LOCALE_NAMESPACE,
inject: () => ({ resolveChannelPeer }),
}, ChannelPeerLabel));
ctx.slots.inject('settings.section', () => ctx.slots.register({
name: 'settings.section',
id: 'xmanrui-dsh-im',

View file

@ -537,6 +537,34 @@ body[data-ds-dark-theme] .dim-page select option, body[data-ds-dark-theme] .dim-
.dim-accessGroupTitleRow { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) max-content; gap: 10px; align-items: end; }
.dim-accessNickname { display: block; margin-top: 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 11px; line-height: 15px; font-weight: 560; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dim-accessNicknameEmpty { color: var(--dsw-alias-label-tertiary, #8f959e); font-weight: 400; }
.dim-channelPeerLabel {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 260px;
min-width: 0;
height: 22px;
padding: 0 8px 0 6px;
border-radius: 6px;
background: var(--dsw-alias-fill-tsp-secondary, color-mix(in srgb, var(--dsw-alias-label-primary, #1f2329) 6%, transparent));
color: var(--dsw-alias-label-secondary, #646a73);
font-size: 12px;
line-height: 22px;
white-space: nowrap;
overflow: hidden;
}
.dim-channelPeerChannel {
flex: none;
color: var(--dsw-alias-label-tertiary, #8f959e);
font-weight: 560;
}
.dim-channelPeerText {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
color: var(--dsw-alias-label-secondary, #646a73);
}
.dim-accessUserRow { position: relative; z-index: 0; overflow: visible; }
.dim-panel .dim-botCard .dim-cardFooter { margin-top: 0; }
.dim-panel .ddt-headingCopy { display: none; }