From b64d5ec02976f73edf4a1402f89e8cbde42f8335 Mon Sep 17 00:00:00 2001 From: cc-dan Date: Tue, 10 Mar 2026 15:19:47 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20v1.2.5=20=E2=80=94=20UI=20improvements?= =?UTF-8?q?=20and=20session=20management=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix session delete to scan all claude project dirs (not just first match) - Batch async rendering for message history with stale render guard - Add custom draggable scrollbar for chat area - Fix AskUserQuestion card rendered at bottom instead of top - Fix bubble split (msg-text + msg-tools) to prevent tool UI overwrite - Add delete confirmation dialog with warm theme styling - Support multiline display in user messages - Apply model config to settings.json immediately on save --- public/app.js | 198 ++++++++++++++++++++++++++++++++++++++++------ public/index.html | 15 ++-- public/style.css | 74 ++++++++++------- server.js | 65 +++++++++------ 4 files changed, 271 insertions(+), 81 deletions(-) diff --git a/public/app.js b/public/app.js index a338b5c..fb94a2c 100644 --- a/public/app.js +++ b/public/app.js @@ -47,6 +47,7 @@ let currentMode = localStorage.getItem('cc-web-mode') || 'yolo'; let currentModel = 'opus'; let loginPasswordValue = ''; // store login password for force-change flow + let skipDeleteConfirm = localStorage.getItem('cc-web-skip-delete-confirm') === '1'; // --- DOM --- const $ = (sel) => document.querySelector(sel); @@ -324,6 +325,16 @@ const msgEl = createMsgElement('assistant', ''); msgEl.id = 'streaming-msg'; + // 流式消息 bubble 拆为 .msg-text 和 .msg-tools 两个子容器 + const bubble = msgEl.querySelector('.msg-bubble'); + bubble.innerHTML = ''; + const textDiv = document.createElement('div'); + textDiv.className = 'msg-text'; + textDiv.innerHTML = '
'; + const toolsDiv = document.createElement('div'); + toolsDiv.className = 'msg-tools'; + bubble.appendChild(textDiv); + bubble.appendChild(toolsDiv); messagesDiv.appendChild(msgEl); scrollToBottom(); } @@ -361,7 +372,9 @@ if (!streamEl) return; const bubble = streamEl.querySelector('.msg-bubble'); if (!bubble) return; - bubble.innerHTML = renderMarkdown(pendingText); + let textDiv = bubble.querySelector('.msg-text'); + if (!textDiv) { textDiv = bubble; } + textDiv.innerHTML = renderMarkdown(pendingText); scrollToBottom(); } @@ -391,6 +404,7 @@ bubble.className = 'msg-bubble'; if (role === 'user') { + bubble.style.whiteSpace = 'pre-wrap'; bubble.textContent = content; } else { bubble.innerHTML = content ? renderMarkdown(content) : '
'; @@ -401,35 +415,69 @@ return div; } + let renderEpoch = 0; + + function buildMsgElement(m) { + const el = createMsgElement(m.role, m.content); + if (m.role === 'assistant' && m.toolCalls && m.toolCalls.length > 0) { + const bubble = el.querySelector('.msg-bubble'); + for (const tc of m.toolCalls) { + const details = document.createElement('details'); + details.className = 'tool-call'; + details.dataset.toolName = tc.name || ''; + if (tc.name === 'AskUserQuestion') details.open = true; + const summary = document.createElement('summary'); + summary.innerHTML = ` ${escapeHtml(tc.name)}`; + details.appendChild(summary); + const displayInput = tc.name === 'AskUserQuestion' ? tc.input : (tc.result || tc.input); + details.appendChild(buildToolContentElement(tc.name, displayInput)); + bubble.appendChild(details); + } + } + return el; + } + function renderMessages(messages) { + renderEpoch++; + const epoch = renderEpoch; messagesDiv.innerHTML = ''; if (messages.length === 0) { messagesDiv.innerHTML = '

欢迎使用 CC-Web

开始与 Claude Code 对话

'; return; } - for (const m of messages) { - const el = createMsgElement(m.role, m.content); - if (m.role === 'assistant' && m.toolCalls && m.toolCalls.length > 0) { - const bubble = el.querySelector('.msg-bubble'); - for (const tc of m.toolCalls) { - const details = document.createElement('details'); - details.className = 'tool-call'; - details.dataset.toolName = tc.name || ''; - if (tc.name === 'AskUserQuestion') details.open = true; - - const summary = document.createElement('summary'); - summary.innerHTML = ` ${escapeHtml(tc.name)}`; - details.appendChild(summary); - - const displayInput = tc.name === 'AskUserQuestion' ? tc.input : (tc.result || tc.input); - details.appendChild(buildToolContentElement(tc.name, displayInput)); - - bubble.insertBefore(details, bubble.firstChild); - } - } - messagesDiv.appendChild(el); + // Batch render: last 10 first, then next 20, then the rest + const batches = []; + const len = messages.length; + if (len <= 10) { + batches.push([0, len]); + } else if (len <= 30) { + batches.push([len - 10, len]); + batches.push([0, len - 10]); + } else { + batches.push([len - 10, len]); + batches.push([len - 30, len - 10]); + batches.push([0, len - 30]); } + + // Render first batch immediately + const frag0 = document.createDocumentFragment(); + for (let i = batches[0][0]; i < batches[0][1]; i++) frag0.appendChild(buildMsgElement(messages[i])); + messagesDiv.appendChild(frag0); scrollToBottom(); + + // Render remaining batches asynchronously, prepending each + let delay = 0; + for (let b = 1; b < batches.length; b++) { + const [start, end] = batches[b]; + delay += 16; + setTimeout(() => { + if (renderEpoch !== epoch) return; // session switched, abort stale render + const frag = document.createDocumentFragment(); + for (let i = start; i < end; i++) frag.appendChild(buildMsgElement(messages[i])); + messagesDiv.insertBefore(frag, messagesDiv.firstChild); + updateScrollbar(); + }, delay); + } } function normalizeAskUserInput(input) { @@ -530,6 +578,8 @@ if (!streamEl) return; const bubble = streamEl.querySelector('.msg-bubble'); if (!bubble) return; + let toolsDiv = bubble.querySelector('.msg-tools'); + if (!toolsDiv) { toolsDiv = bubble; } const details = document.createElement('details'); details.className = 'tool-call'; @@ -542,7 +592,7 @@ details.appendChild(summary); details.appendChild(buildToolContentElement(name, input)); - bubble.appendChild(details); + toolsDiv.appendChild(details); scrollToBottom(); } @@ -560,6 +610,36 @@ } } + function showDeleteConfirm(onConfirm) { + const overlay = document.createElement('div'); + overlay.className = 'settings-overlay'; + overlay.style.zIndex = '10002'; + + const box = document.createElement('div'); + box.className = 'settings-panel'; + box.innerHTML = ` +
删除本会话将同步删去本地 Claude 中的会话历史,不可恢复。确认删除?
+
+ + + +
+ `; + overlay.appendChild(box); + document.body.appendChild(overlay); + + const close = () => document.body.removeChild(overlay); + box.querySelector('#del-confirm-ok').addEventListener('click', () => { close(); onConfirm(); }); + box.querySelector('#del-confirm-skip').addEventListener('click', () => { + skipDeleteConfirm = true; + localStorage.setItem('cc-web-skip-delete-confirm', '1'); + close(); + onConfirm(); + }); + box.querySelector('#del-confirm-cancel').addEventListener('click', close); + overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); + } + function appendSystemMessage(message) { const welcome = messagesDiv.querySelector('.welcome-msg'); if (welcome) welcome.remove(); @@ -578,10 +658,73 @@ function scrollToBottom() { requestAnimationFrame(() => { messagesDiv.scrollTop = messagesDiv.scrollHeight; + updateScrollbar(); }); } - // --- Session List --- + // --- Custom Scrollbar --- + const scrollbarEl = document.getElementById('custom-scrollbar'); + const thumbEl = document.getElementById('custom-scrollbar-thumb'); + + function updateScrollbar() { + if (!scrollbarEl || !thumbEl) return; + const { scrollTop, scrollHeight, clientHeight } = messagesDiv; + if (scrollHeight <= clientHeight) { + thumbEl.style.display = 'none'; + return; + } + thumbEl.style.display = ''; + const trackH = scrollbarEl.clientHeight; + const thumbH = Math.max(30, trackH * clientHeight / scrollHeight); + const thumbTop = (scrollTop / (scrollHeight - clientHeight)) * (trackH - thumbH); + thumbEl.style.height = thumbH + 'px'; + thumbEl.style.top = thumbTop + 'px'; + } + + messagesDiv.addEventListener('scroll', () => updateScrollbar(), { passive: true }); + new ResizeObserver(updateScrollbar).observe(messagesDiv); + + // Drag logic + let dragStartY = 0, dragStartScrollTop = 0, isDragging = false; + + function onDragStart(e) { + isDragging = true; + dragStartY = e.type === 'touchstart' ? e.touches[0].clientY : e.clientY; + dragStartScrollTop = messagesDiv.scrollTop; + thumbEl.classList.add('dragging'); + scrollbarEl.classList.add('active'); + e.preventDefault(); + } + + function onDragMove(e) { + if (!isDragging) return; + const clientY = e.type === 'touchmove' ? e.touches[0].clientY : e.clientY; + const dy = clientY - dragStartY; + const { scrollHeight, clientHeight } = messagesDiv; + const trackH = scrollbarEl.clientHeight; + const thumbH = Math.max(30, trackH * clientHeight / scrollHeight); + const ratio = (scrollHeight - clientHeight) / (trackH - thumbH); + messagesDiv.scrollTop = dragStartScrollTop + dy * ratio; + e.preventDefault(); + } + + function onDragEnd() { + if (!isDragging) return; + isDragging = false; + thumbEl.classList.remove('dragging'); + scrollbarEl.classList.remove('active'); + } + + thumbEl.addEventListener('mousedown', onDragStart); + thumbEl.addEventListener('touchstart', onDragStart, { passive: false }); + document.addEventListener('mousemove', onDragMove); + document.addEventListener('touchmove', onDragMove, { passive: false }); + document.addEventListener('mouseup', onDragEnd); + document.addEventListener('touchend', onDragEnd); + + updateScrollbar(); + + function renderSessionList() { sessionList.innerHTML = ''; for (const s of sessions) { @@ -602,7 +745,7 @@ const target = e.target; if (target.classList.contains('delete')) { e.stopPropagation(); - if (confirm('删除此会话?')) { + const doDelete = () => { send({ type: 'delete_session', sessionId: s.id }); if (s.id === currentSessionId) { currentSessionId = null; @@ -610,6 +753,11 @@ chatTitle.textContent = '新会话'; costDisplay.textContent = ''; } + }; + if (skipDeleteConfirm) { + doDelete(); + } else { + showDeleteConfirm(doDelete); } return; } diff --git a/public/index.html b/public/index.html index fa5bf8e..46156d5 100644 --- a/public/index.html +++ b/public/index.html @@ -56,11 +56,16 @@ -
-
-
-

欢迎使用 CC-Web

-

开始与 Claude Code 对话

+
+
+
+
+

欢迎使用 CC-Web

+

开始与 Claude Code 对话

+
+
+
+
diff --git a/public/style.css b/public/style.css index 2361745..4fab7e7 100644 --- a/public/style.css +++ b/public/style.css @@ -334,17 +334,60 @@ body { } /* === Messages === */ -.messages { +.messages-wrap { flex: 1; - overflow-y: auto; + position: relative; + overflow: hidden; + min-height: 0; +} +.messages { + height: 100%; + overflow-y: scroll; overflow-x: hidden; padding: 16px; + padding-right: 20px; display: flex; flex-direction: column; gap: 12px; -webkit-overflow-scrolling: touch; overscroll-behavior: contain; + scrollbar-width: none; } +.messages::-webkit-scrollbar { display: none; } +/* Custom scrollbar */ +.custom-scrollbar { + position: absolute; + right: 2px; + top: 0; + bottom: 0; + width: 6px; + pointer-events: none; + opacity: 0; + transition: opacity 0.2s; +} +.messages-wrap:hover .custom-scrollbar, +.custom-scrollbar.active { + opacity: 1; +} +.custom-scrollbar-thumb { + position: absolute; + right: 0; + width: 6px; + min-height: 30px; + border-radius: 4px; + background: var(--scrollbar-thumb); + cursor: grab; + transition: width 0.15s, right 0.15s, background 0.15s; + pointer-events: all; +} +.custom-scrollbar-thumb:hover, +.custom-scrollbar-thumb.dragging { + width: 12px; + right: -3px; + background: #b0a090; + cursor: grab; +} +.custom-scrollbar-thumb.dragging { cursor: grabbing; } .welcome-msg { text-align: center; margin: auto; @@ -519,33 +562,6 @@ body { line-height: 1.5; white-space: pre; } -/* HTML preview */ -.code-html-preview { - border-top: 1px solid var(--border-color); - background: var(--bg-secondary); -} -.code-html-preview summary { - padding: 6px 12px; - cursor: pointer; - font-size: 12px; - color: var(--text-secondary); - user-select: none; - list-style: none; -} -.code-html-preview summary::-webkit-details-marker { display: none; } -.code-html-preview summary::before { - content: '▸'; - font-size: 11px; - transition: transform 0.2s; - margin-right: 6px; -} -.code-html-preview[open] summary::before { transform: rotate(90deg); } -.code-html-preview iframe { - width: 100%; - min-height: 180px; - border: 0; - background: #fff; -} /* Tool calls */ .tool-call { diff --git a/server.js b/server.js index 2dd72b9..c4477fe 100644 --- a/server.js +++ b/server.js @@ -309,6 +309,27 @@ function loadClaudeJsonModelMap() { } // Apply model config to runtime MODEL_MAP only (env vars are injected per-spawn, not here) +const CLAUDE_SETTINGS_PATH = path.join(process.env.HOME || process.env.USERPROFILE || '', '.claude', 'settings.json'); +const SETTINGS_API_KEYS = ['ANTHROPIC_AUTH_TOKEN','ANTHROPIC_API_KEY','ANTHROPIC_BASE_URL','ANTHROPIC_MODEL', + 'ANTHROPIC_DEFAULT_OPUS_MODEL','ANTHROPIC_DEFAULT_SONNET_MODEL','ANTHROPIC_DEFAULT_HAIKU_MODEL']; + +function applyCustomTemplateToSettings(tpl) { + let settings = {}; + try { settings = JSON.parse(fs.readFileSync(CLAUDE_SETTINGS_PATH, 'utf8')); } catch {} + const cleanedEnv = {}; + for (const [k, v] of Object.entries(settings.env || {})) { + if (!SETTINGS_API_KEYS.includes(k)) cleanedEnv[k] = v; + } + if (tpl.apiKey) { cleanedEnv.ANTHROPIC_AUTH_TOKEN = tpl.apiKey; cleanedEnv.ANTHROPIC_API_KEY = tpl.apiKey; } + if (tpl.apiBase) cleanedEnv.ANTHROPIC_BASE_URL = tpl.apiBase; + if (tpl.defaultModel) cleanedEnv.ANTHROPIC_MODEL = tpl.defaultModel; + if (tpl.opusModel) cleanedEnv.ANTHROPIC_DEFAULT_OPUS_MODEL = tpl.opusModel; + if (tpl.sonnetModel) cleanedEnv.ANTHROPIC_DEFAULT_SONNET_MODEL = tpl.sonnetModel; + if (tpl.haikuModel) cleanedEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL = tpl.haikuModel; + settings.env = cleanedEnv; + try { fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2)); } catch {} +} + function applyModelConfig() { const config = loadModelConfig(); if (config.mode === 'custom' && config.activeTemplate) { @@ -934,6 +955,11 @@ function handleSaveModelConfig(ws, newConfig) { // Re-apply at runtime MODEL_MAP = { opus: 'claude-opus-4-6', sonnet: 'claude-sonnet-4-6', haiku: 'claude-haiku-4-5-20251001' }; applyModelConfig(); + // custom mode: write to ~/.claude/settings.json immediately on save + if (merged.mode === 'custom' && merged.activeTemplate) { + const tpl = merged.templates.find(t => t.name === merged.activeTemplate); + if (tpl) applyCustomTemplateToSettings(tpl); + } plog('INFO', 'model_config_saved', { mode: merged.mode, activeTemplate: merged.activeTemplate }); wsSend(ws, { type: 'model_config', config: getModelConfigMasked() }); wsSend(ws, { type: 'system_message', message: '模型配置已保存' }); @@ -1138,7 +1164,23 @@ function handleDeleteSession(ws, sessionId) { cleanRunDir(sessionId); try { const p = sessionPath(sessionId); + // Read claudeSessionId before deleting the file + let claudeSessionId = null; + try { + const session = loadSession(sessionId); + claudeSessionId = session?.claudeSessionId || null; + } catch {} if (fs.existsSync(p)) fs.unlinkSync(p); + // Sync-delete the corresponding Claude native session .jsonl + if (claudeSessionId) { + const projectsDir = path.join(process.env.HOME || process.env.USERPROFILE || '', '.claude', 'projects'); + try { + for (const proj of fs.readdirSync(projectsDir)) { + const target = path.join(projectsDir, proj, `${claudeSessionId}.jsonl`); + if (fs.existsSync(target)) fs.unlinkSync(target); + } + } catch {} + } sendSessionList(ws); } catch { wsSend(ws, { type: 'error', message: 'Failed to delete session' }); @@ -1295,28 +1337,7 @@ function handleMessage(ws, msg, options = {}) { const modelCfg = loadModelConfig(); if (modelCfg.mode === 'custom' && modelCfg.activeTemplate) { const tpl = (modelCfg.templates || []).find(t => t.name === modelCfg.activeTemplate); - if (tpl) { - const CLAUDE_SETTINGS_PATH = path.join(process.env.HOME || process.env.USERPROFILE || '', '.claude', 'settings.json'); - let settings = {}; - try { settings = JSON.parse(fs.readFileSync(CLAUDE_SETTINGS_PATH, 'utf8')); } catch {} - const API_KEYS = ['ANTHROPIC_AUTH_TOKEN','ANTHROPIC_API_KEY','ANTHROPIC_BASE_URL','ANTHROPIC_MODEL', - 'ANTHROPIC_DEFAULT_OPUS_MODEL','ANTHROPIC_DEFAULT_SONNET_MODEL','ANTHROPIC_DEFAULT_HAIKU_MODEL']; - const existingEnv = settings.env || {}; - // Remove old API-related keys, keep non-API keys - const cleanedEnv = {}; - for (const [k, v] of Object.entries(existingEnv)) { - if (!API_KEYS.includes(k)) cleanedEnv[k] = v; - } - // Inject template values - if (tpl.apiKey) { cleanedEnv.ANTHROPIC_AUTH_TOKEN = tpl.apiKey; cleanedEnv.ANTHROPIC_API_KEY = tpl.apiKey; } - if (tpl.apiBase) cleanedEnv.ANTHROPIC_BASE_URL = tpl.apiBase; - if (tpl.defaultModel) cleanedEnv.ANTHROPIC_MODEL = tpl.defaultModel; - if (tpl.opusModel) cleanedEnv.ANTHROPIC_DEFAULT_OPUS_MODEL = tpl.opusModel; - if (tpl.sonnetModel) cleanedEnv.ANTHROPIC_DEFAULT_SONNET_MODEL = tpl.sonnetModel; - if (tpl.haikuModel) cleanedEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL = tpl.haikuModel; - settings.env = cleanedEnv; - try { fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2)); } catch {} - } + if (tpl) applyCustomTemplateToSettings(tpl); } }