Compare commits

..

2 Commits

Author SHA1 Message Date
shiyue
3a4006b7d3 Add theme options and timed agent dividers 2026-06-14 12:16:37 +08:00
shiyue
382c5accb7 Refine codex app controls and message navigation 2026-06-13 23:55:11 +08:00
8 changed files with 1757 additions and 43 deletions

View File

@@ -20,3 +20,77 @@ If you're using Codex, project-scoped helpers may also live in:
Keep this managed block so 'trellis update' can refresh the instructions.
<!-- TRELLIS:END -->
## 常用运维命令
重启 cc-web 服务:
```bash
pm2 restart ccweb --update-env
```
## 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 中是否覆盖了上述断言

View File

@@ -256,6 +256,26 @@ function createAgentRuntime(deps) {
return JSON.stringify(truncateObj(item, 1200));
}
function formatAgentMessageDividerTime(date = new Date()) {
const pad = (value) => String(value).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
function createAgentMessageDivider() {
const time = formatAgentMessageDividerTime();
return `<div class="agent-message-divider" data-divider-time="${time}"><span>${time}</span></div>`;
}
function hasAgentMessageBoundaryAtEnd(text) {
return /\n\s*(?:---|\*\*\*|___)\s*$/.test(text)
|| /(?:^|\n)\s*<div\s+class=["']agent-message-divider["'][\s\S]*?<\/div>\s*$/.test(text);
}
function hasAgentMessageBoundaryAtStart(text) {
return /^\s*(?:---|\*\*\*|___)\s*\n/.test(text)
|| /^\s*<div\s+class=["']agent-message-divider["'][\s\S]*?<\/div>\s*/.test(text);
}
function sendRuntime(entry, sessionId, payload) {
wsSend(entry.ws, { ...payload, sessionId });
}
@@ -266,9 +286,9 @@ function createAgentRuntime(deps) {
const currentText = entry.fullText || '';
const hasExistingText = /\S/.test(currentText);
const hasVisualBoundary = /\n\s*(?:---|\*\*\*|___)\s*$/.test(currentText) || /^\s*(?:---|\*\*\*|___)\s*\n/.test(nextText);
const hasVisualBoundary = hasAgentMessageBoundaryAtEnd(currentText) || hasAgentMessageBoundaryAtStart(nextText);
const separator = hasExistingText && !hasVisualBoundary
? (/\n\s*$/.test(currentText) ? '\n---\n\n' : '\n\n---\n\n')
? (/\n\s*$/.test(currentText) ? `\n${createAgentMessageDivider()}\n\n` : `\n\n${createAgentMessageDivider()}\n\n`)
: '';
const chunk = separator + nextText;
entry.fullText += chunk;

View File

@@ -18,6 +18,26 @@ function createCodexAppRuntime(deps = {}) {
wsSend(entry.ws, { ...payload, sessionId });
}
function formatAgentMessageDividerTime(date = new Date()) {
const pad = (value) => String(value).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
function createAgentMessageDivider() {
const time = formatAgentMessageDividerTime();
return `<div class="agent-message-divider" data-divider-time="${time}"><span>${time}</span></div>`;
}
function hasAgentMessageBoundaryAtEnd(text) {
return /\n\s*(?:---|\*\*\*|___)\s*$/.test(text)
|| /(?:^|\n)\s*<div\s+class=["']agent-message-divider["'][\s\S]*?<\/div>\s*$/.test(text);
}
function hasAgentMessageBoundaryAtStart(text) {
return /^\s*(?:---|\*\*\*|___)\s*\n/.test(text)
|| /^\s*<div\s+class=["']agent-message-divider["'][\s\S]*?<\/div>\s*/.test(text);
}
function itemKind(item) {
switch (item?.type) {
case 'commandExecution':
@@ -235,9 +255,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 +273,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 = hasAgentMessageBoundaryAtEnd(currentText) || hasAgentMessageBoundaryAtStart(String(nextText || ''));
return hasVisualBoundary ? '' : `\n\n${createAgentMessageDivider()}\n\n`;
}
function updateToolResult(entry, sessionId, itemId, result, done = false, patch = {}) {

View File

@@ -2,10 +2,11 @@
(function () {
'use strict';
const ASSET_VERSION = '20260613-codexapp-tools2';
const ASSET_VERSION = '20260614-divider-time-selectfix';
const WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`;
const RENDER_DEBOUNCE = 100;
const COMPOSER_SUGGESTION_DEBOUNCE = 120;
const DIVIDER_TIME_STORAGE_KEY = 'cc-web-show-divider-time';
const SLASH_COMMANDS = [
{ cmd: '/clear', desc: '清除当前会话' },
@@ -73,6 +74,42 @@
desc: '更明亮的留白和更克制的棕色强调,像编辑台一样安静。',
swatches: ['#f6f1e8', '#efe8dc', '#8b5e3c', '#2f4b45'],
},
{
value: 'sage',
label: 'Sage Console',
desc: '清透鼠尾草绿与石墨文字,适合长时间工作流。',
swatches: ['#f5f8f2', '#e6efdf', '#2f6f64', '#557ba3'],
},
{
value: 'ink',
label: 'Ink Focus',
desc: '浅灰纸面配靛蓝重点,信息密度更高也更冷静。',
swatches: ['#f6f7fb', '#e8edf5', '#3f5fb5', '#3f8f73'],
},
{
value: 'dawn',
label: 'Dawn Studio',
desc: '晨光米白配珊瑚红,保留温度但比暖纸更轻。',
swatches: ['#fff7f2', '#f3e8e1', '#b5524d', '#4f8a6b'],
},
{
value: 'carbon',
label: 'Carbon Mint',
desc: '石墨黑底配薄荷绿重点,夜间使用更稳。',
swatches: ['#0f1314', '#202829', '#67d8b2', '#9fb7ff'],
},
{
value: 'nocturne',
label: 'Nocturne Teal',
desc: '深海青黑配电光蓝,适合高专注会话。',
swatches: ['#081417', '#142b31', '#5ecdf5', '#f0c36a'],
},
{
value: 'cinder',
label: 'Cinder Rose',
desc: '炭黑底配低饱和玫瑰色,暗色里保留一点温度。',
swatches: ['#151112', '#2a2022', '#e68193', '#67c587'],
},
];
// --- State ---
@@ -98,6 +135,7 @@
let currentModel = 'opus';
let currentAgent = AGENT_LABELS[localStorage.getItem('cc-web-agent')] ? localStorage.getItem('cc-web-agent') : DEFAULT_AGENT;
let currentTheme = (document.documentElement.dataset.theme || localStorage.getItem('cc-web-theme') || 'washi');
let showAgentDividerTime = localStorage.getItem(DIVIDER_TIME_STORAGE_KEY) !== '0';
let codexConfigCache = null;
let loadedHistorySessionId = null;
let activeSessionLoad = null;
@@ -122,6 +160,8 @@
let noteMode = false;
let noteDraftSeq = 0;
const pendingNotesByTarget = new Map();
const userMessageIndex = new Map();
document.documentElement.dataset.dividerTime = showAgentDividerTime ? 'show' : 'hide';
// --- DOM ---
const $ = (sel) => document.querySelector(sel);
@@ -149,6 +189,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');
@@ -444,6 +486,26 @@
refreshThemeSummaries();
}
function getDividerTimeSummary() {
return showAgentDividerTime ? '显示时间' : '不显示时间';
}
function refreshDividerTimeControls(root = document) {
root.querySelectorAll('[data-divider-time-summary]').forEach((node) => {
node.textContent = getDividerTimeSummary();
});
root.querySelectorAll('[data-divider-time-toggle]').forEach((node) => {
node.checked = showAgentDividerTime;
});
}
function applyDividerTimePreference(visible) {
showAgentDividerTime = !!visible;
document.documentElement.dataset.dividerTime = showAgentDividerTime ? 'show' : 'hide';
localStorage.setItem(DIVIDER_TIME_STORAGE_KEY, showAgentDividerTime ? '1' : '0');
refreshDividerTimeControls();
}
function buildThemePickerHtml(options = {}) {
const { showSectionTitle = true } = options;
return `
@@ -473,7 +535,7 @@
});
}
function buildThemeEntryHtml() {
function buildAppearanceSettingsHtml() {
return `
<div class="settings-section-title">外观</div>
<button class="settings-nav-card" type="button" data-open-theme-page>
@@ -483,9 +545,34 @@
</span>
<span class="settings-nav-card-arrow" aria-hidden="true"></span>
</button>
<label class="settings-toggle-row">
<span class="settings-toggle-copy">
<span class="settings-toggle-title">分隔线时间</span>
<span class="settings-toggle-meta">当前:<span data-divider-time-summary>${escapeHtml(getDividerTimeSummary())}</span></span>
</span>
<span class="settings-switch">
<input type="checkbox" data-divider-time-toggle ${showAgentDividerTime ? 'checked' : ''}>
<span class="settings-switch-track" aria-hidden="true">
<span class="settings-switch-thumb"></span>
</span>
</span>
</label>
`;
}
function mountAppearanceSettings(panel) {
const themePageBtn = panel.querySelector('[data-open-theme-page]');
if (themePageBtn) themePageBtn.addEventListener('click', openThemeSubpage);
const dividerTimeToggle = panel.querySelector('[data-divider-time-toggle]');
if (dividerTimeToggle) {
dividerTimeToggle.checked = showAgentDividerTime;
dividerTimeToggle.addEventListener('change', () => {
applyDividerTimePreference(dividerTimeToggle.checked);
});
}
refreshDividerTimeControls(panel);
}
function buildNotifyEntryHtml(config) {
const provider = config?.provider || 'off';
const providerLabel = PROVIDER_OPTIONS.find(o => o.value === provider)?.label || '关闭';
@@ -682,6 +769,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 = '<div class="user-outline-empty">暂无用户消息</div>';
userOutlineBtn.disabled = true;
} else {
userOutlinePanel.innerHTML = items.map((item, index) => `
<button type="button" class="user-outline-item" data-target="${escapeHtml(item.targetMessageId)}">
<span class="user-outline-index">${index + 1}</span>
<span class="user-outline-text">${escapeHtml(item.label)}</span>
</button>
`).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 +2343,7 @@
function resetChatView(agent) {
setCurrentAgent(agent);
closeUserOutlinePanel();
closeFileBrowser();
currentSessionId = null;
loadedHistorySessionId = null;
@@ -2236,6 +2397,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 +2423,7 @@
const targetAgent = normalizeAgent(agent);
const { preserveCurrent = true, loadLast = true } = options;
setCurrentAgent(targetAgent);
closeUserOutlinePanel();
renderSessionList();
const currentMeta = currentSessionId ? getSessionMeta(currentSessionId) : null;
@@ -2353,6 +2516,7 @@
if (currentSessionId && currentSessionId !== sessionId) {
send({ type: 'detach_view' });
}
closeUserOutlinePanel();
clearSessionLoading();
touchSessionCache(sessionId);
applySessionSnapshot(snapshot, { immediate: true, suppressUnreadToast: true });
@@ -2361,6 +2525,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 +3276,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 +3342,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 = `
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="9" width="10" height="10" rx="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v1"></path>
</svg>
`;
copyBtn.addEventListener('click', (event) => {
event.stopPropagation();
copyTextToClipboard(content, '用户消息已复制');
});
bubble.appendChild(copyBtn);
}
if (attachments.length > 0) {
bubble.insertAdjacentHTML('beforeend', renderAttachmentPreviews(attachments));
@@ -3186,6 +3373,9 @@
hydrateAttachmentPreviews(bubble, attachments);
div.appendChild(avatar);
div.appendChild(bubble);
if (role === 'user') {
registerUserMessage(resolvedMessageId, div, content);
}
return div;
}
@@ -3429,6 +3619,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 +3901,10 @@
renderEpoch++;
const epoch = renderEpoch;
messagesDiv.innerHTML = '';
clearUserMessageIndex();
if (messages.length === 0) {
messagesDiv.innerHTML = buildWelcomeMarkup(currentAgent);
updateUserOutlinePanel();
renderPendingNotes({ scroll: false });
scrollToBottom();
return;
@@ -3518,6 +3913,7 @@
const frag = document.createDocumentFragment();
messages.forEach((message) => frag.appendChild(buildMsgElement(message)));
messagesDiv.appendChild(frag);
updateUserOutlinePanel();
renderPendingNotes({ scroll: false });
scrollToBottom();
return;
@@ -3540,6 +3936,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 +3953,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 +3971,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 +4154,10 @@
return wrapper;
}
if (kind === 'collab_agent_tool_call') {
return createCollabAgentToolElement(tool);
}
if (effectiveName === 'AskUserQuestion') {
const questions = extractAskUserQuestions(effectiveInput);
if (questions.length > 0) {
@@ -3999,7 +4403,7 @@
${title}
<div style="font-size:0.9em;color:var(--text-primary);margin-bottom:20px;line-height:1.7;word-break:break-word;white-space:pre-line">${escapeHtml(options.message || '')}</div>
<div style="display:flex;flex-direction:column;gap:8px">
<button id="simple-confirm-ok" style="width:100%;padding:10px;border:none;border-radius:10px;background:var(--accent);color:#fff;font-size:0.95em;font-weight:600;cursor:pointer;font-family:inherit">${escapeHtml(confirmText)}</button>
<button id="simple-confirm-ok" style="width:100%;padding:10px;border:none;border-radius:10px;background:var(--accent);color:var(--accent-ink, #fff);font-size:0.95em;font-weight:600;cursor:pointer;font-family:inherit">${escapeHtml(confirmText)}</button>
<button id="simple-confirm-cancel" style="width:100%;padding:9px;border:none;border-radius:10px;background:transparent;color:var(--text-muted);font-size:0.85em;cursor:pointer;font-family:inherit">${escapeHtml(cancelText)}</button>
</div>
`;
@@ -4035,7 +4439,7 @@
box.innerHTML = `
<div style="font-size:0.9em;color:var(--text-primary);margin-bottom:20px;line-height:1.7">${escapeHtml(getDeleteConfirmMessage(agent))}</div>
<div style="display:flex;flex-direction:column;gap:8px">
<button id="del-confirm-ok" style="width:100%;padding:10px;border:none;border-radius:10px;background:var(--accent);color:#fff;font-size:0.95em;font-weight:600;cursor:pointer;font-family:inherit">确认删除</button>
<button id="del-confirm-ok" style="width:100%;padding:10px;border:none;border-radius:10px;background:var(--accent);color:var(--accent-ink, #fff);font-size:0.95em;font-weight:600;cursor:pointer;font-family:inherit">确认删除</button>
<button id="del-confirm-skip" style="width:100%;padding:9px;border:1px solid var(--border-color);border-radius:10px;background:var(--bg-tertiary);color:var(--text-secondary);font-size:0.85em;cursor:pointer;font-family:inherit">确认且不再提示</button>
<button id="del-confirm-cancel" style="width:100%;padding:9px;border:none;border-radius:10px;background:transparent;color:var(--text-muted);font-size:0.85em;cursor:pointer;font-family:inherit">取消</button>
</div>
@@ -4268,7 +4672,7 @@
if (!currentSessionId || chatTitle.contentEditable === 'true') return;
const originalText = chatTitle.textContent;
chatTitle.contentEditable = 'true';
chatTitle.style.background = '#fff';
chatTitle.style.background = 'var(--surface-strong)';
chatTitle.style.outline = '1px solid var(--accent)';
chatTitle.style.borderRadius = '6px';
chatTitle.style.padding = '2px 8px';
@@ -4691,7 +5095,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 +5138,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 +5239,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 +5280,11 @@
e.target !== chatAgentBtn) {
closeAgentMenu();
}
if (userOutlinePanel && !userOutlinePanel.hidden &&
!userOutlinePanel.contains(e.target) &&
e.target !== userOutlineBtn) {
closeUserOutlinePanel();
}
});
sendBtn.addEventListener('click', sendMessage);
if (noteModeBtn) {
@@ -5300,7 +5731,7 @@
<div class="settings-divider"></div>
${buildThemeEntryHtml()}
${buildAppearanceSettingsHtml()}
<div class="settings-divider"></div>
@@ -5318,8 +5749,7 @@
overlay.appendChild(panel);
document.body.appendChild(overlay);
const themePageBtn = panel.querySelector('[data-open-theme-page]');
if (themePageBtn) themePageBtn.addEventListener('click', openThemeSubpage);
mountAppearanceSettings(panel);
const notifyPageBtn = panel.querySelector('[data-open-notify-page]');
if (notifyPageBtn) notifyPageBtn.addEventListener('click', openNotifySubpage);
@@ -5587,7 +6017,7 @@
<div class="settings-divider"></div>
${buildThemeEntryHtml()}
${buildAppearanceSettingsHtml()}
<div class="settings-divider"></div>
@@ -5605,8 +6035,7 @@
overlay.appendChild(panel);
document.body.appendChild(overlay);
const themePageBtn = panel.querySelector('[data-open-theme-page]');
if (themePageBtn) themePageBtn.addEventListener('click', openThemeSubpage);
mountAppearanceSettings(panel);
const notifyPageBtn2 = panel.querySelector('[data-open-notify-page]');
if (notifyPageBtn2) notifyPageBtn2.addEventListener('click', openNotifySubpage);

View File

@@ -9,10 +9,12 @@
<script>
(function () {
var theme = localStorage.getItem('cc-web-theme') || 'washi';
var dividerTime = localStorage.getItem('cc-web-show-divider-time') === '0' ? 'hide' : 'show';
document.documentElement.dataset.theme = theme;
document.documentElement.dataset.dividerTime = dividerTime;
})();
</script>
<link rel="stylesheet" href="style.css?v=20260613-codexapp-tools2">
<link rel="stylesheet" href="style.css?v=20260614-divider-time-selectfix">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">
</head>
<body>
@@ -61,20 +63,15 @@
<button id="menu-btn" class="menu-btn" title="菜单"></button>
<span id="chat-title" class="chat-title">新会话</span>
<button id="chat-session-id-btn" class="chat-session-id-btn" type="button" title="复制当前会话 ID" hidden>ID</button>
<button id="chat-agent-btn" class="chat-agent-btn" type="button" aria-haspopup="menu" aria-expanded="false">Claude</button>
<div id="chat-agent-menu" class="chat-agent-menu" hidden>
<button type="button" class="chat-agent-option active" data-agent="claude">Claude</button>
<button type="button" class="chat-agent-option" data-agent="codex">Codex</button>
<button type="button" class="chat-agent-option" data-agent="codexapp">Codex App</button>
<div class="chat-agent-picker">
<button id="chat-agent-btn" class="chat-agent-btn" type="button" aria-haspopup="menu" aria-expanded="false">Claude</button>
<div id="chat-agent-menu" class="chat-agent-menu" hidden>
<button type="button" class="chat-agent-option active" data-agent="claude">Claude</button>
<button type="button" class="chat-agent-option" data-agent="codex">Codex</button>
<button type="button" class="chat-agent-option" data-agent="codexapp">Codex App</button>
</div>
</div>
<span id="chat-runtime-state" class="chat-runtime-state" hidden>运行中</span>
<button id="chat-cwd" class="chat-cwd" type="button" hidden></button>
<select id="mode-select" class="mode-select" title="权限模式">
<option value="yolo">YOLO</option>
<option value="default">默认</option>
<option value="plan">Plan</option>
</select>
<span id="cost-display" class="cost-display" hidden></span>
</header>
<div class="messages-wrap">
@@ -94,6 +91,19 @@
<div id="cmd-menu" class="cmd-menu" hidden></div>
<div class="input-area">
<div class="chat-controls" aria-label="会话控制">
<span id="chat-runtime-state" class="chat-runtime-state" hidden>运行中</span>
<select id="mode-select" class="mode-select" title="权限模式">
<option value="yolo">YOLO</option>
<option value="default">默认</option>
<option value="plan">Plan</option>
</select>
<div class="user-outline-anchor">
<button id="user-outline-btn" class="user-outline-btn" type="button" aria-expanded="false" aria-controls="user-outline-panel" title="定位用户消息">定位</button>
<div id="user-outline-panel" class="user-outline-panel" hidden></div>
</div>
<span id="cost-display" class="cost-display" hidden></span>
</div>
<div id="attachment-tray" class="attachment-tray" hidden></div>
<div id="pending-notes-tray" class="pending-notes-tray" hidden></div>
<div class="input-wrapper">
@@ -139,6 +149,6 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.1/marked.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<script src="app.js?v=20260613-codexapp-tools2"></script>
<script src="app.js?v=20260614-divider-time-selectfix"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -161,6 +161,69 @@ function completeTurn(thread, turnId, text, status = 'completed') {
});
}
if (/subagent|collab/i.test(text)) {
send({
method: 'item/started',
params: {
threadId: thread.id,
turnId,
startedAtMs: Date.now(),
item: {
id: 'tool-collab',
type: 'collabAgentToolCall',
tool: 'spawn_agent',
prompt: '整理前端改动并回报状态',
receiverThreadIds: ['child-thread-a', 'child-thread-b'],
agentsStates: {
'child-thread-a': {
name: '实现代理',
role: 'frontend',
status: 'in_progress',
summary: '正在补充结构化渲染与样式',
},
'child-thread-b': {
name: '验证代理',
role: 'qa',
status: 'completed',
summary: '已完成基础事件链检查',
},
},
status: 'inProgress',
},
},
});
send({
method: 'item/completed',
params: {
threadId: thread.id,
turnId,
completedAtMs: Date.now(),
item: {
id: 'tool-collab',
type: 'collabAgentToolCall',
tool: 'spawn_agent',
prompt: '整理前端改动并回报状态',
receiverThreadIds: ['child-thread-a', 'child-thread-b'],
agentsStates: {
'child-thread-a': {
name: '实现代理',
role: 'frontend',
status: 'completed',
summary: '结构化渲染已完成',
},
'child-thread-b': {
name: '验证代理',
role: 'qa',
status: 'completed',
summary: '事件链与持久化检查通过',
},
},
status: 'completed',
},
},
});
}
send({
method: 'thread/tokenUsage/updated',
params: {

View File

@@ -811,6 +811,17 @@ async function main() {
assert(/"hasCcwebMcpConfig": true/.test(codexAppDynamicTool.result || ''), 'Codex App thread/start should pass ccweb MCP config');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
ws.send(JSON.stringify({ type: 'message', text: 'codexapp subagent prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
const codexAppCollabTool = await nextMessage(messages, ws, (msg) => msg.type === 'tool_end' && msg.sessionId === codexAppSession.sessionId && msg.toolUseId === 'tool-collab');
assert(codexAppCollabTool.kind === 'collab_agent_tool_call', 'Codex App should surface collab agent tool calls');
assert(/child-thread-a/.test(codexAppCollabTool.result || ''), 'Codex App collab tool should include child thread ids');
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
storedCodexApp = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
const hasCollabTool = storedCodexApp.messages
.flatMap((message) => Array.isArray(message.toolCalls) ? message.toolCalls : [])
.some((tool) => tool.kind === 'collab_agent_tool_call');
assert(hasCollabTool, 'Codex App collab tool should be persisted into session history');
ws.send(JSON.stringify({ type: 'message', text: 'codexapp collaboration plan probe', sessionId: codexAppSession.sessionId, mode: 'plan', agent: 'codexapp' }));
const codexAppPlanCollab = await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && /collaboration mode:/.test(msg.text || ''));
assert(/"mode":"plan"/.test(codexAppPlanCollab.text || ''), 'Codex App Plan mode should pass plan collaboration mode');