diff --git a/lib/agent-runtime.js b/lib/agent-runtime.js
index e70e81e..bc848c2 100644
--- a/lib/agent-runtime.js
+++ b/lib/agent-runtime.js
@@ -266,9 +266,9 @@ function createAgentRuntime(deps) {
const currentText = entry.fullText || '';
const hasExistingText = /\S/.test(currentText);
- const hasParagraphBoundary = /\n\s*\n\s*$/.test(currentText) || /^\s*\n\s*\n/.test(nextText);
- const separator = hasExistingText && !hasParagraphBoundary
- ? (/\n\s*$/.test(currentText) ? '\n' : '\n\n')
+ const hasVisualBoundary = /\n\s*(?:---|\*\*\*|___)\s*$/.test(currentText) || /^\s*(?:---|\*\*\*|___)\s*\n/.test(nextText);
+ const separator = hasExistingText && !hasVisualBoundary
+ ? (/\n\s*$/.test(currentText) ? '\n---\n\n' : '\n\n---\n\n')
: '';
const chunk = separator + nextText;
entry.fullText += chunk;
diff --git a/lib/ccweb-mcp-server.js b/lib/ccweb-mcp-server.js
index 8946805..59625f9 100644
--- a/lib/ccweb-mcp-server.js
+++ b/lib/ccweb-mcp-server.js
@@ -18,7 +18,7 @@ const TOOLS = [
properties: {
agent: {
type: 'string',
- enum: ['claude', 'codex'],
+ enum: ['claude', 'codex', 'codexapp'],
description: '可选。只返回指定 Agent 的对话。',
},
status: {
@@ -55,6 +55,25 @@ const TOOLS = [
additionalProperties: false,
},
},
+ {
+ name: 'ccweb_request_reply',
+ description: '向指定 ccweb 对话发送一条消息,并在目标对话本轮输出完成后自动把回复发回当前对话。',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ targetConversationId: {
+ type: 'string',
+ description: '目标对话 ID。',
+ },
+ content: {
+ type: 'string',
+ description: '要发送到目标对话的纯文本消息。',
+ },
+ },
+ required: ['targetConversationId', 'content'],
+ additionalProperties: false,
+ },
+ },
];
function writeMessage(message) {
diff --git a/lib/codex-app-runtime.js b/lib/codex-app-runtime.js
new file mode 100644
index 0000000..2084f2d
--- /dev/null
+++ b/lib/codex-app-runtime.js
@@ -0,0 +1,472 @@
+'use strict';
+
+function createCodexAppRuntime(deps = {}) {
+ const {
+ wsSend,
+ loadSession,
+ saveSession,
+ truncateObj,
+ } = deps;
+
+ function truncate(value, maxLen) {
+ if (typeof truncateObj === 'function') return truncateObj(value, maxLen);
+ const text = typeof value === 'string' ? value : JSON.stringify(value);
+ return text.length > maxLen ? `${text.slice(0, maxLen)}...` : value;
+ }
+
+ function sendRuntime(entry, sessionId, payload) {
+ wsSend(entry.ws, { ...payload, sessionId });
+ }
+
+ function itemKind(item) {
+ switch (item?.type) {
+ case 'commandExecution':
+ return 'command_execution';
+ case 'mcpToolCall':
+ return 'mcp_tool_call';
+ case 'fileChange':
+ return 'file_change';
+ case 'reasoning':
+ return 'reasoning';
+ case 'dynamicToolCall':
+ return 'dynamic_tool_call';
+ case 'collabAgentToolCall':
+ return 'collab_agent_tool_call';
+ case 'webSearch':
+ return 'web_search';
+ case 'imageView':
+ return 'image_view';
+ case 'imageGeneration':
+ return 'image_generation';
+ default:
+ return item?.type || 'codex_app_item';
+ }
+ }
+
+ function itemName(item) {
+ switch (item?.type) {
+ case 'commandExecution':
+ return 'CommandExecution';
+ case 'mcpToolCall':
+ return 'McpToolCall';
+ case 'fileChange':
+ return 'FileChange';
+ case 'reasoning':
+ return 'Reasoning';
+ case 'dynamicToolCall':
+ return item.tool || 'DynamicToolCall';
+ case 'collabAgentToolCall':
+ return item.tool || 'CollabAgentToolCall';
+ case 'webSearch':
+ return 'WebSearch';
+ case 'imageGeneration':
+ return 'ImageGeneration';
+ default:
+ return item?.type || 'CodexAppItem';
+ }
+ }
+
+ function itemInput(item) {
+ if (!item) return null;
+ switch (item.type) {
+ case 'commandExecution':
+ return { command: item.command || '' };
+ case 'mcpToolCall':
+ return {
+ server: item.server || '',
+ tool: item.tool || '',
+ arguments: item.arguments ?? null,
+ };
+ case 'fileChange':
+ return { changes: item.changes || [] };
+ case 'reasoning':
+ return { content: item.content || [], summary: item.summary || [] };
+ case 'dynamicToolCall':
+ return { tool: item.tool || '', namespace: item.namespace || null, arguments: item.arguments ?? null };
+ case 'collabAgentToolCall':
+ return {
+ tool: item.tool || '',
+ prompt: item.prompt || null,
+ receiverThreadIds: item.receiverThreadIds || [],
+ agentsStates: item.agentsStates || {},
+ };
+ default:
+ return truncate(item, 500);
+ }
+ }
+
+ function reasoningTextFromItem(item) {
+ const parts = [...(item?.summary || []), ...(item?.content || [])];
+ return parts.map((part) => {
+ if (typeof part === 'string') return part;
+ if (typeof part?.text === 'string') return part.text;
+ return '';
+ }).filter(Boolean).join('\n\n');
+ }
+
+ function hasReasoningContent(item) {
+ return Boolean(reasoningTextFromItem(item).trim());
+ }
+
+ function itemMeta(item) {
+ if (!item) return null;
+ switch (item.type) {
+ case 'commandExecution':
+ return {
+ kind: 'command_execution',
+ title: 'Shell Command',
+ subtitle: item.command || '',
+ exitCode: typeof item.exitCode === 'number' ? item.exitCode : null,
+ status: item.status || null,
+ };
+ case 'mcpToolCall':
+ return {
+ kind: 'mcp_tool_call',
+ title: 'MCP Tool',
+ subtitle: [item.server, item.tool].filter(Boolean).join('.'),
+ status: item.status || null,
+ };
+ case 'fileChange':
+ return {
+ kind: 'file_change',
+ title: 'File Change',
+ subtitle: (item.changes || []).map((change) => change.path).filter(Boolean).join(', '),
+ status: item.status || null,
+ };
+ case 'reasoning':
+ return {
+ kind: 'reasoning',
+ title: 'Reasoning',
+ subtitle: reasoningTextFromItem(item).replace(/\s+/g, ' ').slice(0, 120),
+ status: item.status || null,
+ };
+ default:
+ return {
+ kind: itemKind(item),
+ title: itemName(item),
+ subtitle: item.tool || item.query || '',
+ status: item.status || null,
+ };
+ }
+ }
+
+ function stringifyMcpResult(result) {
+ if (!result) return '';
+ if (Array.isArray(result.content)) {
+ const text = result.content.map((part) => {
+ if (typeof part?.text === 'string') return part.text;
+ try {
+ return JSON.stringify(part);
+ } catch {
+ return String(part);
+ }
+ }).filter(Boolean).join('\n');
+ if (text) return text;
+ }
+ try {
+ return JSON.stringify(result, null, 2);
+ } catch {
+ return String(result);
+ }
+ }
+
+ function itemResult(item) {
+ if (!item) return '';
+ switch (item.type) {
+ case 'commandExecution':
+ return item.aggregatedOutput || '';
+ case 'mcpToolCall':
+ return item.error?.message || stringifyMcpResult(item.result);
+ case 'fileChange':
+ return JSON.stringify(item.changes || [], null, 2);
+ case 'reasoning':
+ return reasoningTextFromItem(item);
+ case 'dynamicToolCall':
+ return JSON.stringify({
+ success: item.success ?? null,
+ contentItems: item.contentItems || null,
+ }, null, 2);
+ case 'collabAgentToolCall':
+ return JSON.stringify({
+ status: item.status || null,
+ receiverThreadIds: item.receiverThreadIds || [],
+ agentsStates: item.agentsStates || {},
+ }, null, 2);
+ default:
+ if (typeof item.text === 'string') return item.text;
+ return JSON.stringify(truncate(item, 1200));
+ }
+ }
+
+ function ensureToolCall(entry, item, sessionId) {
+ if (!item?.id) return null;
+ const kind = itemKind(item);
+ let toolCall = entry.toolCalls.find((tool) => tool.id === item.id);
+ if (toolCall) {
+ toolCall.name = itemName(item);
+ toolCall.kind = kind;
+ toolCall.meta = itemMeta(item) || toolCall.meta || null;
+ if (toolCall.input == null) toolCall.input = itemInput(item);
+ return toolCall;
+ }
+
+ toolCall = {
+ name: itemName(item),
+ id: item.id,
+ kind,
+ meta: itemMeta(item),
+ input: itemInput(item),
+ done: false,
+ };
+ entry.toolCalls.push(toolCall);
+ sendRuntime(entry, sessionId, {
+ type: 'tool_start',
+ name: toolCall.name,
+ toolUseId: toolCall.id,
+ input: toolCall.input,
+ kind: toolCall.kind,
+ meta: toolCall.meta,
+ });
+ return toolCall;
+ }
+
+ function appendAgentText(entry, itemId, text) {
+ const nextText = String(text || '');
+ if (!nextText) return '';
+ if (!entry.agentMessageItems) entry.agentMessageItems = new Map();
+ const currentItemText = entry.agentMessageItems.get(itemId) || '';
+ entry.agentMessageItems.set(itemId, currentItemText + nextText);
+ entry.fullText = (entry.fullText || '') + nextText;
+ return nextText;
+ }
+
+ function appendAgentCompletedText(entry, item) {
+ const text = String(item?.text || '');
+ if (!text) return '';
+ if (!entry.agentMessageItems) entry.agentMessageItems = new Map();
+ const currentItemText = entry.agentMessageItems.get(item.id) || '';
+ if (currentItemText && text.startsWith(currentItemText)) {
+ const remainder = text.slice(currentItemText.length);
+ entry.agentMessageItems.set(item.id, text);
+ entry.fullText = (entry.fullText || '') + remainder;
+ return remainder;
+ }
+ if (currentItemText === text) return '';
+ entry.agentMessageItems.set(item.id, text);
+ entry.fullText = (entry.fullText || '') + text;
+ return text;
+ }
+
+ function updateToolResult(entry, sessionId, itemId, result, done = false, patch = {}) {
+ if (!itemId) return;
+ let toolCall = entry.toolCalls.find((tool) => tool.id === itemId);
+ if (!toolCall) {
+ toolCall = {
+ name: patch.name || 'CodexAppItem',
+ id: itemId,
+ kind: patch.kind || 'codex_app_item',
+ meta: patch.meta || null,
+ input: patch.input || null,
+ done: false,
+ };
+ entry.toolCalls.push(toolCall);
+ sendRuntime(entry, sessionId, {
+ type: 'tool_start',
+ name: toolCall.name,
+ toolUseId: toolCall.id,
+ input: toolCall.input,
+ kind: toolCall.kind,
+ meta: toolCall.meta,
+ });
+ }
+ if (patch.name) toolCall.name = patch.name;
+ if (patch.kind) toolCall.kind = patch.kind;
+ if (patch.meta) toolCall.meta = patch.meta;
+ if (patch.input !== undefined) toolCall.input = patch.input;
+ toolCall.done = done;
+ toolCall.result = result;
+ sendRuntime(entry, sessionId, {
+ type: done ? 'tool_end' : 'tool_update',
+ toolUseId: itemId,
+ name: toolCall.name,
+ input: toolCall.input,
+ result,
+ kind: toolCall.kind,
+ meta: toolCall.meta,
+ });
+ }
+
+ function updateUsage(sessionId, entry, usage) {
+ const total = usage?.total || usage || null;
+ if (!total) return;
+ const session = loadSession(sessionId);
+ if (!session) return;
+ session.totalUsage = {
+ inputTokens: total.inputTokens || 0,
+ cachedInputTokens: total.cachedInputTokens || 0,
+ outputTokens: total.outputTokens || 0,
+ };
+ entry.lastUsage = session.totalUsage;
+ saveSession(session);
+ sendRuntime(entry, sessionId, { type: 'usage', totalUsage: session.totalUsage });
+ }
+
+ function processCodexAppNotification(entry, notification, sessionId) {
+ if (!entry || !notification?.method) return { done: false };
+ const method = notification.method;
+ const params = notification.params || {};
+
+ if (params.threadId && !entry.threadId) entry.threadId = params.threadId;
+ if (params.turnId && !entry.turnId) entry.turnId = params.turnId;
+
+ switch (method) {
+ case 'turn/started':
+ if (params.turn?.id) entry.turnId = params.turn.id;
+ return { done: false };
+
+ case 'item/started': {
+ const item = params.item;
+ if (!item || !item.id || item.type === 'agentMessage' || item.type === 'userMessage') return { done: false };
+ if (item.type === 'reasoning' && !hasReasoningContent(item)) return { done: false };
+ ensureToolCall(entry, item, sessionId);
+ return { done: false };
+ }
+
+ case 'item/agentMessage/delta': {
+ const chunk = appendAgentText(entry, params.itemId || 'agent-message', params.delta || '');
+ if (chunk) sendRuntime(entry, sessionId, { type: 'text_delta', text: chunk });
+ return { done: false };
+ }
+
+ case 'item/commandExecution/outputDelta': {
+ const itemId = params.itemId;
+ const current = entry.toolOutputDeltas?.get(itemId) || '';
+ const next = current + String(params.delta || '');
+ if (!entry.toolOutputDeltas) entry.toolOutputDeltas = new Map();
+ entry.toolOutputDeltas.set(itemId, next);
+ updateToolResult(entry, sessionId, itemId, next, false, {
+ name: 'CommandExecution',
+ kind: 'command_execution',
+ });
+ return { done: false };
+ }
+
+ case 'item/fileChange/patchUpdated': {
+ updateToolResult(entry, sessionId, params.itemId, JSON.stringify(params.changes || [], null, 2), false, {
+ name: 'FileChange',
+ kind: 'file_change',
+ input: { changes: params.changes || [] },
+ meta: {
+ kind: 'file_change',
+ title: 'File Change',
+ subtitle: (params.changes || []).map((change) => change.path).filter(Boolean).join(', '),
+ status: 'inProgress',
+ },
+ });
+ return { done: false };
+ }
+
+ case 'item/mcpToolCall/progress': {
+ updateToolResult(entry, sessionId, params.itemId, params.message || '', false, {
+ name: 'McpToolCall',
+ kind: 'mcp_tool_call',
+ });
+ return { done: false };
+ }
+
+ case 'item/reasoning/summaryTextDelta':
+ case 'item/reasoning/textDelta': {
+ const itemId = params.itemId;
+ const delta = String(params.delta || '');
+ if (!itemId || !delta) return { done: false };
+ const current = entry.toolOutputDeltas?.get(itemId) || '';
+ const next = current + delta;
+ if (!entry.toolOutputDeltas) entry.toolOutputDeltas = new Map();
+ entry.toolOutputDeltas.set(itemId, next);
+ updateToolResult(entry, sessionId, itemId, next, false, {
+ name: 'Reasoning',
+ kind: 'reasoning',
+ });
+ return { done: false };
+ }
+
+ case 'item/completed': {
+ const item = params.item;
+ if (!item || !item.id) return { done: false };
+ if (item.type === 'agentMessage') {
+ const chunk = appendAgentCompletedText(entry, item);
+ if (chunk) sendRuntime(entry, sessionId, { type: 'text_delta', text: chunk });
+ return { done: false };
+ }
+ if (item.type === 'userMessage') return { done: false };
+ if (item.type === 'reasoning') {
+ const result = (itemResult(item) || entry.toolOutputDeltas?.get(item.id) || '').slice(0, 4000);
+ if (!result.trim()) return { done: false };
+ const toolCall = ensureToolCall(entry, item, sessionId);
+ if (!toolCall) return { done: false };
+ toolCall.done = true;
+ toolCall.result = result;
+ toolCall.meta = itemMeta(item) || toolCall.meta;
+ sendRuntime(entry, sessionId, {
+ type: 'tool_end',
+ toolUseId: toolCall.id,
+ result,
+ kind: toolCall.kind,
+ meta: toolCall.meta,
+ });
+ return { done: false };
+ }
+ const toolCall = ensureToolCall(entry, item, sessionId);
+ if (!toolCall) return { done: false };
+ const result = itemResult(item).slice(0, 4000);
+ toolCall.done = true;
+ toolCall.result = result;
+ toolCall.meta = itemMeta(item) || toolCall.meta;
+ sendRuntime(entry, sessionId, {
+ type: 'tool_end',
+ toolUseId: toolCall.id,
+ result,
+ kind: toolCall.kind,
+ meta: toolCall.meta,
+ });
+ return { done: false };
+ }
+
+ case 'thread/tokenUsage/updated':
+ updateUsage(sessionId, entry, params.tokenUsage);
+ return { done: false };
+
+ case 'turn/completed': {
+ if (params.turn?.id) entry.turnId = params.turn.id;
+ entry.turnStatus = params.turn?.status || 'completed';
+ if (params.turn?.status === 'failed') {
+ entry.lastError = params.turn?.error?.message || 'Codex App 任务失败';
+ }
+ return { done: true };
+ }
+
+ case 'error':
+ case 'warning':
+ case 'guardianWarning':
+ case 'configWarning':
+ case 'deprecationNotice': {
+ const message = params.message || params.title || '';
+ if (message) {
+ if (method === 'error') entry.lastError = message;
+ sendRuntime(entry, sessionId, { type: 'system_message', message });
+ }
+ return { done: false };
+ }
+
+ default:
+ return { done: false };
+ }
+ }
+
+ return {
+ processCodexAppNotification,
+ updateUsage,
+ };
+}
+
+module.exports = { createCodexAppRuntime };
diff --git a/lib/codex-app-server-client.js b/lib/codex-app-server-client.js
new file mode 100644
index 0000000..16bb2d6
--- /dev/null
+++ b/lib/codex-app-server-client.js
@@ -0,0 +1,220 @@
+'use strict';
+
+const readline = require('readline');
+const { spawn } = require('child_process');
+
+function createCodexAppServerClient(options = {}) {
+ const command = options.command || 'codex';
+ const args = Array.isArray(options.args) && options.args.length > 0
+ ? options.args.slice()
+ : ['app-server', '--stdio'];
+ const env = options.env || process.env;
+ const cwd = options.cwd || process.cwd();
+ const clientInfo = options.clientInfo || {
+ name: 'ccweb_codexapp',
+ title: 'CC-Web Codex App',
+ version: '0.1.0',
+ };
+ const onNotification = typeof options.onNotification === 'function' ? options.onNotification : () => {};
+ const onServerRequest = typeof options.onServerRequest === 'function' ? options.onServerRequest : null;
+ const onExit = typeof options.onExit === 'function' ? options.onExit : () => {};
+ const onLog = typeof options.onLog === 'function' ? options.onLog : () => {};
+ const postInitialize = typeof options.postInitialize === 'function' ? options.postInitialize : null;
+
+ let proc = null;
+ let rl = null;
+ let nextId = 1;
+ let initPromise = null;
+ let exited = false;
+ const pending = new Map();
+
+ function rejectAllPending(err) {
+ for (const [, pendingRequest] of pending) {
+ clearTimeout(pendingRequest.timer);
+ pendingRequest.reject(err);
+ }
+ pending.clear();
+ }
+
+ function sendRaw(message) {
+ if (!proc || !proc.stdin || proc.stdin.destroyed) {
+ throw new Error('Codex app-server 未启动。');
+ }
+ proc.stdin.write(`${JSON.stringify(message)}\n`);
+ }
+
+ function respondToServerRequest(id, result, error) {
+ try {
+ if (error) {
+ sendRaw({ id, error });
+ } else {
+ sendRaw({ id, result: result || {} });
+ }
+ } catch (err) {
+ onLog('WARN', 'codex_app_server_response_failed', { error: err.message });
+ }
+ }
+
+ function handleServerRequest(message) {
+ const id = message.id;
+ const method = message.method;
+ const params = message.params || {};
+ if (onServerRequest) {
+ Promise.resolve()
+ .then(() => onServerRequest({ method, params, id }))
+ .then((result) => respondToServerRequest(id, result || {}))
+ .catch((err) => respondToServerRequest(id, null, {
+ code: -32603,
+ message: err?.message || 'cc-web 无法处理 Codex app-server 请求。',
+ }));
+ return;
+ }
+ respondToServerRequest(id, null, {
+ code: -32601,
+ message: `cc-web 暂不支持 Codex app-server 请求: ${method}`,
+ });
+ }
+
+ function handleMessage(line) {
+ let message;
+ try {
+ message = JSON.parse(line);
+ } catch {
+ onLog('WARN', 'codex_app_server_invalid_json', { line: String(line || '').slice(0, 200) });
+ return;
+ }
+
+ if (Object.prototype.hasOwnProperty.call(message, 'id')) {
+ const pendingRequest = pending.get(message.id);
+ if (pendingRequest) {
+ pending.delete(message.id);
+ clearTimeout(pendingRequest.timer);
+ if (message.error) {
+ const err = new Error(message.error.message || 'Codex app-server 请求失败。');
+ err.code = message.error.code;
+ err.data = message.error.data;
+ pendingRequest.reject(err);
+ } else {
+ pendingRequest.resolve(message.result || {});
+ }
+ return;
+ }
+ if (message.method) {
+ handleServerRequest(message);
+ return;
+ }
+ }
+
+ if (message.method) {
+ onNotification(message);
+ }
+ }
+
+ function request(method, params = {}, timeoutMs = 300000) {
+ const id = nextId++;
+ const message = { id, method, params };
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ pending.delete(id);
+ reject(new Error(`Codex app-server 请求超时: ${method}`));
+ }, timeoutMs);
+ pending.set(id, { resolve, reject, timer, method });
+ try {
+ sendRaw(message);
+ } catch (err) {
+ clearTimeout(timer);
+ pending.delete(id);
+ reject(err);
+ }
+ });
+ }
+
+ function notification(method, params = {}) {
+ sendRaw({ method, params });
+ }
+
+ function start() {
+ if (initPromise) return initPromise;
+ exited = false;
+ proc = spawn(command, args, {
+ env,
+ cwd,
+ stdio: ['pipe', 'pipe', 'pipe'],
+ windowsHide: true,
+ });
+
+ let stderr = '';
+ proc.stderr.on('data', (chunk) => {
+ stderr += chunk.toString();
+ if (stderr.length > 4000) stderr = stderr.slice(-4000);
+ });
+
+ rl = readline.createInterface({ input: proc.stdout });
+ rl.on('line', handleMessage);
+
+ proc.on('exit', (code, signal) => {
+ exited = true;
+ if (rl) rl.close();
+ const err = new Error(`Codex app-server 已退出: code=${code ?? 'null'} signal=${signal || 'null'}`);
+ err.exitCode = code;
+ err.signal = signal;
+ err.stderr = stderr;
+ rejectAllPending(err);
+ onExit({ code, signal, stderr });
+ });
+
+ proc.on('error', (err) => {
+ rejectAllPending(err);
+ onExit({ code: null, signal: null, stderr: err.message });
+ });
+
+ initPromise = request('initialize', {
+ clientInfo,
+ capabilities: { experimentalApi: true },
+ }, 30000)
+ .then(async (result) => {
+ notification('initialized', {});
+ if (postInitialize) await postInitialize({ request, notification, onLog });
+ return result;
+ })
+ .catch((err) => {
+ stop();
+ throw err;
+ });
+
+ return initPromise;
+ }
+
+ function stop() {
+ initPromise = null;
+ if (rl) {
+ try { rl.close(); } catch {}
+ rl = null;
+ }
+ if (proc && !exited) {
+ try { proc.kill('SIGTERM'); } catch {}
+ setTimeout(() => {
+ try {
+ if (proc && !proc.killed) proc.kill('SIGKILL');
+ } catch {}
+ }, 3000);
+ }
+ proc = null;
+ rejectAllPending(new Error('Codex app-server 已停止。'));
+ }
+
+ function isRunning() {
+ return !!proc && !exited;
+ }
+
+ return {
+ start,
+ stop,
+ request,
+ notification,
+ isRunning,
+ pid: () => proc?.pid || null,
+ };
+}
+
+module.exports = { createCodexAppServerClient };
diff --git a/public/app.js b/public/app.js
index 36f47fc..89ea712 100644
--- a/public/app.js
+++ b/public/app.js
@@ -2,8 +2,10 @@
(function () {
'use strict';
+ const ASSET_VERSION = '20260613-codexapp-tools2';
const WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`;
const RENDER_DEBOUNCE = 100;
+ const COMPOSER_SUGGESTION_DEBOUNCE = 120;
const SLASH_COMMANDS = [
{ cmd: '/clear', desc: '清除当前会话' },
@@ -24,6 +26,7 @@
const AGENT_LABELS = {
claude: 'Claude',
codex: 'Codex',
+ codexapp: 'Codex App',
};
const DEFAULT_AGENT = 'claude';
@@ -99,6 +102,10 @@
let loadedHistorySessionId = null;
let activeSessionLoad = null;
let sidebarSwipe = null;
+ let activeComposerToken = null;
+ let composerSuggestionTimer = null;
+ let composerRequestSeq = 0;
+ let latestComposerRequestId = '';
let pendingAttachments = [];
let uploadingAttachments = [];
let attachmentPreviewModal = null;
@@ -108,6 +115,7 @@
let currentSessionRunning = false;
let fileBrowserState = null;
let directoryPickerState = null;
+ let codexAppUserInputModal = null;
let pendingNewSessionRequest = null;
let skipDeleteConfirm = localStorage.getItem('cc-web-skip-delete-confirm') === '1';
let pendingInitialSessionLoad = false;
@@ -143,6 +151,7 @@
const chatCwd = $('#chat-cwd');
const costDisplay = $('#cost-display');
const attachmentTray = $('#attachment-tray');
+ const pendingNotesTray = $('#pending-notes-tray');
const imageUploadInput = $('#image-upload-input');
const attachBtn = $('#attach-btn');
const messagesDiv = $('#messages');
@@ -172,6 +181,15 @@
return AGENT_LABELS[agent] ? agent : DEFAULT_AGENT;
}
+ function isCodexLikeAgent(agent) {
+ const normalized = normalizeAgent(agent);
+ return normalized === 'codex' || normalized === 'codexapp';
+ }
+
+ function isCodexAppAgent(agent) {
+ return normalizeAgent(agent) === 'codexapp';
+ }
+
function getDraftNoteKey(agent = currentAgent) {
return `draft:${normalizeAgent(agent)}`;
}
@@ -218,9 +236,10 @@
}
if (inputWrapper) inputWrapper.classList.toggle('note-mode-active', active);
if (sendBtn) {
+ const allowRuntimeInsert = isGenerating && isCodexAppAgent(currentAgent) && !active;
sendBtn.classList.toggle('note-send', active);
- sendBtn.title = active ? '记录笔记' : '发送';
- sendBtn.hidden = isGenerating ? !active : false;
+ sendBtn.title = active ? '记录笔记' : (allowRuntimeInsert ? '插入' : '发送');
+ sendBtn.hidden = isGenerating ? (!active && !allowRuntimeInsert) : false;
}
if (msgInput) {
msgInput.placeholder = active ? '记录笔记,稍后从气泡发送…' : defaultMsgInputPlaceholder;
@@ -240,15 +259,15 @@
function createPendingNoteElement(note) {
const div = document.createElement('div');
- div.className = 'msg note';
+ div.className = 'pending-note';
div.dataset.noteId = note.id;
const avatar = document.createElement('div');
- avatar.className = 'msg-avatar note-avatar';
+ avatar.className = 'note-avatar';
avatar.textContent = 'N';
const bubble = document.createElement('div');
- bubble.className = 'msg-bubble note-bubble';
+ bubble.className = 'note-bubble';
const meta = document.createElement('div');
meta.className = 'note-meta';
@@ -274,24 +293,23 @@
}
function renderPendingNotes(options = {}) {
- messagesDiv.querySelectorAll('.msg.note').forEach((node) => node.remove());
+ if (!pendingNotesTray) return;
+ pendingNotesTray.innerHTML = '';
const notes = getCurrentNotes(false);
if (!notes || notes.length === 0) {
- updateScrollbar();
+ pendingNotesTray.hidden = true;
+ if (options.updateScrollbar !== false) updateScrollbar();
return;
}
- const welcome = messagesDiv.querySelector('.welcome-msg');
- if (welcome) welcome.remove();
-
const frag = document.createDocumentFragment();
notes.forEach((note) => frag.appendChild(createPendingNoteElement(note)));
- messagesDiv.appendChild(frag);
- if (options.scroll !== false) {
- scrollToBottom();
- } else {
- updateScrollbar();
+ pendingNotesTray.appendChild(frag);
+ pendingNotesTray.hidden = false;
+ if (options.scrollIntoView !== false && options.scroll !== false) {
+ pendingNotesTray.scrollTop = pendingNotesTray.scrollHeight;
}
+ if (options.updateScrollbar !== false) updateScrollbar();
}
function findPendingNote(noteId) {
@@ -336,7 +354,7 @@
function beginEditPendingNote(noteId) {
const found = findPendingNote(noteId);
if (!found) return;
- const noteEl = messagesDiv.querySelector(`.msg.note[data-note-id="${noteId}"]`);
+ const noteEl = pendingNotesTray?.querySelector(`.pending-note[data-note-id="${noteId}"]`);
const bubble = noteEl?.querySelector('.note-bubble');
if (!bubble) return;
@@ -650,6 +668,15 @@
return sessions.find((s) => s.id === sessionId) || null;
}
+ function compareSessionUpdatedDesc(a, b) {
+ return new Date(b?.updated || 0) - new Date(a?.updated || 0);
+ }
+
+ function compareSessionPinnedDesc(a, b) {
+ const pinnedDiff = new Date(b?.pinnedAt || 0) - new Date(a?.pinnedAt || 0);
+ return pinnedDiff || compareSessionUpdatedDesc(a, b);
+ }
+
function shortSessionId(sessionId) {
const value = String(sessionId || '');
return value ? value.slice(0, 8) : '';
@@ -730,6 +757,7 @@
mode: payload.mode || 'yolo',
model: payload.model || '',
agent: normalizeAgent(payload.agent),
+ pinnedAt: payload.pinnedAt || null,
hasUnread: !!payload.hasUnread,
cwd: payload.cwd || null,
totalCost: typeof payload.totalCost === 'number' ? payload.totalCost : 0,
@@ -832,6 +860,7 @@
snapshot.agent = normalizeAgent(meta.agent || snapshot.agent);
snapshot.hasUnread = !!meta.hasUnread;
snapshot.updated = meta.updated || snapshot.updated;
+ snapshot.pinnedAt = meta.pinnedAt || null;
snapshot.isRunning = !!meta.isRunning;
}
return snapshot;
@@ -898,6 +927,154 @@
return data || {};
}
+ function closeCodexAppUserInputModal(sendCancel = false) {
+ if (!codexAppUserInputModal) return;
+ const { overlay, escapeHandler, requestId, sessionId } = codexAppUserInputModal;
+ if (escapeHandler) document.removeEventListener('keydown', escapeHandler);
+ if (overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay);
+ codexAppUserInputModal = null;
+ if (sendCancel && requestId) {
+ send({
+ type: 'codex_app_user_input_response',
+ sessionId,
+ requestId,
+ answers: {},
+ });
+ }
+ }
+
+ function cssEscape(value) {
+ if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(String(value || ''));
+ return String(value || '').replace(/["\\]/g, '\\$&');
+ }
+
+ function collectCodexAppUserInputAnswers(panel, questions) {
+ const answers = {};
+ for (const question of questions) {
+ const id = String(question?.id || '').trim();
+ if (!id) continue;
+ const escapedId = cssEscape(id);
+ const checked = panel.querySelector(`input[name="codex-ui-${escapedId}"]:checked`);
+ const values = [];
+ if (checked) {
+ if (checked.value === '__other__') {
+ const input = panel.querySelector(`[data-codex-ui-other="${escapedId}"]`);
+ const text = String(input?.value || '').trim();
+ if (text) values.push(text);
+ } else {
+ values.push(checked.value);
+ }
+ } else {
+ const input = panel.querySelector(`[data-codex-ui-other="${escapedId}"]`);
+ const text = String(input?.value || '').trim();
+ if (text) values.push(text);
+ }
+ answers[id] = { answers: values };
+ }
+ return answers;
+ }
+
+ function renderCodexAppQuestion(question, index) {
+ const id = String(question?.id || `q${index}`);
+ const options = Array.isArray(question?.options) ? question.options : [];
+ const hasOther = !!question?.isOther || options.length === 0;
+ const inputType = question?.isSecret ? 'password' : 'text';
+ const optionHtml = options.map((option, optionIndex) => {
+ const value = String(option?.label || `选项 ${optionIndex + 1}`);
+ return `
+
+ `;
+ }).join('');
+ const otherHtml = hasOther ? `
+
+ ` : '';
+
+ return `
+