feat: v1.2.5 — UI improvements and session management fixes

- 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
This commit is contained in:
cc-dan
2026-03-10 15:19:47 +00:00
parent 10603eb31b
commit b64d5ec029
4 changed files with 271 additions and 81 deletions

View File

@@ -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 = '<div class="typing-indicator"><span></span><span></span><span></span></div>';
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) : '<div class="typing-indicator"><span></span><span></span><span></span></div>';
@@ -401,13 +415,9 @@
return div;
}
function renderMessages(messages) {
messagesDiv.innerHTML = '';
if (messages.length === 0) {
messagesDiv.innerHTML = '<div class="welcome-msg"><div class="welcome-icon">✿</div><h3>欢迎使用 CC-Web</h3><p>开始与 Claude Code 对话</p></div>';
return;
}
for (const m of messages) {
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');
@@ -416,20 +426,58 @@
details.className = 'tool-call';
details.dataset.toolName = tc.name || '';
if (tc.name === 'AskUserQuestion') details.open = true;
const summary = document.createElement('summary');
summary.innerHTML = `<span class="tool-call-icon done"></span> ${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;
}
bubble.insertBefore(details, bubble.firstChild);
function renderMessages(messages) {
renderEpoch++;
const epoch = renderEpoch;
messagesDiv.innerHTML = '';
if (messages.length === 0) {
messagesDiv.innerHTML = '<div class="welcome-msg"><div class="welcome-icon">✿</div><h3>欢迎使用 CC-Web</h3><p>开始与 Claude Code 对话</p></div>';
return;
}
// 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]);
}
messagesDiv.appendChild(el);
}
// 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 = `
<div style="font-size:0.9em;color:var(--text-primary);margin-bottom:20px;line-height:1.7">删除本会话将同步删去本地 Claude 中的会话历史,不可恢复。确认删除?</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-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>
`;
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;
}

View File

@@ -56,6 +56,7 @@
<span id="cost-display" class="cost-display"></span>
</header>
<div class="messages-wrap">
<div id="messages" class="messages">
<div class="welcome-msg">
<div class="welcome-icon"></div>
@@ -63,6 +64,10 @@
<p>开始与 Claude Code 对话</p>
</div>
</div>
<div class="custom-scrollbar" id="custom-scrollbar">
<div class="custom-scrollbar-thumb" id="custom-scrollbar-thumb"></div>
</div>
</div>
<!-- Slash command menu -->
<div id="cmd-menu" class="cmd-menu" hidden></div>

View File

@@ -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 {

View File

@@ -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);
}
}