From 96dbb8191473338a0a82e10613578e3ae7e7bba6 Mon Sep 17 00:00:00 2001 From: cc-dan Date: Wed, 11 Mar 2026 00:40:37 +0000 Subject: [PATCH] release v1.2.6: AskUserQuestion preview panel, 401 atomic write fix, mobile scrollbar --- CHANGELOG.md | 7 +++- public/app.js | 93 +++++++++++++++++++++++++++++++++++++++++---- public/style.css | 98 +++++++++++++++++++++++++++++++++++++++++++++--- server.js | 12 +++++- 4 files changed, 194 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a231470..800d290 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ # 更新记录 -- **v1.2.5** +- **v1.2.6** + - 新增 AskUserQuestion 选项预览区:左侧选项列表,右侧实时显示选项说明;桌面端 hover 切换,移动端 tap 选中后点确认按钮发送。 + - 修复 `~/.claude/settings.json` 写入竞争问题:改为原子写入(先写临时文件再 rename),避免 Claude 子进程读到写了一半的文件导致随机 401 认证失败。 + - 修复 `ANTHROPIC_REASONING_MODEL` 被误删问题:补充到 settings.json 白名单,保留该字段不被覆盖。 + - 移动端自定义滚动条优化:加宽滑块热区(18px),滚动时自动显示滑块,停止后 1.2 秒淡出,修复 hover 粘滞导致半透明滑块残留问题。 + - 修复删除会话时同步删除 `~/.claude/projects/` 下对应的原生会话历史,遍历所有项目目录确保完整清除。 - 新增删除确认弹窗,支持「确认且不再提示」选项,风格与主界面一致。 - 用户消息支持多行换行显示。 diff --git a/public/app.js b/public/app.js index fb94a2c..a7d5804 100644 --- a/public/app.js +++ b/public/app.js @@ -528,29 +528,98 @@ card.appendChild(body); if (Array.isArray(q.options) && q.options.length > 0) { + const hasDesc = q.options.some(o => o.description); + + // 左右分栏容器 + const layout = document.createElement('div'); + layout.className = 'ask-options-layout' + (hasDesc ? ' has-preview' : ''); + const opts = document.createElement('div'); opts.className = 'ask-question-options'; + + // 右侧预览区(仅在有 description 时创建) + const preview = hasDesc ? document.createElement('div') : null; + if (preview) { + preview.className = 'ask-option-preview'; + // 默认显示第一项 + preview.textContent = q.options[0].description || ''; + } + + // 当前选中项(移动端 tap-to-preview 状态) + let selectedOpt = null; + let selectedBtn = null; + q.options.forEach((opt, i) => { const item = document.createElement('button'); item.type = 'button'; item.className = 'ask-option-item'; - item.addEventListener('click', () => appendAskOptionToInput(q, opt)); const title = document.createElement('div'); title.className = 'ask-option-label'; title.textContent = `${i + 1}. ${opt.label || ''}`; item.appendChild(title); - if (opt.description) { - const desc = document.createElement('div'); - desc.className = 'ask-option-desc'; - desc.textContent = opt.description; - item.appendChild(desc); + // 桌面:hover 切换预览 + if (preview) { + item.addEventListener('mouseenter', () => { + preview.textContent = opt.description || ''; + }); } + item.addEventListener('click', (e) => { + const isTouch = item.dataset.touchActivated === '1'; + item.dataset.touchActivated = ''; + + if (isTouch) { + // 移动端:第一次 tap = 选中预览,不发送 + if (selectedBtn !== item) { + if (selectedBtn) selectedBtn.classList.remove('ask-option-selected'); + selectedBtn = item; + selectedOpt = opt; + item.classList.add('ask-option-selected'); + if (preview) preview.textContent = opt.description || ''; + return; + } + // 第二次 tap 同一项 = 发送 + } + + // 桌面直接发送 + appendAskOptionToInput(q, opt); + }); + + item.addEventListener('touchstart', () => { + item.dataset.touchActivated = '1'; + }, { passive: true }); + opts.appendChild(item); }); - card.appendChild(opts); + + layout.appendChild(opts); + if (preview) { + layout.appendChild(preview); + // 预览区最小高度 = 左侧选项列表总高度(渲染后同步) + requestAnimationFrame(() => { + preview.style.minHeight = opts.offsetHeight + 'px'; + }); + } + + // 移动端确认按钮 + if (hasDesc) { + const confirmBtn = document.createElement('button'); + confirmBtn.type = 'button'; + confirmBtn.className = 'ask-confirm-btn'; + confirmBtn.textContent = '确认选择'; + confirmBtn.addEventListener('click', () => { + if (selectedOpt) { + appendAskOptionToInput(q, selectedOpt); + } else if (q.options.length > 0) { + appendAskOptionToInput(q, q.options[0]); + } + }); + layout.appendChild(confirmBtn); + } + + card.appendChild(layout); } wrapper.appendChild(card); @@ -681,7 +750,15 @@ thumbEl.style.top = thumbTop + 'px'; } - messagesDiv.addEventListener('scroll', () => updateScrollbar(), { passive: true }); + messagesDiv.addEventListener('scroll', () => { + updateScrollbar(); + // 移动端:滚动时短暂显示滑块,停止后淡出 + scrollbarEl.classList.add('scrolling'); + clearTimeout(scrollbarEl._hideTimer); + scrollbarEl._hideTimer = setTimeout(() => { + if (!isDragging) scrollbarEl.classList.remove('scrolling'); + }, 1200); + }, { passive: true }); new ResizeObserver(updateScrollbar).observe(messagesDiv); // Drag logic diff --git a/public/style.css b/public/style.css index 4fab7e7..c72beab 100644 --- a/public/style.css +++ b/public/style.css @@ -49,11 +49,9 @@ body { -webkit-tap-highlight-color: transparent; } -/* Scrollbar */ -::-webkit-scrollbar { width: 5px; } -::-webkit-scrollbar-track { background: var(--scrollbar-track); } -::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 3px; } -::-webkit-scrollbar-thumb:hover { background: #b0a090; } +/* 全局隐藏原生滚动条,聊天区由 custom-scrollbar 接管 */ +::-webkit-scrollbar { display: none; } +* { scrollbar-width: none; } /* === Login === */ .login-overlay { @@ -369,6 +367,16 @@ body { .custom-scrollbar.active { opacity: 1; } +/* 移动端触摸后 hover 会粘滞 — 完全禁用 hover 触发,只靠 .active(拖动时)显示 */ +@media (pointer: coarse) { + .messages-wrap:hover .custom-scrollbar { + opacity: 0; + } + /* 移动端滚动时用独立的类显示滑块,不走 .active */ + .custom-scrollbar.scrolling { + opacity: 1; + } +} .custom-scrollbar-thumb { position: absolute; right: 0; @@ -388,6 +396,28 @@ body { cursor: grab; } .custom-scrollbar-thumb.dragging { cursor: grabbing; } +/* 移动端触摸设备:加宽滑块与轨道,便于手指操作;默认隐藏,拖动时显示 */ +@media (pointer: coarse) { + .custom-scrollbar { + width: 18px; + right: 0; + opacity: 0; + } + .custom-scrollbar.active { + opacity: 1; + } + .custom-scrollbar-thumb { + width: 8px; + right: 5px; + min-height: 40px; + border-radius: 4px; + } + .custom-scrollbar-thumb:hover, + .custom-scrollbar-thumb.dragging { + width: 14px; + right: 2px; + } +} .welcome-msg { text-align: center; margin: auto; @@ -640,10 +670,64 @@ body { color: var(--text-primary); margin-bottom: 8px; } +/* 左右分栏布局 */ +.ask-options-layout { + display: flex; + flex-direction: column; + gap: 6px; +} +.ask-options-layout.has-preview { + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: auto auto; + gap: 8px; + align-items: start; +} .ask-question-options { display: flex; flex-direction: column; gap: 6px; + grid-row: 1; + grid-column: 1; +} +/* 右侧预览区 */ +.ask-option-preview { + grid-row: 1; + grid-column: 2; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 10px 12px; + font-size: 12px; + line-height: 1.6; + color: var(--text-secondary); + min-height: 60px; + transition: background 0.15s; + white-space: pre-wrap; + word-break: break-word; +} +/* 确认按钮 — 仅移动端 */ +.ask-confirm-btn { + display: none; + grid-row: 2; + grid-column: 1 / -1; + width: 100%; + padding: 9px 0; + border: 1.5px solid var(--accent); + border-radius: 8px; + background: transparent; + color: var(--accent); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background 0.12s, color 0.12s; +} +.ask-confirm-btn:active { + background: var(--accent); + color: #fff; +} +@media (pointer: coarse) { + .ask-confirm-btn { display: block; } } .ask-option-item { border: 1px solid var(--border-color); @@ -659,6 +743,10 @@ body { background: var(--accent-light); border-color: var(--accent); } +.ask-option-item.ask-option-selected { + background: var(--accent-light); + border-color: var(--accent); +} .ask-option-label { font-size: 12px; font-weight: 600; diff --git a/server.js b/server.js index c4477fe..312226f 100644 --- a/server.js +++ b/server.js @@ -311,7 +311,8 @@ 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']; + 'ANTHROPIC_DEFAULT_OPUS_MODEL','ANTHROPIC_DEFAULT_SONNET_MODEL','ANTHROPIC_DEFAULT_HAIKU_MODEL', + 'ANTHROPIC_REASONING_MODEL']; function applyCustomTemplateToSettings(tpl) { let settings = {}; @@ -327,7 +328,14 @@ function applyCustomTemplateToSettings(tpl) { 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 {} + // 原子写入:先写临时文件再 rename,避免 Claude 子进程读到写了一半的文件 + const tmpPath = CLAUDE_SETTINGS_PATH + '.tmp'; + try { + fs.writeFileSync(tmpPath, JSON.stringify(settings, null, 2)); + fs.renameSync(tmpPath, CLAUDE_SETTINGS_PATH); + } catch { + try { fs.unlinkSync(tmpPath); } catch {} + } } function applyModelConfig() {