diff --git a/AGENTS.md b/AGENTS.md
index 3f95406..d999f21 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -20,3 +20,69 @@ If you're using Codex, project-scoped helpers may also live in:
Keep this managed block so 'trellis update' can refresh the instructions.
+
+## Codex App / hapi 对齐经验
+
+以下约定来自本项目对 `/home/hdzx/2026/hapi` 中 Codex app-server 接入方式的对比结果。后续如果继续维护 `codexapp`,默认按这些约定实现,避免再走偏到“看起来能跑、但拿不到原生协作能力”的分叉路径。
+
+### 1. 初始化链路优先对齐上游协议
+
+- `initialize` 成功后,先发送 `initialized` notification。
+- 然后再做 best-effort 的能力探测:
+ - `experimentalFeature/enablement/set`,当前至少尝试 `{ enablement: { goals: true } }`
+ - `collaborationMode/list`
+- 上述探测失败时:
+ - 只记录日志
+ - 不直接中断 `codexapp` 启动
+
+### 2. collaborationMode 的参数形状必须贴近 hapi
+
+- 一旦本轮使用 `collaborationMode`,`turn/start` 顶层参数应保持精简。
+- `model`、`reasoning_effort`、`developer_instructions` 应放入:
+ - `collaborationMode.settings.model`
+ - `collaborationMode.settings.reasoning_effort`
+ - `collaborationMode.settings.developer_instructions`
+- 使用 `collaborationMode` 时,不要再重复传顶层 `model` / `effort`,否则容易让 app-server 落到非原生协作路径。
+
+### 3. 自定义跨会话能力走 MCP,不优先走 dynamicTools
+
+- `ccweb_list_conversations`、`ccweb_send_message` 这类自定义能力,优先通过 `thread/start.config.mcp_servers.*` 挂载。
+- 不要把它们当成 `dynamicTools` 主路径注入给 `codexapp`。
+- 原因:
+ - `dynamicTools` 容易污染 app-server 原生工具集
+ - 会增加拿不到 `spawn_agent` / `wait_agent` / 原生协作工具的风险
+
+### 4. MCP 配置要做成“线程级”而不是“进程级”
+
+- `codexapp` 是长驻 app-server,不是一次一进程的 CLI 调用。
+- 因此 `CC_WEB_SOURCE_SESSION_ID`、`CC_WEB_CROSS_HOP_COUNT` 这类来源上下文,不应只放在 app-server 进程全局环境里。
+- 正确做法是随 `thread/start.config.mcp_servers.ccweb.env` 一起下发,让每个线程拿到自己的来源会话上下文。
+
+### 5. guided input 依赖 plan 协作模式
+
+- `request_user_input` 类能力默认按“协作 / plan 模式可用”来设计。
+- Default / YOLO 模式下,不要假设引导输入一定可用。
+- 如果要做降级:
+ - 优先退回普通文本交互
+ - 不要让整轮对话因为 guided input 不可用而直接失效
+
+### 6. 能力降级要明确而保守
+
+- 如果运行时拒绝 `collaborationMode`(例如 `unknown field collaborationMode` 或 `unsupported collaboration mode`):
+ - 应记录一次能力降级
+ - 后续本轮可退回普通 turn 发送
+- 但如果只是 `goals` 或 `collaborationMode/list` 探测失败:
+ - 不应直接判定整个 app-server 不可用
+
+### 7. 回归检查要覆盖协议形状,不只看 UI
+
+后续涉及 `codexapp` 的改动,至少要检查这些点:
+
+- `initialize` 后是否真的调用了:
+ - `experimentalFeature/enablement/set`
+ - `collaborationMode/list`
+- `collaborationMode` 存在时:
+ - 顶层是否没有重复 `model`
+ - 顶层是否没有重复 `effort`
+- `ccweb` 能力是否走 `mcpToolCall`,而不是再次退回 `dynamicToolCall`
+- mock / regression 中是否覆盖了上述断言
diff --git a/lib/codex-app-runtime.js b/lib/codex-app-runtime.js
index 2084f2d..9e50a35 100644
--- a/lib/codex-app-runtime.js
+++ b/lib/codex-app-runtime.js
@@ -235,9 +235,10 @@ function createCodexAppRuntime(deps = {}) {
if (!nextText) return '';
if (!entry.agentMessageItems) entry.agentMessageItems = new Map();
const currentItemText = entry.agentMessageItems.get(itemId) || '';
+ const separator = agentMessageSeparator(entry, itemId, nextText);
entry.agentMessageItems.set(itemId, currentItemText + nextText);
- entry.fullText = (entry.fullText || '') + nextText;
- return nextText;
+ entry.fullText = (entry.fullText || '') + separator + nextText;
+ return separator + nextText;
}
function appendAgentCompletedText(entry, item) {
@@ -252,9 +253,18 @@ function createCodexAppRuntime(deps = {}) {
return remainder;
}
if (currentItemText === text) return '';
+ const separator = agentMessageSeparator(entry, item.id, text);
entry.agentMessageItems.set(item.id, text);
- entry.fullText = (entry.fullText || '') + text;
- return text;
+ entry.fullText = (entry.fullText || '') + separator + text;
+ return separator + text;
+ }
+
+ function agentMessageSeparator(entry, itemId, nextText) {
+ if (entry.agentMessageItems?.get(itemId)) return '';
+ const currentText = entry.fullText || '';
+ if (!/\S/.test(currentText)) return '';
+ const hasVisualBoundary = /\n\s*(?:---|\*\*\*|___)\s*$/.test(currentText) || /^\s*(?:---|\*\*\*|___)\s*\n/.test(String(nextText || ''));
+ return hasVisualBoundary ? '' : '\n\n---\n\n';
}
function updateToolResult(entry, sessionId, itemId, result, done = false, patch = {}) {
diff --git a/public/app.js b/public/app.js
index 89ea712..854c372 100644
--- a/public/app.js
+++ b/public/app.js
@@ -122,6 +122,7 @@
let noteMode = false;
let noteDraftSeq = 0;
const pendingNotesByTarget = new Map();
+ const userMessageIndex = new Map();
// --- DOM ---
const $ = (sel) => document.querySelector(sel);
@@ -149,6 +150,8 @@
const chatAgentMenu = $('#chat-agent-menu');
const chatRuntimeState = $('#chat-runtime-state');
const chatCwd = $('#chat-cwd');
+ const userOutlineBtn = $('#user-outline-btn');
+ const userOutlinePanel = $('#user-outline-panel');
const costDisplay = $('#cost-display');
const attachmentTray = $('#attachment-tray');
const pendingNotesTray = $('#pending-notes-tray');
@@ -682,6 +685,79 @@
return value ? value.slice(0, 8) : '';
}
+ function shortMessagePreview(text, maxLength = 60) {
+ const value = String(text || '').replace(/\s+/g, ' ').trim();
+ if (!value) return '空消息';
+ return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value;
+ }
+
+ function createLocalId(prefix = 'local') {
+ if (window.crypto?.randomUUID) return `${prefix}-${window.crypto.randomUUID()}`;
+ return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
+ }
+
+ function clearUserMessageIndex() {
+ userMessageIndex.clear();
+ }
+
+ function registerUserMessage(messageId, element, content) {
+ if (!messageId || !element) return;
+ userMessageIndex.set(messageId, {
+ id: messageId,
+ element,
+ content: String(content || ''),
+ });
+ }
+
+ function buildUserOutlineItems() {
+ return Array.from(userMessageIndex.values()).map((entry) => ({
+ id: entry.id,
+ targetMessageId: entry.element?.id || '',
+ label: shortMessagePreview(entry.content, 64),
+ })).filter((entry) => entry.targetMessageId);
+ }
+
+ function updateUserOutlinePanel() {
+ if (!userOutlinePanel || !userOutlineBtn) return;
+ const items = buildUserOutlineItems();
+ if (items.length === 0) {
+ userOutlinePanel.innerHTML = '
暂无用户消息
';
+ userOutlineBtn.disabled = true;
+ } else {
+ userOutlinePanel.innerHTML = items.map((item, index) => `
+
+ `).join('');
+ userOutlineBtn.disabled = false;
+ }
+ }
+
+ function closeUserOutlinePanel() {
+ if (!userOutlinePanel || !userOutlineBtn) return;
+ userOutlinePanel.hidden = true;
+ userOutlineBtn.setAttribute('aria-expanded', 'false');
+ }
+
+ function toggleUserOutlinePanel() {
+ if (!userOutlinePanel || !userOutlineBtn) return;
+ if (userOutlinePanel.hidden) {
+ updateUserOutlinePanel();
+ userOutlinePanel.hidden = false;
+ userOutlineBtn.setAttribute('aria-expanded', 'true');
+ } else {
+ closeUserOutlinePanel();
+ }
+ }
+
+ function scrollToMessage(anchorId) {
+ if (!anchorId) return;
+ const target = document.getElementById(anchorId);
+ if (!target) return;
+ target.scrollIntoView({ block: 'start', behavior: 'smooth' });
+ }
+
function updateSessionIdBadge() {
if (!chatSessionIdBtn) return;
if (!currentSessionId) {
@@ -2183,6 +2259,7 @@
function resetChatView(agent) {
setCurrentAgent(agent);
+ closeUserOutlinePanel();
closeFileBrowser();
currentSessionId = null;
loadedHistorySessionId = null;
@@ -2236,6 +2313,7 @@
migratePendingNotesToSession(snapshot.sessionId, snapshotAgent);
setCurrentSessionRunningState(snapshot.isRunning);
setStatsDisplay(snapshot);
+ closeUserOutlinePanel();
currentCwd = snapshot.cwd || null;
updateCwdBadge();
if (snapshot.mode && MODE_LABELS[snapshot.mode]) {
@@ -2261,6 +2339,7 @@
const targetAgent = normalizeAgent(agent);
const { preserveCurrent = true, loadLast = true } = options;
setCurrentAgent(targetAgent);
+ closeUserOutlinePanel();
renderSessionList();
const currentMeta = currentSessionId ? getSessionMeta(currentSessionId) : null;
@@ -2353,6 +2432,7 @@
if (currentSessionId && currentSessionId !== sessionId) {
send({ type: 'detach_view' });
}
+ closeUserOutlinePanel();
clearSessionLoading();
touchSessionCache(sessionId);
applySessionSnapshot(snapshot, { immediate: true, suppressUnreadToast: true });
@@ -2361,6 +2441,7 @@
function openSession(sessionId, options = {}) {
if (!sessionId) return;
+ closeUserOutlinePanel();
if (options.forceSync) {
beginSessionSwitch(sessionId, { blocking: options.blocking !== false, force: true, label: options.label });
return;
@@ -3111,7 +3192,12 @@
const div = document.createElement('div');
const isCrossConversation = role === 'user' && !!meta.crossConversation;
const isCrossConversationReply = isCrossConversation && !!(meta.crossConversation.reply || meta.crossConversation.replyToRequestId);
+ const resolvedMessageId = meta?.messageId || meta?.id || createLocalId('user');
div.className = `msg ${role}${role === 'assistant' ? ' agent-' + currentAgent : ''}${isCrossConversation ? ' cross-conversation' : ''}${isCrossConversationReply ? ' cross-conversation-reply' : ''}`;
+ if (role === 'user') {
+ div.id = `hapi-message-${resolvedMessageId}`;
+ div.dataset.messageId = resolvedMessageId;
+ }
if (role === 'system') {
const bubble = document.createElement('div');
@@ -3172,6 +3258,23 @@
textNode.style.whiteSpace = 'pre-wrap';
textNode.textContent = content;
bubble.appendChild(textNode);
+
+ const copyBtn = document.createElement('button');
+ copyBtn.type = 'button';
+ copyBtn.className = 'msg-copy-btn';
+ copyBtn.title = '复制用户消息';
+ copyBtn.setAttribute('aria-label', '复制用户消息');
+ copyBtn.innerHTML = `
+
+ `;
+ copyBtn.addEventListener('click', (event) => {
+ event.stopPropagation();
+ copyTextToClipboard(content, '用户消息已复制');
+ });
+ bubble.appendChild(copyBtn);
}
if (attachments.length > 0) {
bubble.insertAdjacentHTML('beforeend', renderAttachmentPreviews(attachments));
@@ -3186,6 +3289,9 @@
hydrateAttachmentPreviews(bubble, attachments);
div.appendChild(avatar);
div.appendChild(bubble);
+ if (role === 'user') {
+ registerUserMessage(resolvedMessageId, div, content);
+ }
return div;
}
@@ -3429,6 +3535,209 @@
return section;
}
+ function parseMaybeJsonObject(value) {
+ if (value && typeof value === 'object') return value;
+ if (typeof value !== 'string') return null;
+ const trimmed = value.trim();
+ if (!trimmed || (!trimmed.startsWith('{') && !trimmed.startsWith('['))) return null;
+ try {
+ const parsed = JSON.parse(trimmed);
+ return parsed && typeof parsed === 'object' ? parsed : null;
+ } catch {
+ return null;
+ }
+ }
+
+ function summarizePrompt(prompt) {
+ const text = typeof prompt === 'string' ? prompt.trim().replace(/\s+/g, ' ') : '';
+ if (!text) return '';
+ return text.length > 140 ? `${text.slice(0, 140)}…` : text;
+ }
+
+ function normalizeCollabAgentData(tool) {
+ const inputData = effectiveObject(tool?.input);
+ const resultData = effectiveObject(tool?.result);
+ const merged = {
+ ...inputData,
+ ...resultData,
+ agentsStates: resultData.agentsStates || inputData.agentsStates || {},
+ receiverThreadIds: resultData.receiverThreadIds || inputData.receiverThreadIds || [],
+ prompt: inputData.prompt || resultData.prompt || '',
+ tool: inputData.tool || resultData.tool || tool?.name || '',
+ status: resultData.status || inputData.status || tool?.meta?.status || null,
+ };
+ return merged;
+ }
+
+ function effectiveObject(value) {
+ const parsed = parseMaybeJsonObject(value);
+ if (parsed && !Array.isArray(parsed)) return parsed;
+ if (value && typeof value === 'object' && !Array.isArray(value)) return value;
+ return {};
+ }
+
+ function collabAgentStateEntries(data) {
+ const states = data?.agentsStates;
+ if (!states || typeof states !== 'object') return [];
+ return Object.entries(states).map(([id, value], index) => {
+ const state = value && typeof value === 'object' ? value : { status: value };
+ const label = String(state.label || state.title || state.nickname || state.name || `子代理 ${index + 1}`);
+ const role = String(state.role || state.agent || state.agentType || '').trim();
+ const status = String(state.status || state.state || 'pending').trim() || 'pending';
+ const detail = String(state.summary || state.lastMessage || state.step || state.description || '').trim();
+ return { id, label, role, status, detail };
+ });
+ }
+
+ function collabStateTone(statusText) {
+ const normalized = String(statusText || '').toLowerCase();
+ if (!normalized) return 'pending';
+ if (/(done|completed|success|finished|idle)/.test(normalized)) return 'done';
+ if (/(fail|error|cancel|aborted|rejected)/.test(normalized)) return 'error';
+ if (/(running|working|active|inprogress|in_progress|executing)/.test(normalized)) return 'running';
+ return 'pending';
+ }
+
+ function collabStateLabel(statusText) {
+ const normalized = String(statusText || '').trim();
+ if (!normalized) return '等待中';
+ const lower = normalized.toLowerCase();
+ if (/(done|completed|success|finished)/.test(lower)) return '已完成';
+ if (/(fail|error|rejected)/.test(lower)) return '失败';
+ if (/(cancel|aborted)/.test(lower)) return '已取消';
+ if (/(running|working|active|inprogress|in_progress|executing)/.test(lower)) return '进行中';
+ if (/(idle|pending|queued|waiting)/.test(lower)) return '等待中';
+ return normalized;
+ }
+
+ function createCollabAgentToolElement(tool) {
+ const data = normalizeCollabAgentData(tool);
+ const wrapper = document.createElement('div');
+ wrapper.className = 'tool-call-content collab-agent-content';
+
+ const stack = document.createElement('div');
+ stack.className = 'collab-agent-stack';
+
+ const header = document.createElement('div');
+ header.className = 'collab-agent-header';
+
+ const titleWrap = document.createElement('div');
+ titleWrap.className = 'collab-agent-title-wrap';
+
+ const kicker = document.createElement('div');
+ kicker.className = 'collab-agent-kicker';
+ kicker.textContent = 'Codex App 子代理';
+ titleWrap.appendChild(kicker);
+
+ const title = document.createElement('div');
+ title.className = 'collab-agent-title';
+ title.textContent = data.tool || '协作任务';
+ titleWrap.appendChild(title);
+
+ const meta = document.createElement('div');
+ meta.className = 'collab-agent-meta';
+ const threadCount = Array.isArray(data.receiverThreadIds) ? data.receiverThreadIds.length : 0;
+ const agentCount = collabAgentStateEntries(data).length;
+ meta.textContent = `${agentCount || threadCount || 0} 个子代理`;
+ titleWrap.appendChild(meta);
+ header.appendChild(titleWrap);
+
+ const statusChip = document.createElement('span');
+ const overallTone = collabStateTone(data.status || (tool.done ? 'completed' : 'running'));
+ statusChip.className = `collab-agent-overall-status ${overallTone}`;
+ statusChip.textContent = collabStateLabel(data.status || (tool.done ? 'completed' : 'running'));
+ header.appendChild(statusChip);
+ stack.appendChild(header);
+
+ const promptText = summarizePrompt(data.prompt);
+ if (promptText) {
+ const promptBlock = document.createElement('div');
+ promptBlock.className = 'collab-agent-prompt';
+ promptBlock.textContent = promptText;
+ stack.appendChild(promptBlock);
+ }
+
+ const stateEntries = collabAgentStateEntries(data);
+ if (stateEntries.length > 0) {
+ const list = document.createElement('div');
+ list.className = 'collab-agent-list';
+ stateEntries.forEach((entry, index) => {
+ const item = document.createElement('div');
+ item.className = 'collab-agent-item';
+
+ const row = document.createElement('div');
+ row.className = 'collab-agent-item-row';
+
+ const label = document.createElement('div');
+ label.className = 'collab-agent-item-label';
+ label.textContent = entry.label || `子代理 ${index + 1}`;
+ row.appendChild(label);
+
+ const chip = document.createElement('span');
+ const tone = collabStateTone(entry.status);
+ chip.className = `collab-agent-item-status ${tone}`;
+ chip.textContent = collabStateLabel(entry.status);
+ row.appendChild(chip);
+ item.appendChild(row);
+
+ if (entry.role) {
+ const role = document.createElement('div');
+ role.className = 'collab-agent-item-role';
+ role.textContent = entry.role;
+ item.appendChild(role);
+ }
+
+ if (entry.detail) {
+ const detail = document.createElement('div');
+ detail.className = 'collab-agent-item-detail';
+ detail.textContent = entry.detail;
+ item.appendChild(detail);
+ }
+
+ if (entry.id) {
+ const footer = document.createElement('div');
+ footer.className = 'collab-agent-item-footer';
+ footer.textContent = `ID ${shortSessionId(entry.id)}`;
+ footer.title = entry.id;
+ footer.addEventListener('click', () => {
+ copyTextToClipboard(entry.id, '子代理线程 ID 已复制');
+ });
+ item.appendChild(footer);
+ }
+
+ list.appendChild(item);
+ });
+ stack.appendChild(list);
+ }
+
+ if (Array.isArray(data.receiverThreadIds) && data.receiverThreadIds.length > 0) {
+ const threads = document.createElement('div');
+ threads.className = 'collab-agent-threads';
+ data.receiverThreadIds.forEach((threadId) => {
+ const btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = 'collab-agent-thread-chip';
+ btn.textContent = `ID ${shortSessionId(threadId)}`;
+ btn.title = `复制子代理线程 ID\n${threadId}`;
+ btn.addEventListener('click', () => {
+ copyTextToClipboard(threadId, '子代理线程 ID 已复制');
+ });
+ threads.appendChild(btn);
+ });
+ stack.appendChild(threads);
+ }
+
+ if (!promptText && stateEntries.length === 0 && (!Array.isArray(data.receiverThreadIds) || data.receiverThreadIds.length === 0)) {
+ const empty = document.createElement('div');
+ empty.className = 'tool-call-empty';
+ empty.textContent = tool.done ? '子代理调用已结束,未返回结构化状态。' : '等待子代理状态…';
+ stack.appendChild(empty);
+ }
+
+ wrapper.appendChild(stack);
+ return wrapper;
+ }
+
function isGroupableToolCall(node) {
return !!(node?.classList?.contains('tool-call') && node.dataset.toolKind !== 'todo_list');
}
@@ -3508,8 +3817,10 @@
renderEpoch++;
const epoch = renderEpoch;
messagesDiv.innerHTML = '';
+ clearUserMessageIndex();
if (messages.length === 0) {
messagesDiv.innerHTML = buildWelcomeMarkup(currentAgent);
+ updateUserOutlinePanel();
renderPendingNotes({ scroll: false });
scrollToBottom();
return;
@@ -3518,6 +3829,7 @@
const frag = document.createDocumentFragment();
messages.forEach((message) => frag.appendChild(buildMsgElement(message)));
messagesDiv.appendChild(frag);
+ updateUserOutlinePanel();
renderPendingNotes({ scroll: false });
scrollToBottom();
return;
@@ -3540,6 +3852,7 @@
const frag0 = document.createDocumentFragment();
for (let i = batches[0][0]; i < batches[0][1]; i++) frag0.appendChild(buildMsgElement(messages[i]));
messagesDiv.appendChild(frag0);
+ updateUserOutlinePanel();
renderPendingNotes({ scroll: false });
scrollToBottom();
@@ -3556,6 +3869,7 @@
const frag = document.createDocumentFragment();
for (let i = start; i < end; i++) frag.appendChild(buildMsgElement(messages[i]));
messagesDiv.insertBefore(frag, messagesDiv.firstChild);
+ updateUserOutlinePanel();
// Compensate scrollTop so visible area stays unchanged
messagesDiv.scrollTop = prevScrollTop + (messagesDiv.scrollHeight - prevHeight);
updateScrollbar();
@@ -3573,12 +3887,14 @@
messages.forEach((m) => frag.appendChild(buildMsgElement(m)));
if (!preserveScroll) {
messagesDiv.insertBefore(frag, messagesDiv.firstChild);
+ updateUserOutlinePanel();
if (!skipScrollbar) updateScrollbar();
return;
}
const prevHeight = messagesDiv.scrollHeight;
const prevScrollTop = messagesDiv.scrollTop;
messagesDiv.insertBefore(frag, messagesDiv.firstChild);
+ updateUserOutlinePanel();
messagesDiv.scrollTop = prevScrollTop + (messagesDiv.scrollHeight - prevHeight);
if (!skipScrollbar) updateScrollbar();
}
@@ -3754,6 +4070,10 @@
return wrapper;
}
+ if (kind === 'collab_agent_tool_call') {
+ return createCollabAgentToolElement(tool);
+ }
+
if (effectiveName === 'AskUserQuestion') {
const questions = extractAskUserQuestions(effectiveInput);
if (questions.length > 0) {
@@ -4691,7 +5011,11 @@
function submitUserMessage(text, attachments = []) {
const welcome = messagesDiv.querySelector('.welcome-msg');
if (welcome) welcome.remove();
- messagesDiv.appendChild(createMsgElement('user', text, attachments));
+ const messageId = createLocalId('user');
+ const element = createMsgElement('user', text, attachments, { messageId });
+ messagesDiv.appendChild(element);
+ registerUserMessage(messageId, element, text);
+ updateUserOutlinePanel();
scrollToBottom();
send({ type: 'message', text, attachments, sessionId: currentSessionId, mode: currentMode, agent: currentAgent });
@@ -4730,7 +5054,11 @@
appendError('Codex App 运行中暂不支持 slash 指令插入。');
return;
}
- messagesDiv.appendChild(createMsgElement('user', text, []));
+ const messageId = createLocalId('user');
+ const element = createMsgElement('user', text, [], { messageId });
+ messagesDiv.appendChild(element);
+ registerUserMessage(messageId, element, text);
+ updateUserOutlinePanel();
scrollToBottom();
send({ type: 'message', text, sessionId: currentSessionId, mode: currentMode, agent: currentAgent });
msgInput.value = '';
@@ -4827,6 +5155,20 @@
});
}
+ if (userOutlineBtn && userOutlinePanel) {
+ userOutlineBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ toggleUserOutlinePanel();
+ });
+ userOutlinePanel.addEventListener('click', (e) => {
+ const target = e.target instanceof HTMLElement ? e.target.closest('.user-outline-item') : null;
+ if (!target) return;
+ const anchorId = target.getAttribute('data-target') || '';
+ closeUserOutlinePanel();
+ scrollToMessage(anchorId);
+ });
+ }
+
// Split new-chat button
newChatBtn.addEventListener('click', () => showNewSessionModal());
newChatArrow.addEventListener('click', (e) => {
@@ -4854,6 +5196,11 @@
e.target !== chatAgentBtn) {
closeAgentMenu();
}
+ if (userOutlinePanel && !userOutlinePanel.hidden &&
+ !userOutlinePanel.contains(e.target) &&
+ e.target !== userOutlineBtn) {
+ closeUserOutlinePanel();
+ }
});
sendBtn.addEventListener('click', sendMessage);
if (noteModeBtn) {
diff --git a/public/index.html b/public/index.html
index 765f8f9..307f961 100644
--- a/public/index.html
+++ b/public/index.html
@@ -61,20 +61,15 @@
新会话
-
-