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

@ -566,12 +566,12 @@ __export(index_exports, {
name: () => name
});
module.exports = __toCommonJS(index_exports);
var React24 = __toESM(require("react"), 1);
var React25 = __toESM(require("react"), 1);
// package.json
var package_default = {
name: "dsh-im-ops",
version: "4.9.1-ops.19",
version: "4.9.1-ops.20",
description: "Ops fork of dsh-im (all channels kept). Based on @xmanrui/dsh-im@4.9.1 \u2014 access/session policies owned here.",
keywords: [
"deepseek-harness",
@ -852,6 +852,7 @@ var EN = Object.freeze({
"\u673A\u5668\u4EBA\u8BBE\u7F6E\u9875\u7B7E": "Bot settings tabs",
"\u6295\u9012\u8BBE\u7F6E": "Delivery settings",
"\u8BBF\u95EE\u8BBE\u7F6E": "Access settings",
"\u6E20\u9053\u7528\u6237": "Channel user",
"\uFF08\u4E0D\u53EF\u7528\uFF09": " (unavailable)",
"\u7FA4 Agent Preset": "Group Agent Preset",
"\u8DDF\u968F\u5168\u5C40": "Follow global",
@ -11353,7 +11354,8 @@ var WHATSAPP_ENDPOINTS = Object.freeze({
setGroupSessionScope: "bot.group-session-scope.set",
setWorkspace: "bot.workspace.set",
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
setContextEnhancement: "bot.context-enhancement.set"
setContextEnhancement: "bot.context-enhancement.set",
resolveChannelPeer: "bot.session.channel-peer"
});
var PROVISION_STATES5 = /* @__PURE__ */ new Set(["starting", "pending", "connecting", "connected", "failed", "cancelled"]);
var BOT_STATES = /* @__PURE__ */ new Set(["connected", "connecting", "offline", "error"]);
@ -12113,11 +12115,83 @@ function WhatsappSettingsTab({ rpcCall }) {
));
}
// plugin-src/client/channel-peer-label.js
var React20 = __toESM(require("react"), 1);
var CHANNEL_LABELS = Object.freeze({
whatsapp: "WhatsApp",
feishu: "\u98DE\u4E66",
dingtalk: "\u9489\u9489",
wecom: "\u4F01\u4E1A\u5FAE\u4FE1",
weixin: "\u5FAE\u4FE1",
qq: "QQ",
telegram: "Telegram",
discord: "Discord",
slack: "Slack",
office: "AI Office"
});
function ChannelPeerLabel({ sessionId, resolveChannelPeer }) {
const [peer, setPeer] = React20.useState(null);
React20.useEffect(() => {
if (typeof resolveChannelPeer !== "function" || typeof sessionId !== "string" || !sessionId) {
setPeer(null);
return void 0;
}
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 channelName2 = CHANNEL_LABELS[peer.channel] || peer.channel || "IM";
const title = peer.kind === "group" ? `${channelName2} \xB7 ${peer.label}` : `${channelName2} \xB7 ${peer.label}`;
return h2(
"span",
{
className: "dim-channelPeerLabel",
title,
"aria-label": localizeText("\u6E20\u9053\u7528\u6237") + ` \xB7 ${title}`
},
h2("span", { className: "dim-channelPeerChannel", "aria-hidden": "true" }, channelName2),
h2("span", { className: "dim-channelPeerText" }, peer.label)
);
}
async function resolveWhatsappChannelPeer(whatsappRpcCall, sessionId, signal) {
if (typeof whatsappRpcCall !== "function") return null;
const result = await whatsappRpcCall(
WHATSAPP_ENDPOINTS.resolveChannelPeer,
{ sessionId },
signal
);
const value = unwrapRpcResult9(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
};
}
// plugin-src/client/delivery-settings.js
var React22 = __toESM(require("react"), 1);
var React23 = __toESM(require("react"), 1);
// plugin-src/client/access-policy-settings.js
var React20 = __toESM(require("react"), 1);
var React21 = __toESM(require("react"), 1);
var ACCESS_POLICY_ENDPOINT = "bot.access-policy.set";
var GROUP_SESSION_SCOPE_ENDPOINT = "bot.group-session-scope.set";
var ACCESS_CHANNEL_DEFINITIONS = Object.freeze({
@ -12220,8 +12294,8 @@ function ScenePolicyEditor({
unsupported = false,
onChange
}) {
const ownerHelpId = React20.useId();
const emptyAllowlistHelpId = React20.useId();
const ownerHelpId = React21.useId();
const emptyAllowlistHelpId = React21.useId();
const allowlist = policy.mode === "allowlist";
const collectionKey = allowlist ? "users" : "commandPermissionOverrides";
const branchKey = allowlist ? "allowlist" : "open";
@ -12273,7 +12347,7 @@ function ScenePolicyEditor({
h2("strong", null, "\u5F53\u524D\u6E20\u9053\u4E0D\u652F\u6301\u7FA4\u804A"),
h2("p", null, "\u6B64\u533A\u57DF\u65E0\u9700\u914D\u7F6E\uFF0C\u4FDD\u5B58\u79C1\u804A\u8BBE\u7F6E\u65F6\u4F1A\u4FDD\u7559\u73B0\u6709\u7FA4\u804A\u7B56\u7565\u3002")
) : h2(
React20.Fragment,
React21.Fragment,
null,
h2(
"div",
@ -12415,18 +12489,18 @@ function AccessPolicySettingsPage({ channel: channel4, account, rpcCall, onSaved
const initialPolicy = normalizeAccessPolicy(account?.accessPolicy);
const initialKey = JSON.stringify(initialPolicy);
const initialScope = normalizeGroupSessionScope(account?.groupSessionScope);
const [draft, setDraft] = React20.useState(() => clonePolicy(
const [draft, setDraft] = React21.useState(() => clonePolicy(
initialPolicy ?? DEFAULT_ACCESS_POLICY
));
const [groupSessionScope, setGroupSessionScope] = React20.useState(initialScope);
const [saving, setSaving] = React20.useState(false);
const [feedback, setFeedback] = React20.useState(null);
React20.useEffect(() => {
const [groupSessionScope, setGroupSessionScope] = React21.useState(initialScope);
const [saving, setSaving] = React21.useState(false);
const [feedback, setFeedback] = React21.useState(null);
React21.useEffect(() => {
const next = normalizeAccessPolicy(account?.accessPolicy);
setDraft(clonePolicy(next ?? DEFAULT_ACCESS_POLICY));
setGroupSessionScope(normalizeGroupSessionScope(account?.groupSessionScope));
}, [account?.botId, initialKey, account?.groupSessionScope]);
React20.useEffect(() => {
React21.useEffect(() => {
setFeedback(null);
}, [account?.botId]);
if (!definition) {
@ -12579,7 +12653,7 @@ function AccessPolicySettingsPage({ channel: channel4, account, rpcCall, onSaved
}
// plugin-src/client/access-grant-settings.js
var React21 = __toESM(require("react"), 1);
var React22 = __toESM(require("react"), 1);
var ACCESS_GRANT_ENDPOINT = "bot.access-grant.set";
var ACCESS_PENDING_RESOLVE_ENDPOINT = "bot.access-pending.resolve";
var ACCESS_GROUP_TITLES_REFRESH_ENDPOINT = "bot.access-group-titles.refresh";
@ -12671,13 +12745,13 @@ function PhoneTypeahead({
disabled,
requirePhone = true
}) {
const [query, setQuery] = React21.useState(value || "");
const [open, setOpen] = React21.useState(false);
const blurTimer = React21.useRef(null);
React21.useEffect(() => {
const [query, setQuery] = React22.useState(value || "");
const [open, setOpen] = React22.useState(false);
const blurTimer = React22.useRef(null);
React22.useEffect(() => {
setQuery(value || "");
}, [value]);
React21.useEffect(() => () => {
React22.useEffect(() => () => {
if (blurTimer.current) window.clearTimeout(blurTimer.current);
}, []);
const q = query.trim().toLowerCase();
@ -12985,21 +13059,21 @@ function AccessGrantSettingsPage({ channel: channel4, account, rpcCall, onSaved
const initialGrant = normalizeAccessGrant(account?.accessGrant);
const initialKey = JSON.stringify(initialGrant);
const initialScope = normalizeGroupSessionScope(account?.groupSessionScope);
const [draft, setDraft] = React21.useState(() => cloneGrant(initialGrant, ownerPhone));
const [groupSessionScope, setGroupSessionScope] = React21.useState(initialScope);
const [saving, setSaving] = React21.useState(false);
const [feedback, setFeedback] = React21.useState(null);
const [newGroupJid, setNewGroupJid] = React21.useState("");
const [loadingGrant, setLoadingGrant] = React21.useState(!initialGrant);
const contextCatalog = React21.useContext(AgentPresetCatalogContext);
const [presetCatalog, setPresetCatalog] = React21.useState(() => normalizeAgentPresetCatalog(
const [draft, setDraft] = React22.useState(() => cloneGrant(initialGrant, ownerPhone));
const [groupSessionScope, setGroupSessionScope] = React22.useState(initialScope);
const [saving, setSaving] = React22.useState(false);
const [feedback, setFeedback] = React22.useState(null);
const [newGroupJid, setNewGroupJid] = React22.useState("");
const [loadingGrant, setLoadingGrant] = React22.useState(!initialGrant);
const contextCatalog = React22.useContext(AgentPresetCatalogContext);
const [presetCatalog, setPresetCatalog] = React22.useState(() => normalizeAgentPresetCatalog(
account?.agentPresetCatalog ?? contextCatalog ?? EMPTY_AGENT_PRESET_CATALOG
));
React21.useEffect(() => {
React22.useEffect(() => {
setDraft(cloneGrant(normalizeAccessGrant(account?.accessGrant), ownerPhone));
setGroupSessionScope(normalizeGroupSessionScope(account?.groupSessionScope));
}, [account?.botId, initialKey, account?.groupSessionScope, ownerPhone]);
React21.useEffect(() => {
React22.useEffect(() => {
if (typeof rpcCall !== "function" || !account?.botId) return void 0;
let cancelled = false;
setLoadingGrant(true);
@ -13053,7 +13127,7 @@ function AccessGrantSettingsPage({ channel: channel4, account, rpcCall, onSaved
};
}, [account?.botId, rpcCall, ownerPhone]);
const contacts = draft.contacts ?? [];
const knownGroupJids = React21.useMemo(() => {
const knownGroupJids = React22.useMemo(() => {
const set = new Set(Object.keys(draft.groups ?? {}));
for (const contact of contacts) {
for (const jid of contact.groupJids ?? []) set.add(jid);
@ -13063,7 +13137,7 @@ function AccessGrantSettingsPage({ channel: channel4, account, rpcCall, onSaved
}
return [...set].sort();
}, [draft.groups, draft.pending, contacts]);
const actionableContacts = React21.useMemo(
const actionableContacts = React22.useMemo(
() => contacts.filter((contact) => contactNeedsGrantAction(contact, draft, knownGroupJids)),
[contacts, draft, knownGroupJids]
);
@ -13742,13 +13816,13 @@ function TargetForm({
}) {
const editing = mode === "edit";
const initialKind = initialValue?.kind && definition.fields[initialValue.kind] ? initialValue.kind : definition.kinds[0].value;
const [targetId, setTargetId] = React22.useState(initialValue?.targetId ?? "");
const [name2, setName] = React22.useState(initialValue?.name ?? "");
const [kind, setKind] = React22.useState(initialKind);
const [route, setRoute] = React22.useState(initialValue?.route ?? {});
const [error, setError] = React22.useState(null);
const [testing, setTesting] = React22.useState(false);
const [testState, setTestState] = React22.useState(null);
const [targetId, setTargetId] = React23.useState(initialValue?.targetId ?? "");
const [name2, setName] = React23.useState(initialValue?.name ?? "");
const [kind, setKind] = React23.useState(initialKind);
const [route, setRoute] = React23.useState(initialValue?.route ?? {});
const [error, setError] = React23.useState(null);
const [testing, setTesting] = React23.useState(false);
const [testState, setTestState] = React23.useState(null);
const currentTarget = () => {
const normalizedRoute = Object.fromEntries(fieldsFor(definition, kind).map((field) => {
const raw = String(route[field.key] ?? "").trim();
@ -13965,7 +14039,7 @@ function TargetSuggestionPicker({
suggestions.map((suggestion, index) => {
const identity = routeIdentity(definition, suggestion);
const added = configured.has(identity);
return React22.createElement("option", {
return React23.createElement("option", {
key: suggestion.id ?? suggestion.suggestionId ?? `${identity}:${index}`,
value: String(index),
disabled: added
@ -13982,10 +14056,10 @@ function TargetSuggestionPicker({
);
}
function TargetRow({ definition, target, botId, connected, rpcCall, onChanged, onEdit }) {
const [action, setAction] = React22.useState(null);
const [testState, setTestState] = React22.useState(null);
const [copyState, setCopyState] = React22.useState(null);
const [confirmDelete, setConfirmDelete] = React22.useState(false);
const [action, setAction] = React23.useState(null);
const [testState, setTestState] = React23.useState(null);
const [copyState, setCopyState] = React23.useState(null);
const [confirmDelete, setConfirmDelete] = React23.useState(false);
const testTarget = async () => {
setAction("test");
setTestState(null);
@ -14027,7 +14101,7 @@ function TargetRow({ definition, target, botId, connected, rpcCall, onChanged, o
h2(
"div",
{ className: "dim-targetTitle" },
React22.createElement("strong", null, target.name || target.targetId),
React23.createElement("strong", null, target.name || target.targetId),
h2("span", null, kindLabel(definition, target.kind))
),
h2("code", null, `targetId: ${target.targetId}`)
@ -14090,28 +14164,28 @@ function DeliveryTargetSettingsPage({
onBack
}) {
const definition = CHANNEL_DEFINITIONS[channel4];
const [activeTabId, setActiveTabId] = React22.useState(BOT_SETTINGS_TABS[0].id);
const [phase, setPhase] = React22.useState("loading");
const [targets, setTargets] = React22.useState([]);
const [suggestionPhase, setSuggestionPhase] = React22.useState("idle");
const [suggestions, setSuggestions] = React22.useState([]);
const [suggestionError, setSuggestionError] = React22.useState(null);
const [error, setError] = React22.useState(null);
const [editor, setEditor] = React22.useState(null);
const [saving, setSaving] = React22.useState(false);
const [botCopyState, setBotCopyState] = React22.useState(null);
const [accessPolicy, setAccessPolicy] = React22.useState(account.accessPolicy);
const [accessGrant, setAccessGrant] = React22.useState(account.accessGrant);
const mounted = React22.useRef(true);
React22.useEffect(() => {
const [activeTabId, setActiveTabId] = React23.useState(BOT_SETTINGS_TABS[0].id);
const [phase, setPhase] = React23.useState("loading");
const [targets, setTargets] = React23.useState([]);
const [suggestionPhase, setSuggestionPhase] = React23.useState("idle");
const [suggestions, setSuggestions] = React23.useState([]);
const [suggestionError, setSuggestionError] = React23.useState(null);
const [error, setError] = React23.useState(null);
const [editor, setEditor] = React23.useState(null);
const [saving, setSaving] = React23.useState(false);
const [botCopyState, setBotCopyState] = React23.useState(null);
const [accessPolicy, setAccessPolicy] = React23.useState(account.accessPolicy);
const [accessGrant, setAccessGrant] = React23.useState(account.accessGrant);
const mounted = React23.useRef(true);
React23.useEffect(() => {
setAccessPolicy(account.accessPolicy);
setAccessGrant(account.accessGrant);
}, [account.botId, account.accessPolicy, account.accessGrant]);
const invoke = React22.useCallback(async (endpoint, payload = {}, signal) => {
const invoke = React23.useCallback(async (endpoint, payload = {}, signal) => {
if (typeof rpcCall !== "function") throw new Error("\u6295\u9012\u76EE\u6807\u8BBE\u7F6E\u6682\u4E0D\u53EF\u7528\u3002");
return unwrapRpcResult12(await rpcCall(endpoint, payload, signal));
}, [rpcCall]);
const loadTargets = React22.useCallback(async ({ signal, silent = false } = {}) => {
const loadTargets = React23.useCallback(async ({ signal, silent = false } = {}) => {
if (!silent) setPhase("loading");
setError(null);
try {
@ -14125,7 +14199,7 @@ function DeliveryTargetSettingsPage({
setPhase("error");
}
}, [account.botId, invoke]);
const loadSuggestions = React22.useCallback(async () => {
const loadSuggestions = React23.useCallback(async () => {
setSuggestionPhase("loading");
setSuggestionError(null);
try {
@ -14139,7 +14213,7 @@ function DeliveryTargetSettingsPage({
setSuggestionPhase("error");
}
}, [account.botId, definition, invoke]);
React22.useEffect(() => {
React23.useEffect(() => {
mounted.current = true;
const controller = new AbortController();
void loadTargets({ signal: controller.signal });
@ -14266,7 +14340,7 @@ function DeliveryTargetSettingsPage({
rpcCall: accessRpcCall,
onSaved: setAccessPolicy
}) : h2(
React22.Fragment,
React23.Fragment,
null,
h2(
"section",
@ -14970,6 +15044,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; }
@ -15052,7 +15154,7 @@ function installImStyles() {
}
// plugin-src/client/update-panel.js
var React23 = __toESM(require("react"), 1);
var React24 = __toESM(require("react"), 1);
var import_react_dom3 = require("react-dom");
var import_valid = __toESM(require_valid(), 1);
var import_rcompare = __toESM(require_rcompare(), 1);
@ -15142,11 +15244,11 @@ function manualUpdateCommand(snapshot) {
return `dsh plugin --profile ${profileArgument} add -w dsh-im-ops@${version}`;
}
function ManualUpdateCommand({ command, disabled, sourceInstall, desktop }) {
const [copyState, setCopyState] = React23.useState("idle");
const commandRef = React23.useRef(null);
const mounted = React23.useRef(false);
const copying = React23.useRef(false);
React23.useEffect(() => {
const [copyState, setCopyState] = React24.useState("idle");
const commandRef = React24.useRef(null);
const mounted = React24.useRef(false);
const copying = React24.useRef(false);
React24.useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
@ -15178,7 +15280,7 @@ function ManualUpdateCommand({ command, disabled, sourceInstall, desktop }) {
{ className: "dim-updateManual", "aria-label": "\u624B\u5DE5\u66F4\u65B0" },
h2("h4", { className: "dim-updateManualHeading" }, "\u624B\u5DE5\u66F4\u65B0"),
command ? h2(
React23.Fragment,
React24.Fragment,
null,
h2("p", { className: "dim-updateManualHint" }, "\u81EA\u52A8\u66F4\u65B0\u5931\u8D25\u53EF\u4EE5\u4F7F\u7528\u547D\u4EE4\u66F4\u65B0\uFF1A"),
h2(
@ -15214,7 +15316,7 @@ function ManualUpdateCommand({ command, disabled, sourceInstall, desktop }) {
"aria-hidden": "true",
focusable: "false"
}, copyState === "copied" ? h2("path", { d: "m4 10 4 4 8-8" }) : h2(
React23.Fragment,
React24.Fragment,
null,
h2("rect", { x: 7, y: 7, width: 10, height: 11, rx: 1.5 }),
h2("path", { d: "M5 13H3.5A1.5 1.5 0 0 1 2 11.5v-8A1.5 1.5 0 0 1 3.5 2h8A1.5 1.5 0 0 1 13 3.5V5" })
@ -15239,10 +15341,10 @@ function ManualUpdateCommand({ command, disabled, sourceInstall, desktop }) {
);
}
function UpdateDialog({ children, onClose }) {
const dialogRef = React23.useRef(null);
const titleId = React23.useId();
const descriptionId = React23.useId();
React23.useEffect(() => {
const dialogRef = React24.useRef(null);
const titleId = React24.useId();
const descriptionId = React24.useId();
React24.useEffect(() => {
const previous = globalThis.document?.activeElement;
dialogRef.current?.focus?.();
return () => {
@ -15300,19 +15402,19 @@ function UpdateDialog({ children, onClose }) {
return typeof document !== "undefined" && document.body ? (0, import_react_dom3.createPortal)(content, document.body) : content;
}
function UpdatePanel({ rpcCall, clientVersion, onStatus }) {
const [snapshot, setSnapshot] = React23.useState(null);
const [action, setAction] = React23.useState("status");
const [error, setError] = React23.useState(null);
const [open, setOpen] = React23.useState(false);
const [uncertainInstall, setUncertainInstall] = React23.useState(false);
const mounted = React23.useRef(false);
const busy = React23.useRef(false);
const readController = React23.useRef(null);
const pollReadController = React23.useRef(null);
const installRequest = React23.useRef(null);
const onStatusRef = React23.useRef(onStatus);
const [snapshot, setSnapshot] = React24.useState(null);
const [action, setAction] = React24.useState("status");
const [error, setError] = React24.useState(null);
const [open, setOpen] = React24.useState(false);
const [uncertainInstall, setUncertainInstall] = React24.useState(false);
const mounted = React24.useRef(false);
const busy = React24.useRef(false);
const readController = React24.useRef(null);
const pollReadController = React24.useRef(null);
const installRequest = React24.useRef(null);
const onStatusRef = React24.useRef(onStatus);
onStatusRef.current = onStatus;
const accept = React23.useCallback((next) => {
const accept = React24.useCallback((next) => {
setSnapshot(next);
onStatusRef.current?.(next);
}, []);
@ -15321,7 +15423,7 @@ function UpdatePanel({ rpcCall, clientVersion, onStatus }) {
setUncertainInstall(false);
accept(next);
};
const invoke = React23.useCallback(async (endpoint, payload = {}, signal) => {
const invoke = React24.useCallback(async (endpoint, payload = {}, signal) => {
if (typeof rpcCall !== "function") {
const unavailable = new Error("\u5F53\u524D Host \u4E0D\u652F\u6301\u66F4\u65B0\u63A5\u53E3\uFF0C\u8BF7\u5148\u624B\u52A8\u66F4\u65B0\u63D2\u4EF6\u5E76\u91CD\u542F\u3002");
unavailable.code = "update-unavailable";
@ -15329,7 +15431,7 @@ function UpdatePanel({ rpcCall, clientVersion, onStatus }) {
}
return unwrapSnapshot(await rpcCall(endpoint, payload, signal));
}, [rpcCall]);
React23.useEffect(() => {
React24.useEffect(() => {
mounted.current = true;
busy.current = true;
const controller = new AbortController();
@ -15352,7 +15454,7 @@ function UpdatePanel({ rpcCall, clientVersion, onStatus }) {
const activeJob = ACTIVE_STATES4.has(snapshot?.job?.state);
const restartRequired = snapshot?.job?.state === "restart-required" || snapshot?.blockedReason === "pending-restart";
const shouldPoll = activeJob || uncertainInstall;
React23.useEffect(() => {
React24.useEffect(() => {
if (!shouldPoll) return void 0;
let controller;
const scheduler = createPollScheduler({
@ -15450,7 +15552,7 @@ function UpdatePanel({ rpcCall, clientVersion, onStatus }) {
const manualCommand = manualUpdateCommand(snapshot);
const buttonLabel = action === "checking" ? "\u68C0\u67E5\u4E2D\u2026" : action === "starting" || activeJob ? "\u6B63\u5728\u66F4\u65B0\u2026" : restartRequired ? "\u5F85\u624B\u52A8\u91CD\u542F" : snapshot?.canInstall ? "\u66F4\u65B0\u81F3" : "\u68C0\u67E5\u66F4\u65B0";
return h2(
React23.Fragment,
React24.Fragment,
null,
h2("button", {
type: "button",
@ -15475,13 +15577,13 @@ function UpdatePanel({ rpcCall, clientVersion, onStatus }) {
h2("dt", null, "\u8FD0\u884C\u7248\u672C"),
h2("dd", null, `v${snapshot?.runningVersion ?? clientVersion}`),
snapshot?.installedVersion && snapshot.installedVersion !== snapshot.runningVersion ? h2(
React23.Fragment,
React24.Fragment,
null,
h2("dt", null, "\u5DF2\u5B89\u88C5\u7248\u672C"),
h2("dd", null, `v${snapshot.installedVersion}`)
) : null,
targetVersion ? h2(
React23.Fragment,
React24.Fragment,
null,
h2("dt", null, "\u76EE\u6807\u7248\u672C"),
h2("dd", null, `v${targetVersion}`)
@ -15685,20 +15787,20 @@ function IMSettingsTab({
browserLocation = globalThis.location,
navigateToRecoveryUrl = replacePageLocation
}) {
const [selected, setSelected] = React24.useState("weixin");
const [loopbackRecovery, setLoopbackRecovery] = React24.useState(null);
const [runningVersion, setRunningVersion] = React24.useState(IM_PLUGIN_VERSION);
const [deliverySettings, setDeliverySettings] = React24.useState(null);
const githubTooltipId = React24.useId();
const channelSelectId = React24.useId();
const [selected, setSelected] = React25.useState("weixin");
const [loopbackRecovery, setLoopbackRecovery] = React25.useState(null);
const [runningVersion, setRunningVersion] = React25.useState(IM_PLUGIN_VERSION);
const [deliverySettings, setDeliverySettings] = React25.useState(null);
const githubTooltipId = React25.useId();
const channelSelectId = React25.useId();
const active = CHANNELS.find((channel4) => channel4.id === selected) ?? CHANNELS[0];
const reportLoopbackRecovery = React24.useCallback((recovery) => {
const reportLoopbackRecovery = React25.useCallback((recovery) => {
setLoopbackRecovery((current) => current?.url === recovery.url ? current : recovery);
}, []);
const reportUpdateStatus = React24.useCallback((snapshot) => {
const reportUpdateStatus = React25.useCallback((snapshot) => {
setRunningVersion(snapshot.runningVersion);
}, []);
const rpcCalls = React24.useMemo(() => createLoopbackAwareRpcCalls({
const rpcCalls = React25.useMemo(() => createLoopbackAwareRpcCalls({
dingtalkRpcCall,
discordRpcCall,
feishuRpcCall,
@ -15730,7 +15832,7 @@ function IMSettingsTab({
weixinRpcCall,
whatsappRpcCall
]);
const botSettingsContext = React24.useMemo(() => Object.freeze({
const botSettingsContext = React25.useMemo(() => Object.freeze({
openBotSettings: setDeliverySettings
}), []);
return h2(
@ -15881,6 +15983,15 @@ function apply(ctx) {
listDirectory: (path, signal) => callWorkspaceDirectoryApi(ctx, "listDirectory", path, signal),
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",

File diff suppressed because one or more lines are too long

View file

@ -1,6 +1,6 @@
{
"name": "dsh-im-ops",
"version": "4.9.1-ops.19",
"version": "4.9.1-ops.20",
"description": "Ops fork of dsh-im (all channels kept). Based on @xmanrui/dsh-im@4.9.1 — access/session policies owned here.",
"keywords": [
"deepseek-harness",

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; }

View file

@ -158,6 +158,7 @@ export async function createProductionController(ctx, config = {}, internals = {
workspaces,
stateFor,
agentPresetCatalog,
channel: 'whatsapp',
});
const supervisor = createSupervisor({
channel: 'whatsapp',

View file

@ -32,6 +32,7 @@ export const WHATSAPP_ENDPOINTS = Object.freeze({
setWorkspace: SET_WORKSPACE_ENDPOINT,
setAgentPreset: SET_AGENT_PRESET_ENDPOINT,
setContextEnhancement: SET_CONTEXT_ENHANCEMENT_ENDPOINT,
resolveChannelPeer: 'bot.session.channel-peer',
});
export const WHATSAPP_RPC_ENDPOINTS = Object.freeze(Object.values(WHATSAPP_ENDPOINTS));
@ -99,6 +100,13 @@ function payloadFailure(endpoint, payload) {
return validContextEnhancementPayload(payload)
? null : '请提交有效的上下文增强设置。';
}
if (endpoint === WHATSAPP_ENDPOINTS.resolveChannelPeer) {
return exactKeys(payload, ['sessionId'])
&& typeof payload.sessionId === 'string'
&& payload.sessionId.trim()
&& payload.sessionId.trim().length <= 256
? null : 'bot.session.channel-peer requires a sessionId.';
}
return 'Unknown WhatsApp endpoint.';
}
@ -252,6 +260,11 @@ export function createWhatsappRpcHandler(controller, { encodeQr = qrDataUrl } =
payload.groupSessionScope,
(status) => publicStatus(status, cachedEncode),
);
} else if (endpoint === WHATSAPP_ENDPOINTS.resolveChannelPeer) {
if (typeof controller.resolveChannelPeer !== 'function') {
throw new Error('Channel peer resolve is unavailable');
}
value = await controller.resolveChannelPeer(payload.sessionId.trim());
} else {
value = await publicStatus(await controller.deleteBot(payload.botId), cachedEncode);
}

View file

@ -28,6 +28,7 @@ import {
resolveAccessPending,
validateAccessGrant,
} from './access-grant.mjs';
import { resolveChannelPeerFromBinding } from './channel-peer.mjs';
import { CONNECTION_TEST_STATE_IDENTITY } from './connection-test.mjs';
import {
DEFAULT_CONTEXT_ENHANCEMENT_CONFIG,
@ -1458,10 +1459,16 @@ export function createBotScopedHarness(harness, options) {
return createBotWorkspaceScope(harness, options).harness;
}
export function createWorkspaceAwareController(controller, { workspaces, stateFor, agentPresetCatalog } = {}) {
export function createWorkspaceAwareController(controller, {
workspaces,
stateFor,
agentPresetCatalog,
channel = null,
} = {}) {
if (!controller || !workspaces || typeof stateFor !== 'function') {
throw new TypeError('controller, workspaces, and stateFor are required');
}
const channelId = typeof channel === 'string' && channel.trim() ? channel.trim() : null;
const transitions = new Map();
const withBotTransition = (botId, operation) => {
const previous = transitions.get(botId) ?? Promise.resolve();
@ -1674,6 +1681,35 @@ export function createWorkspaceAwareController(controller, { workspaces, stateFo
}
});
const resolveChannelPeer = async (sessionId) => {
if (!channelId) return null;
const id = typeof sessionId === 'string' ? sessionId.trim() : '';
if (!id || id.length > 256) return null;
const snapshot = await controller.status();
const bots = Array.isArray(snapshot?.bots) ? snapshot.bots : [];
for (const bot of bots) {
const botId = typeof bot?.botId === 'string' ? bot.botId.trim() : '';
if (!botId) continue;
let state;
try {
state = await stateFor(botId);
} catch {
continue;
}
const conversationKey = typeof state?.keyForSession === 'function'
? state.keyForSession(id)
: null;
if (!conversationKey) continue;
return resolveChannelPeerFromBinding({
channel: channelId,
botId,
conversationKey,
grant: workspaces.accessGrantFor(botId),
});
}
return null;
};
return new Proxy(controller, {
get(target, property) {
if (property === 'updateWorkspace') return updateWorkspace;
@ -1683,6 +1719,7 @@ export function createWorkspaceAwareController(controller, { workspaces, stateFo
if (property === 'updateGroupSessionScope') return updateGroupSessionScope;
if (property === 'updateAccessGrant') return updateAccessGrant;
if (property === 'resolveAccessPending') return resolveAccessPendingProxy;
if (property === 'resolveChannelPeer') return resolveChannelPeer;
const value = Reflect.get(target, property, target);
if (typeof value !== 'function') return value;
if (property === 'deleteBot') {

View file

@ -0,0 +1,115 @@
import { normalizeAccessPhone, phoneFromWhatsappJid } from './access-grant.mjs';
/**
* Parse a durable conversation binding key into chat identity fields.
* @param {unknown} key
* @returns {{ kind: 'direct' | 'group', conversationId: string, senderId: string | null } | null}
*/
export function parseConversationKey(key) {
if (typeof key !== 'string' || !key) return null;
if (key.startsWith('direct:')) {
const conversationId = key.slice('direct:'.length).trim();
return conversationId ? { kind: 'direct', conversationId, senderId: null } : null;
}
if (!key.startsWith('group:')) return null;
const rest = key.slice('group:'.length);
const userMarker = ':user:';
const userAt = rest.indexOf(userMarker);
if (userAt >= 0) {
const conversationId = rest.slice(0, userAt).trim();
const senderId = rest.slice(userAt + userMarker.length).trim();
if (!conversationId || !senderId) return null;
return { kind: 'group', conversationId, senderId };
}
const conversationId = rest.trim();
return conversationId ? { kind: 'group', conversationId, senderId: null } : null;
}
function contactPushName(grant, phone) {
if (!phone || !grant || !Array.isArray(grant.contacts)) return '';
const hit = grant.contacts.find((contact) => contact?.phone === phone);
return typeof hit?.pushName === 'string' ? hit.pushName.trim() : '';
}
function groupTitle(grant, groupJid) {
const title = grant?.groups?.[groupJid]?.title;
return typeof title === 'string' && title.trim() ? title.trim() : '';
}
function phoneFromIdentity(value) {
return phoneFromWhatsappJid(value) ?? normalizeAccessPhone(value);
}
/**
* Build a public channel-peer summary for session-header display.
* @param {{
* channel: string,
* botId: string,
* conversationKey: string,
* grant?: object | null,
* }} input
* @returns {object | null}
*/
export function resolveChannelPeerFromBinding(input) {
const channel = typeof input?.channel === 'string' ? input.channel.trim() : '';
const botId = typeof input?.botId === 'string' ? input.botId.trim() : '';
const conversationKey = typeof input?.conversationKey === 'string'
? input.conversationKey.trim() : '';
if (!channel || !botId || !conversationKey) return null;
const parsed = parseConversationKey(conversationKey);
if (!parsed) return null;
const grant = input.grant && typeof input.grant === 'object' ? input.grant : null;
if (parsed.kind === 'direct') {
const phone = phoneFromIdentity(parsed.conversationId);
const pushName = contactPushName(grant, phone);
const who = pushName || phone || parsed.conversationId;
const label = phone && pushName && pushName !== phone
? `${pushName} · ${phone}`
: who;
return Object.freeze({
channel,
botId,
kind: 'direct',
conversationKey,
conversationId: parsed.conversationId,
senderId: null,
phone: phone || null,
pushName: pushName || null,
groupTitle: null,
label,
});
}
const title = groupTitle(grant, parsed.conversationId) || '未命名群';
if (!parsed.senderId) {
return Object.freeze({
channel,
botId,
kind: 'group',
conversationKey,
conversationId: parsed.conversationId,
senderId: null,
phone: null,
pushName: null,
groupTitle: title,
label: `群 · ${title}`,
});
}
const phone = phoneFromIdentity(parsed.senderId);
const pushName = contactPushName(grant, phone);
const who = pushName || phone || parsed.senderId;
return Object.freeze({
channel,
botId,
kind: 'group',
conversationKey,
conversationId: parsed.conversationId,
senderId: parsed.senderId,
phone: phone || null,
pushName: pushName || null,
groupTitle: title,
label: `${title} · ${who}`,
});
}

View file

@ -47,6 +47,19 @@ export class ConversationStateStore {
return this.#state.sessions[key] ?? null;
}
/**
* Reverse-map a Harness session id to its conversation binding key.
* @param {string} sessionId
* @returns {string|null}
*/
keyForSession(sessionId) {
if (typeof sessionId !== 'string' || !sessionId) return null;
for (const [key, bound] of Object.entries(this.#state.sessions)) {
if (bound === sessionId) return key;
}
return null;
}
async setSession(key, sessionId) {
this.#state.sessions[key] = sessionId;
await this.#persist();

View file

@ -0,0 +1,80 @@
import assert from 'node:assert/strict';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test } from 'node:test';
import {
parseConversationKey,
resolveChannelPeerFromBinding,
} from '../../../src/channels/shared/channel-peer.mjs';
import { ConversationStateStore } from '../../../src/channels/shared/conversation-state-store.mjs';
test('parseConversationKey covers direct, shared group, and per-user group', () => {
assert.deepEqual(parseConversationKey('direct:8618111111111@s.whatsapp.net'), {
kind: 'direct',
conversationId: '8618111111111@s.whatsapp.net',
senderId: null,
});
assert.deepEqual(parseConversationKey('group:120363@g.us'), {
kind: 'group',
conversationId: '120363@g.us',
senderId: null,
});
assert.deepEqual(parseConversationKey('group:120363@g.us:user:8618222222222@s.whatsapp.net'), {
kind: 'group',
conversationId: '120363@g.us',
senderId: '8618222222222@s.whatsapp.net',
});
assert.equal(parseConversationKey(''), null);
assert.equal(parseConversationKey('other:x'), null);
});
test('resolveChannelPeerFromBinding builds DM and group labels from access grant', () => {
const grant = {
contacts: [{ phone: '8618222222222', pushName: 'Alice' }],
groups: {
'120363@g.us': { title: 'Ops Room', admins: [], members: [] },
},
};
const direct = resolveChannelPeerFromBinding({
channel: 'whatsapp',
botId: 'bot-1',
conversationKey: 'direct:8618222222222@s.whatsapp.net',
grant,
});
assert.equal(direct.label, 'Alice · 8618222222222');
assert.equal(direct.kind, 'direct');
assert.equal(direct.phone, '8618222222222');
const groupUser = resolveChannelPeerFromBinding({
channel: 'whatsapp',
botId: 'bot-1',
conversationKey: 'group:120363@g.us:user:8618222222222@s.whatsapp.net',
grant,
});
assert.equal(groupUser.label, 'Ops Room · Alice');
assert.equal(groupUser.groupTitle, 'Ops Room');
const groupShared = resolveChannelPeerFromBinding({
channel: 'whatsapp',
botId: 'bot-1',
conversationKey: 'group:120363@g.us',
grant,
});
assert.equal(groupShared.label, '群 · Ops Room');
});
test('ConversationStateStore.keyForSession reverses session bindings', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-im-channel-peer-'));
try {
const store = await new ConversationStateStore(join(root, 'state.json')).load();
await store.setSession('direct:u1', 'session-a');
await store.setSession('group:g1:user:u2', 'session-b');
assert.equal(store.keyForSession('session-a'), 'direct:u1');
assert.equal(store.keyForSession('session-b'), 'group:g1:user:u2');
assert.equal(store.keyForSession('missing'), null);
} finally {
await rm(root, { recursive: true, force: true });
}
});

View file

@ -764,7 +764,10 @@ test('client registers one top-level bilingual IM settings section with a direct
},
slots: {
inject(name, install) {
assert.equal(name, 'settings.section');
assert.ok(
name === 'settings.section' || name === 'conversation.session.header.actions',
`unexpected slot inject: ${name}`,
);
install();
},
register(options, component) {
@ -783,15 +786,22 @@ test('client registers one top-level bilingual IM settings section with a direct
assert.deepEqual(clientInject, ['slots', 'connection', 'locale', 'workspaces']);
assert.equal(dictionaries[0].namespace, IM_LOCALE_NAMESPACE);
assert.deepEqual(Object.keys(dictionaries[0].value.en).sort(), Object.keys(dictionaries[0].value.zh).sort());
assert.equal(registrations.length, 1);
assert.equal(registrations[0].options.name, 'settings.section');
assert.equal(registrations[0].options.id, 'xmanrui-dsh-im');
assert.equal(registrations[0].options.order, 21);
assert.equal(registrations[0].options.locale, IM_LOCALE_NAMESPACE);
assert.equal(registrations[0].options.label(), 'IM bots');
assert.equal(registrations[0].component, IMSettingsTab);
assert.equal(registrations.length, 2);
const header = registrations.find((entry) => entry.options.name === 'conversation.session.header.actions');
const settings = registrations.find((entry) => entry.options.name === 'settings.section');
assert.ok(header);
assert.equal(header.options.id, 'im-channel-peer');
assert.equal(header.options.order, 0);
assert.equal(header.options.locale, IM_LOCALE_NAMESPACE);
assert.equal(typeof header.options.inject().resolveChannelPeer, 'function');
assert.ok(settings);
assert.equal(settings.options.id, 'xmanrui-dsh-im');
assert.equal(settings.options.order, 21);
assert.equal(settings.options.locale, IM_LOCALE_NAMESPACE);
assert.equal(settings.options.label(), 'IM bots');
assert.equal(settings.component, IMSettingsTab);
const injected = registrations[0].options.inject();
const injected = settings.options.inject();
const signal = new AbortController().signal;
await injected.updateRpcCall('update.status', {}, signal);
assert.deepEqual(rpcCalls, [['/dsh-im', 'update.status', {}, signal]]);
@ -806,7 +816,7 @@ test('client registers one top-level bilingual IM settings section with a direct
]);
const markup = renderToStaticMarkup(React.createElement(
registrations[0].component,
settings.component,
injected,
));
assert.match(markup, /Connecting DeepSeek Harness/);
@ -846,7 +856,10 @@ test('client directory picker uses the current DSH uiWorkspace service', async (
},
slots: {
inject(name, install) {
assert.equal(name, 'settings.section');
assert.ok(
name === 'settings.section' || name === 'conversation.session.header.actions',
`unexpected slot inject: ${name}`,
);
install();
},
register(options, component) {
@ -869,7 +882,9 @@ test('client directory picker uses the current DSH uiWorkspace service', async (
},
};
const injected = registrations[0].options.inject();
const settings = registrations.find((entry) => entry.options.name === 'settings.section');
assert.ok(settings);
const injected = settings.options.inject();
const signal = new AbortController().signal;
assert.deepEqual(
await injected.workspaceDirectoryPicker.listDirectory('/workspace/current', signal),