@bolloon/bolloon-agent 0.1.24 → 0.1.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/web/client.js +295 -6
- package/dist/web/index.html +32 -2
- package/dist/web/server.js +221 -6
- package/dist/web/style.css +35 -0
- package/package.json +1 -1
- package/src/web/client.js +295 -6
- package/src/web/index.html +32 -2
- package/src/web/server.ts +240 -6
- package/src/web/style.css +35 -0
package/dist/web/server.js
CHANGED
|
@@ -254,8 +254,77 @@ async function executeTask(task, channelId) {
|
|
|
254
254
|
executionTaskId = null;
|
|
255
255
|
}
|
|
256
256
|
let sseClients = new Set();
|
|
257
|
+
// v3: 远端 channel UI 元数据缓存 — key: peerId, value: sanitize 过的 channel 列表
|
|
258
|
+
// in-memory only, 进程重启清空 (judgment 内容永远不在这里)
|
|
259
|
+
let remoteChannelCache = new Map();
|
|
257
260
|
let channelSessions = new Map(); // key: channelId
|
|
258
261
|
let sessionMessages = new Map(); // key: channelId + sessionId
|
|
262
|
+
/**
|
|
263
|
+
* v3 重做: 构造 channel 的两路 judgment prompt 片段
|
|
264
|
+
* 路 1: 用户在盾牌里手动绑定的 judgment (channel.bound_judgment_ids)
|
|
265
|
+
* 路 2: 全局 judgment 列表 (供 LLM 在主调用中按需挑选, 写入回复)
|
|
266
|
+
* 返回 "" 表示完全没数据; 否则返回完整 "[系统上下文] ..." 块 (含尾部换行)
|
|
267
|
+
* 失败非致命 — 任何异常都返回空串, 保证 LLM 调用不被阻塞
|
|
268
|
+
*/
|
|
269
|
+
/**
|
|
270
|
+
* v3: 过滤 channel 元数据, 只返回对远端 peer 安全的字段.
|
|
271
|
+
* 关键: bound_judgment_ids / walletBinding / autoInvokeTools 内部状态不外传.
|
|
272
|
+
* judgment 内容永远不会出现在 RPC 响应里 (judgment 始终在 A 节点内存, 由 A 跑 LLM).
|
|
273
|
+
*/
|
|
274
|
+
function sanitizeChannelForPeer(ch) {
|
|
275
|
+
return {
|
|
276
|
+
id: ch.id,
|
|
277
|
+
name: ch.name,
|
|
278
|
+
did: ch.did,
|
|
279
|
+
publicKey: ch.publicKey,
|
|
280
|
+
createdAt: ch.createdAt,
|
|
281
|
+
updatedAt: ch.updatedAt,
|
|
282
|
+
hasWallet: !!ch.walletAddress, // 只告诉 B "有没有钱包", 不传地址
|
|
283
|
+
boundJudgmentCount: Array.isArray(ch.bound_judgment_ids) ? ch.bound_judgment_ids.length : 0,
|
|
284
|
+
// 🔒 不返回: bound_judgment_ids, walletAddress, walletBinding, autoInvokeTools, sessions
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
async function buildJudgmentHint(channel, channelIdForLog) {
|
|
288
|
+
try {
|
|
289
|
+
const { loadAllJudgments, initializeValueStore } = await import('../pi-ecosystem-judgment/human-value-store.js');
|
|
290
|
+
await initializeValueStore();
|
|
291
|
+
const allJudgments = await loadAllJudgments();
|
|
292
|
+
if (allJudgments.length === 0)
|
|
293
|
+
return '';
|
|
294
|
+
const boundIds = new Set(channel && Array.isArray(channel.bound_judgment_ids) ? channel.bound_judgment_ids : []);
|
|
295
|
+
const bound = allJudgments.filter(j => j.id !== undefined && boundIds.has(j.id));
|
|
296
|
+
const others = allJudgments.filter(j => j.id !== undefined && !boundIds.has(j.id));
|
|
297
|
+
let hint = '';
|
|
298
|
+
// 路 1: 用户手动绑定的 judgment — 硬约束, 必须遵循
|
|
299
|
+
if (bound.length > 0) {
|
|
300
|
+
hint += `[系统上下文] 此 channel 用户绑定了 ${bound.length} 条判断力, 必须严格遵循:\n`;
|
|
301
|
+
for (const j of bound) {
|
|
302
|
+
const decision = (j.decision || '').toString().slice(0, 200);
|
|
303
|
+
const reasonList = Array.isArray(j.reasons) ? j.reasons : [];
|
|
304
|
+
const reasonText = reasonList.length > 0
|
|
305
|
+
? ` (理由: ${reasonList.join('; ').slice(0, 100)})`
|
|
306
|
+
: '';
|
|
307
|
+
hint += `- ${decision}${reasonText}\n`;
|
|
308
|
+
}
|
|
309
|
+
hint += '\n';
|
|
310
|
+
}
|
|
311
|
+
// 路 2: 全局 judgment 候选池 — 软参考, LLM 自己挑
|
|
312
|
+
if (others.length > 0) {
|
|
313
|
+
hint += `[系统上下文] 候选判断力 (用户未明确绑定, 你可以按相关性自主选择参考):\n`;
|
|
314
|
+
for (const j of others) {
|
|
315
|
+
const decision = (j.decision || '').toString().slice(0, 120);
|
|
316
|
+
hint += `- [id=${j.id}] ${decision}\n`;
|
|
317
|
+
}
|
|
318
|
+
hint += `\n[系统上下文] 如果你的回复参考了某条候选判断力, 请在回复中自然提及 "我参考了你的判断: <decision 简述>" 即可, 无需复述 id.\n\n`;
|
|
319
|
+
}
|
|
320
|
+
console.log(`[v3] channel ${channelIdForLog} 注入: 绑定 ${bound.length} 条, 候选 ${others.length} 条`);
|
|
321
|
+
return hint;
|
|
322
|
+
}
|
|
323
|
+
catch (err) {
|
|
324
|
+
console.error(`[v3] 加载判断力失败 (非致命):`, err.message);
|
|
325
|
+
return '';
|
|
326
|
+
}
|
|
327
|
+
}
|
|
259
328
|
async function getAgentForChannel(channelId, channelDid, channelName, channelDidDoc) {
|
|
260
329
|
// 获取当前 channel 的 currentSessionId
|
|
261
330
|
const channels = await loadChannels();
|
|
@@ -468,6 +537,8 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
468
537
|
// (line ~638) 与这里外层的 const channel 形成 shadowing 让 TS 误报"使用前未声明"
|
|
469
538
|
const boundWalletAddress = channel?.walletAddress;
|
|
470
539
|
const autoToolsEnabled = channel?.autoInvokeTools !== false; // 默认开启
|
|
540
|
+
// 捕获外层 channel 到独立变量, 避免被 try 块内 (line 740+) 的 const channel 遮蔽
|
|
541
|
+
const channelForJudgment = channel;
|
|
471
542
|
try {
|
|
472
543
|
const agent = await getAgentForChannel(channelId, realChannelDid, realChannelName, realChannelDidDoc);
|
|
473
544
|
let fullResponse = '';
|
|
@@ -503,6 +574,11 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
503
574
|
else {
|
|
504
575
|
contextHint += `[系统上下文] 自动工具调用已关闭: 每次执行工具前必须先与用户确认。\n`;
|
|
505
576
|
}
|
|
577
|
+
// v3: 注入 channel 绑定的判断力 (judgment_ids)
|
|
578
|
+
// 这是 v3 的核心 — channel 跑 LLM 时, 它的判断力 = 绑定的 judgment 列表
|
|
579
|
+
const judgmentHint = await buildJudgmentHint(channelForJudgment, channelId);
|
|
580
|
+
if (judgmentHint)
|
|
581
|
+
contextHint += judgmentHint;
|
|
506
582
|
if (contextHint)
|
|
507
583
|
contextHint += '\n';
|
|
508
584
|
fullResponse = await agent.promptStream(contextHint + text, streamCallback);
|
|
@@ -659,10 +735,44 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
659
735
|
res.status(500).json({ error: err.message });
|
|
660
736
|
}
|
|
661
737
|
});
|
|
738
|
+
// v3: 列出本节点缓存的远端 channel (按 peerId 分组)
|
|
739
|
+
app.get('/api/remote-channels', async (_req, res) => {
|
|
740
|
+
try {
|
|
741
|
+
const out = [];
|
|
742
|
+
for (const [peerId, list] of remoteChannelCache.entries()) {
|
|
743
|
+
out.push({ peerId, channels: list });
|
|
744
|
+
}
|
|
745
|
+
res.json({ count: out.length, peers: out });
|
|
746
|
+
}
|
|
747
|
+
catch (err) {
|
|
748
|
+
res.status(500).json({ error: err.message });
|
|
749
|
+
}
|
|
750
|
+
});
|
|
751
|
+
// v3: 主动向所有已连接 P2P peer 拉 channel 列表
|
|
752
|
+
// 用法: B 端用户点 "刷新远端智能体" → 触发本 endpoint
|
|
753
|
+
app.post('/api/remote-channels/refresh', async (_req, res) => {
|
|
754
|
+
try {
|
|
755
|
+
const peers = irohTransport.getPeers ? irohTransport.getPeers() : [];
|
|
756
|
+
const peerIds = Array.isArray(peers) ? peers.map((p) => p.nodeId || p) : [];
|
|
757
|
+
if (peerIds.length === 0) {
|
|
758
|
+
return res.json({ ok: true, sent: 0, note: 'no connected peers' });
|
|
759
|
+
}
|
|
760
|
+
let sent = 0;
|
|
761
|
+
for (const peerId of peerIds) {
|
|
762
|
+
const ok = await irohTransport.sendMessage(peerId, 'agent.meta.list', new TextEncoder().encode('{}'));
|
|
763
|
+
if (ok)
|
|
764
|
+
sent++;
|
|
765
|
+
}
|
|
766
|
+
res.json({ ok: true, sent, total: peerIds.length });
|
|
767
|
+
}
|
|
768
|
+
catch (err) {
|
|
769
|
+
res.status(500).json({ error: err.message });
|
|
770
|
+
}
|
|
771
|
+
});
|
|
662
772
|
app.post('/channels', async (req, res) => {
|
|
663
773
|
try {
|
|
664
|
-
const { name, agentId, walletAddress, autoInvokeTools } = req.body;
|
|
665
|
-
console.log(`[创建频道] 收到请求: name=${name}, agentId=${agentId}, wallet=${walletAddress ? 'yes' : 'no'}`);
|
|
774
|
+
const { name, agentId, walletAddress, autoInvokeTools, bound_judgment_ids } = req.body;
|
|
775
|
+
console.log(`[创建频道] 收到请求: name=${name}, agentId=${agentId}, wallet=${walletAddress ? 'yes' : 'no'}, boundJudgments=${Array.isArray(bound_judgment_ids) ? bound_judgment_ids.length : 0}`);
|
|
666
776
|
if (!name || !agentId) {
|
|
667
777
|
return res.status(400).json({ error: 'name and agentId required' });
|
|
668
778
|
}
|
|
@@ -670,6 +780,10 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
670
780
|
const id = `ch_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
|
|
671
781
|
// 校验钱包地址格式 (粗校验: 0x + 40 hex / Solana base58 / Sui 0x+64)
|
|
672
782
|
const validWallet = isValidWalletAddress(walletAddress);
|
|
783
|
+
// 过滤 bound_judgment_ids: 只保留 string
|
|
784
|
+
const safeBoundIds = Array.isArray(bound_judgment_ids)
|
|
785
|
+
? bound_judgment_ids.filter((x) => typeof x === 'string' && x.length > 0)
|
|
786
|
+
: [];
|
|
673
787
|
// 先创建频道(不阻塞等待 DID 生成)
|
|
674
788
|
const channel = {
|
|
675
789
|
id,
|
|
@@ -681,6 +795,7 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
681
795
|
walletAddress: validWallet || undefined,
|
|
682
796
|
walletRegisteredAt: validWallet ? new Date().toISOString() : undefined,
|
|
683
797
|
autoInvokeTools: autoInvokeTools !== false, // 默认 true
|
|
798
|
+
bound_judgment_ids: safeBoundIds,
|
|
684
799
|
sessions: [{
|
|
685
800
|
id: `sess_${Date.now()}`,
|
|
686
801
|
createdAt: new Date().toISOString(),
|
|
@@ -839,7 +954,7 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
839
954
|
app.patch('/channels/:channelId', async (req, res) => {
|
|
840
955
|
try {
|
|
841
956
|
const { channelId } = req.params;
|
|
842
|
-
const { name, walletAddress, autoInvokeTools } = req.body;
|
|
957
|
+
const { name, walletAddress, autoInvokeTools, bound_judgment_ids } = req.body;
|
|
843
958
|
const channels = await loadChannels();
|
|
844
959
|
const channel = channels.find(c => c.id === channelId);
|
|
845
960
|
if (!channel) {
|
|
@@ -866,6 +981,19 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
866
981
|
if (typeof autoInvokeTools === 'boolean') {
|
|
867
982
|
channel.autoInvokeTools = autoInvokeTools;
|
|
868
983
|
}
|
|
984
|
+
// bound_judgment_ids: 允许数组(替换)/null(清空)/undefined(不改)
|
|
985
|
+
if (bound_judgment_ids !== undefined) {
|
|
986
|
+
if (bound_judgment_ids === null) {
|
|
987
|
+
channel.bound_judgment_ids = [];
|
|
988
|
+
}
|
|
989
|
+
else if (Array.isArray(bound_judgment_ids)) {
|
|
990
|
+
channel.bound_judgment_ids = bound_judgment_ids.filter((x) => typeof x === 'string' && x.length > 0);
|
|
991
|
+
}
|
|
992
|
+
else {
|
|
993
|
+
return res.status(400).json({ error: 'bound_judgment_ids must be array or null' });
|
|
994
|
+
}
|
|
995
|
+
console.log(`[Channel ${channelId}] 绑定判断力: ${channel.bound_judgment_ids.length} 条`);
|
|
996
|
+
}
|
|
869
997
|
channel.updatedAt = new Date().toISOString();
|
|
870
998
|
await saveChannels(channels);
|
|
871
999
|
res.json(channel);
|
|
@@ -1027,8 +1155,9 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
1027
1155
|
broadcast({ type: 'error', content: event.content }, channelId);
|
|
1028
1156
|
}
|
|
1029
1157
|
};
|
|
1030
|
-
// 重新生成时只发送用户消息
|
|
1031
|
-
|
|
1158
|
+
// 重新生成时只发送用户消息 (v3: 同时注入 channel 绑定的判断力)
|
|
1159
|
+
const regenHint = await buildJudgmentHint(channel, channelId);
|
|
1160
|
+
fullResponse = await agent.promptStream(regenHint + userMessage, streamCallback);
|
|
1032
1161
|
broadcast({ type: 'ai', content: fullResponse }, channelId);
|
|
1033
1162
|
// 更新 session
|
|
1034
1163
|
const existingSession = await loadSession(channelId, currentSessionId);
|
|
@@ -1516,6 +1645,91 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
1516
1645
|
timestamp: Date.now()
|
|
1517
1646
|
}, 'p2p-global');
|
|
1518
1647
|
});
|
|
1648
|
+
// ============ v3: 跨用户 channel 元数据 RPC ============
|
|
1649
|
+
// 设计原则: judgment / bound_judgment_ids / wallet 等敏感字段绝不出现在 RPC 响应里.
|
|
1650
|
+
// 收到 'agent.meta.list' → 返回本节点所有 channel 的 UI 元数据 (无 judgment)
|
|
1651
|
+
// 收到 'agent.meta.get' + channelId → 返回单条 channel 的 UI 元数据
|
|
1652
|
+
// B 节点收到响应 → 存到远端 cache → 渲染到 "远端智能体" 区域
|
|
1653
|
+
// B 侧: 收到对端的 list/get 回复 → 更新远端 cache → SSE 推给前端
|
|
1654
|
+
irohTransport.onMessage('agent.meta.list.reply', (msg) => {
|
|
1655
|
+
try {
|
|
1656
|
+
const data = JSON.parse(new TextDecoder().decode(msg.payload));
|
|
1657
|
+
if (!data.ok)
|
|
1658
|
+
return;
|
|
1659
|
+
const peerId = msg.from;
|
|
1660
|
+
const list = Array.isArray(data.channels) ? data.channels : [];
|
|
1661
|
+
remoteChannelCache.set(peerId, list);
|
|
1662
|
+
console.log(`[v3] 缓存远端 peer ${peerId.substring(0, 12)}... 的 ${list.length} 个 channel`);
|
|
1663
|
+
broadcast({
|
|
1664
|
+
type: 'remote-channel-update',
|
|
1665
|
+
peerId,
|
|
1666
|
+
channels: list
|
|
1667
|
+
}, 'p2p-global');
|
|
1668
|
+
}
|
|
1669
|
+
catch (err) {
|
|
1670
|
+
console.error('[v3] 处理 agent.meta.list.reply 失败:', err);
|
|
1671
|
+
}
|
|
1672
|
+
});
|
|
1673
|
+
irohTransport.onMessage('agent.meta.get.reply', (msg) => {
|
|
1674
|
+
try {
|
|
1675
|
+
const data = JSON.parse(new TextDecoder().decode(msg.payload));
|
|
1676
|
+
if (!data.ok || !data.channel)
|
|
1677
|
+
return;
|
|
1678
|
+
const peerId = msg.from;
|
|
1679
|
+
const ch = data.channel;
|
|
1680
|
+
const list = remoteChannelCache.get(peerId) || [];
|
|
1681
|
+
const idx = list.findIndex(c => c.id === ch.id);
|
|
1682
|
+
if (idx >= 0)
|
|
1683
|
+
list[idx] = ch;
|
|
1684
|
+
else
|
|
1685
|
+
list.push(ch);
|
|
1686
|
+
remoteChannelCache.set(peerId, list);
|
|
1687
|
+
broadcast({
|
|
1688
|
+
type: 'remote-channel-update',
|
|
1689
|
+
peerId,
|
|
1690
|
+
channels: list
|
|
1691
|
+
}, 'p2p-global');
|
|
1692
|
+
}
|
|
1693
|
+
catch (err) {
|
|
1694
|
+
console.error('[v3] 处理 agent.meta.get.reply 失败:', err);
|
|
1695
|
+
}
|
|
1696
|
+
});
|
|
1697
|
+
// A 侧: 收到对端的 list/get 请求
|
|
1698
|
+
irohTransport.onMessage('agent.meta.list', async (msg) => {
|
|
1699
|
+
console.log(`[v3] 收到 agent.meta.list from ${msg.from.substring(0, 12)}...`);
|
|
1700
|
+
try {
|
|
1701
|
+
const channels = await loadChannels();
|
|
1702
|
+
const publicMeta = channels.map(sanitizeChannelForPeer);
|
|
1703
|
+
const response = JSON.stringify({ ok: true, channels: publicMeta });
|
|
1704
|
+
const encoded = new TextEncoder().encode(response);
|
|
1705
|
+
// 沿用 msg.from 路由回去
|
|
1706
|
+
irohTransport.sendMessage(msg.from, 'agent.meta.list.reply', encoded).catch(err => {
|
|
1707
|
+
console.error('[v3] 发送 agent.meta.list.reply 失败:', err);
|
|
1708
|
+
});
|
|
1709
|
+
}
|
|
1710
|
+
catch (err) {
|
|
1711
|
+
console.error('[v3] 处理 agent.meta.list 失败:', err);
|
|
1712
|
+
}
|
|
1713
|
+
});
|
|
1714
|
+
irohTransport.onMessage('agent.meta.get', async (msg) => {
|
|
1715
|
+
try {
|
|
1716
|
+
const req = JSON.parse(new TextDecoder().decode(msg.payload));
|
|
1717
|
+
const channelId = req.channelId;
|
|
1718
|
+
console.log(`[v3] 收到 agent.meta.get for ${channelId} from ${msg.from.substring(0, 12)}...`);
|
|
1719
|
+
const channels = await loadChannels();
|
|
1720
|
+
const ch = channels.find(c => c.id === channelId);
|
|
1721
|
+
if (!ch) {
|
|
1722
|
+
const response = JSON.stringify({ ok: false, error: 'channel not found' });
|
|
1723
|
+
irohTransport.sendMessage(msg.from, 'agent.meta.get.reply', new TextEncoder().encode(response));
|
|
1724
|
+
return;
|
|
1725
|
+
}
|
|
1726
|
+
const response = JSON.stringify({ ok: true, channel: sanitizeChannelForPeer(ch) });
|
|
1727
|
+
irohTransport.sendMessage(msg.from, 'agent.meta.get.reply', new TextEncoder().encode(response));
|
|
1728
|
+
}
|
|
1729
|
+
catch (err) {
|
|
1730
|
+
console.error('[v3] 处理 agent.meta.get 失败:', err);
|
|
1731
|
+
}
|
|
1732
|
+
});
|
|
1519
1733
|
irohTransport.onMessage('ai-dialogue', (msg) => {
|
|
1520
1734
|
const content = new TextDecoder().decode(msg.payload);
|
|
1521
1735
|
console.log(`[iroh] 收到 AI 对话 from ${msg.from.substring(0, 12)}...`);
|
|
@@ -2140,7 +2354,8 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
2140
2354
|
try {
|
|
2141
2355
|
const { createHealthMonitor, createWatchdog } = await import('../heartbeat/index.js');
|
|
2142
2356
|
healthMonitor = createHealthMonitor();
|
|
2143
|
-
watchdog
|
|
2357
|
+
// 把 watchdog 静默阈值拉到 30 分钟, 避免开发期 / 用户空闲时被误杀
|
|
2358
|
+
watchdog = createWatchdog({ silentThresholdMs: 30 * 60 * 1000 });
|
|
2144
2359
|
console.log('[24h] Heartbeat modules loaded');
|
|
2145
2360
|
}
|
|
2146
2361
|
catch (err) {
|
package/dist/web/style.css
CHANGED
|
@@ -269,6 +269,41 @@ body {
|
|
|
269
269
|
padding: 16px;
|
|
270
270
|
position: relative;
|
|
271
271
|
}
|
|
272
|
+
|
|
273
|
+
/* v3: 本地/远端 flex 比例由 CSS variable 驱动, 拖拽分隔线时改这两个值 */
|
|
274
|
+
.sidebar-section.local-flex { flex: var(--local-flex, 1) 1 0; }
|
|
275
|
+
.sidebar-section.remote-flex { flex: var(--remote-flex, 1) 1 0; transition: flex 0.15s; }
|
|
276
|
+
|
|
277
|
+
/* v3: 可拖拽分隔线 */
|
|
278
|
+
#sidebar-split-handle {
|
|
279
|
+
flex: 0 0 auto;
|
|
280
|
+
height: 6px;
|
|
281
|
+
cursor: ns-resize;
|
|
282
|
+
display: flex;
|
|
283
|
+
align-items: center;
|
|
284
|
+
justify-content: center;
|
|
285
|
+
position: relative;
|
|
286
|
+
background: transparent;
|
|
287
|
+
user-select: none;
|
|
288
|
+
}
|
|
289
|
+
#sidebar-split-handle:hover { background: var(--bg-hover); }
|
|
290
|
+
#sidebar-split-handle.dragging { background: var(--accent-glow); }
|
|
291
|
+
#sidebar-split-handle .split-handle-grip {
|
|
292
|
+
width: 32px;
|
|
293
|
+
height: 3px;
|
|
294
|
+
border-radius: 2px;
|
|
295
|
+
background: var(--border-light);
|
|
296
|
+
pointer-events: none;
|
|
297
|
+
}
|
|
298
|
+
#sidebar-split-handle:hover .split-handle-grip,
|
|
299
|
+
#sidebar-split-handle.dragging .split-handle-grip { background: var(--accent); }
|
|
300
|
+
|
|
301
|
+
/* v3: 远端区域折叠状态 */
|
|
302
|
+
#remote-agents-section.collapsed .channel-list { display: none; }
|
|
303
|
+
#remote-agents-section.collapsed { flex: 0 0 auto; padding-top: 0; padding-bottom: 0; }
|
|
304
|
+
#remote-agents-section.collapsed .section-header { margin-bottom: 0; }
|
|
305
|
+
#remote-agents-section.collapsed #remote-agents-toggle { transform: rotate(-90deg); }
|
|
306
|
+
|
|
272
307
|
.section-header {
|
|
273
308
|
display: flex;
|
|
274
309
|
align-items: center;
|