diff --git a/static/src/app/lifecycle.ts b/static/src/app/lifecycle.ts index 60f0818..a184012 100644 --- a/static/src/app/lifecycle.ts +++ b/static/src/app/lifecycle.ts @@ -41,6 +41,7 @@ export async function mounted() { // 立即加载初始数据(并行获取状态,优先同步运行模式) this.loadInitialData(); + this.startConnectionHeartbeat(); // 注册全局事件处理器(用于任务轮询) (window as any).__taskEventHandler = (event: any) => { @@ -97,6 +98,7 @@ export function beforeUnmount() { this.resourceStopContainerStatsPolling(); this.resourceStopProjectStoragePolling(); this.resourceStopUsageQuotaPolling(); + this.stopConnectionHeartbeat(); teardownShowImageObserver(); if (this.titleTypingTimer) { clearInterval(this.titleTypingTimer); diff --git a/static/src/app/methods/ui.ts b/static/src/app/methods/ui.ts index f78bb4f..4cae7d5 100644 --- a/static/src/app/methods/ui.ts +++ b/static/src/app/methods/ui.ts @@ -1163,6 +1163,50 @@ export const uiMethods = { this.socket = null; }, + async checkConnectionHealth() { + const controller = typeof AbortController !== 'undefined' ? new AbortController() : null; + const timeoutId = controller ? window.setTimeout(() => controller.abort(), 5000) : null; + try { + const response = await fetch('/api/status', { + method: 'GET', + cache: 'no-store', + signal: controller?.signal + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + this.isConnected = true; + this.connectionHeartbeatFailCount = 0; + } catch (error) { + this.connectionHeartbeatFailCount = (this.connectionHeartbeatFailCount || 0) + 1; + // 一次失败就先置灰,避免“服务已断但常亮绿色”的误导 + this.isConnected = false; + } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } + } + }, + + startConnectionHeartbeat() { + if (this.connectionHeartbeatTimer) { + return; + } + this.connectionHeartbeatFailCount = 0; + // 先做一次即时探活,再进入定时轮询 + this.checkConnectionHealth(); + this.connectionHeartbeatTimer = window.setInterval(() => { + this.checkConnectionHealth(); + }, this.connectionHeartbeatIntervalMs || 8000); + }, + + stopConnectionHeartbeat() { + if (this.connectionHeartbeatTimer) { + clearInterval(this.connectionHeartbeatTimer); + this.connectionHeartbeatTimer = null; + } + }, + openRealtimeTerminal() { const { protocol, hostname, port } = window.location; const target = `${protocol}//${hostname}${port ? ':' + port : ''}/terminal`; diff --git a/static/src/app/state.ts b/static/src/app/state.ts index 20b0e3f..8236f59 100644 --- a/static/src/app/state.ts +++ b/static/src/app/state.ts @@ -83,6 +83,9 @@ export function dataState() { conversationHasVideos: false, conversationListRequestSeq: 0, conversationListRefreshToken: 0, + connectionHeartbeatTimer: null, + connectionHeartbeatFailCount: 0, + connectionHeartbeatIntervalMs: 8000, // 工具控制菜单 icons: ICONS, diff --git a/static/src/styles/base/_global.scss b/static/src/styles/base/_global.scss index 1b2705a..d65a342 100644 --- a/static/src/styles/base/_global.scss +++ b/static/src/styles/base/_global.scss @@ -18,6 +18,13 @@ body[data-theme='dark'] { color: var(--claude-text); } +/* Remove iOS/Android WebView default tap highlight */ +html, +body, +* { + -webkit-tap-highlight-color: transparent; +} + /* Global icon utility */ .icon { --icon-size: 1em; diff --git a/static/src/styles/components/input/_composer.scss b/static/src/styles/components/input/_composer.scss index 807d742..0a3cc8d 100644 --- a/static/src/styles/components/input/_composer.scss +++ b/static/src/styles/components/input/_composer.scss @@ -246,6 +246,9 @@ .menu-entry { border: none; background: transparent; + -webkit-appearance: none; + appearance: none; + -webkit-tap-highlight-color: transparent; padding: 10px 12px; border-radius: 12px; font-size: 14px; @@ -259,6 +262,11 @@ min-height: 44px; } +.menu-entry:focus, +.menu-entry:focus-visible { + outline: none; +} + .menu-entry:hover:not(:disabled) { background: rgba(0, 0, 0, 0.05); } diff --git a/static/src/styles/components/panels/_left-panel.scss b/static/src/styles/components/panels/_left-panel.scss index 8545486..992a3d7 100644 --- a/static/src/styles/components/panels/_left-panel.scss +++ b/static/src/styles/components/panels/_left-panel.scss @@ -162,7 +162,7 @@ width: 12px; height: 12px; border-radius: 6px; - background: var(--claude-muted); + background: #e34d4d; transition: background 0.2s ease, box-shadow 0.2s ease; position: relative; overflow: visible; @@ -173,8 +173,8 @@ position: absolute; inset: 0; border-radius: inherit; - opacity: 0; - background: transparent; + background: #e34d4d; + animation: statusPulse 2.4s ease-out infinite; } .connection-dot.active { @@ -183,7 +183,6 @@ .connection-dot.active::after { background: var(--claude-success); - animation: statusPulse 2.4s ease-out infinite; } .mode-icon-enter-active, diff --git a/utils/conversation_manager.py b/utils/conversation_manager.py index 21e80ee..b11143e 100644 --- a/utils/conversation_manager.py +++ b/utils/conversation_manager.py @@ -1064,6 +1064,17 @@ class ConversationManager: """ try: index = self._load_index() + # 统计必须基于全量对话,避免启动阶段“仅预热最近20条索引”导致总数偏小。 + # 这里按需触发一次全量重建;后续统计可复用已保存的完整索引。 + try: + total_files = len(self._iter_conversation_files(sort_by_mtime=False)) + except Exception: + total_files = 0 + if total_files and len(index) < total_files: + rebuilt_full = self._rebuild_index_from_files(max_count=None) + if rebuilt_full: + self._save_index(rebuilt_full) + index = rebuilt_full total_conversations = len(index) total_messages = sum(meta.get("total_messages", 0) for meta in index.values())