From ae1144fbb9a5a669dab01b45c6d92f9cfd276621 Mon Sep 17 00:00:00 2001 From: lehhair <82690391+lehhair@users.noreply.github.com> Date: Tue, 10 Feb 2026 23:14:13 +0800 Subject: [PATCH 001/890] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 28f25c2a..669625cc 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # OpenCodeUI -一个为 [OpenCode](https://github.com/opencode-ai/opencode) 打造的第三方 Web 前端界面。 +一个为 [OpenCode](https://github.com/anomalyco/opencode) 打造的第三方 Web 前端界面。 **本项目完全由 AI 辅助编程(Vibe Coding)完成**——从第一行代码到最终发布,所有功能均通过与 AI 对话驱动开发,累计 162 次提交。 From 54f3456d3299e2aaa20673a64ff92f2c0982f6ea Mon Sep 17 00:00:00 2001 From: lehhair <82690391+lehhair@users.noreply.github.com> Date: Tue, 10 Feb 2026 23:26:22 +0800 Subject: [PATCH 002/890] Update README.md --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 669625cc..08773904 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,11 @@ 本项目的部分 UI 风格参考了 [Claude](https://claude.ai) 的界面设计。 +## 预览 + +image +image + ## 快速开始 ### 前提 From 85713d0390b10d231327dd7bc769825318e71d89 Mon Sep 17 00:00:00 2001 From: lehhair Date: Tue, 10 Feb 2026 23:39:49 +0800 Subject: [PATCH 003/890] fix: add missing icon to Auto-Approve setting row --- src/features/settings/SettingsDialog.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/features/settings/SettingsDialog.tsx b/src/features/settings/SettingsDialog.tsx index 4669682b..2f07ba71 100644 --- a/src/features/settings/SettingsDialog.tsx +++ b/src/features/settings/SettingsDialog.tsx @@ -5,7 +5,7 @@ import { SunIcon, MoonIcon, SystemIcon, MaximizeIcon, MinimizeIcon, PathAutoIcon, PathUnixIcon, PathWindowsIcon, GlobeIcon, PlusIcon, TrashIcon, CheckIcon, WifiIcon, WifiOffIcon, SpinnerIcon, KeyIcon, - SettingsIcon, KeyboardIcon, CloseIcon, BellIcon + SettingsIcon, KeyboardIcon, CloseIcon, BellIcon, BoltIcon } from '../../components/Icons' import { usePathMode, useServerStore, useIsMobile, useNotification } from '../../hooks' import { autoApproveStore } from '../../store' @@ -499,6 +499,7 @@ function GeneralSettings({ themeMode, onThemeChange, isWideMode, onToggleWideMod } onClick={handleAutoApprove} > From 94e86c5e60663a79d856d4310d7c199091c9f015 Mon Sep 17 00:00:00 2001 From: lehhair Date: Tue, 10 Feb 2026 23:42:57 +0800 Subject: [PATCH 004/890] ci: add GitHub Pages deployment workflow --- .github/workflows/deploy.yml | 46 ++++++++++++++++++++++++++++++++++++ public/404.html | 19 +++++++++++++++ vite.config.ts | 1 + 3 files changed, 66 insertions(+) create mode 100644 .github/workflows/deploy.yml create mode 100644 public/404.html diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..d33ff0ea --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,46 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - run: npm ci + + - run: npm run build + env: + VITE_BASE_PATH: /OpenCodeUI/ + + - uses: actions/upload-pages-artifact@v3 + with: + path: dist + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/public/404.html b/public/404.html new file mode 100644 index 00000000..f57190d1 --- /dev/null +++ b/public/404.html @@ -0,0 +1,19 @@ + + + + + + + diff --git a/vite.config.ts b/vite.config.ts index 3f52682d..701af3e2 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,6 +4,7 @@ import tailwindcss from '@tailwindcss/vite' // https://vite.dev/config/ export default defineConfig({ + base: process.env.VITE_BASE_PATH || '/', plugins: [react(), tailwindcss()], server: { proxy: { From 2e93c0d20264da5dcba2707c7cd1c07f7a6936b7 Mon Sep 17 00:00:00 2001 From: lehhair <82690391+lehhair@users.noreply.github.com> Date: Wed, 11 Feb 2026 00:07:02 +0800 Subject: [PATCH 005/890] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20README.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 08773904..1de3719a 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,14 @@ > **免责声明**:本项目仅供学习交流使用,不对因使用本项目导致的任何问题承担责任。项目处于早期阶段,可能存在 bug 和不稳定之处。 +## 快速体验 + +在本地运行 +```bash +opencode serve --cors "https://lehhair.github.io" +``` +然后访问 https://lehhair.github.io/OpenCodeUI/ + ## 特性 - **完整的 Chat 界面** — 消息流、Markdown 渲染、代码高亮(Shiki) From 331643b1e4278e8a77a96b78896b49a65990ee19 Mon Sep 17 00:00:00 2001 From: lehhair Date: Wed, 11 Feb 2026 17:06:29 +0800 Subject: [PATCH 006/890] fix: remove frontend auth management, add SSE reconnect on visibility/network change - Remove credentials:'include' and all auth header logic from frontend - Auth handled by browser natively (401 triggers Basic Auth dialog) - Add visibility/network lifecycle listeners for SSE reconnection - Reload session messages on SSE reconnect to fill gaps - Simplify AddServerForm to name+url only (no auth fields) --- src/api/events.ts | 119 ++++++++++++++++++++--- src/api/http.ts | 26 +---- src/features/settings/SettingsDialog.tsx | 29 ++---- src/hooks/useChatSession.ts | 7 ++ src/hooks/useGlobalEvents.ts | 13 +++ src/hooks/useServerStore.ts | 4 +- src/store/index.ts | 4 +- src/store/serverStore.ts | 39 +------- src/types/api/event.ts | 2 + 9 files changed, 147 insertions(+), 96 deletions(-) diff --git a/src/api/events.ts b/src/api/events.ts index 5c623254..5ba25ae9 100644 --- a/src/api/events.ts +++ b/src/api/events.ts @@ -2,7 +2,7 @@ // Global Event Subscription (SSE) - Singleton Pattern // ============================================ -import { getApiBaseUrl, getAuthHeader } from './http' +import { getApiBaseUrl } from './http' import type { ApiMessageWithParts, ApiPart, @@ -71,6 +71,9 @@ let singletonController: AbortController | null = null let heartbeatTimer: ReturnType | null = null let reconnectTimer: ReturnType | null = null let isConnecting = false +let lifecycleListenersRegistered = false +/** 标记连接曾经成功过(用于判断是否为"重连") */ +let hasConnectedBefore = false function resetHeartbeat() { if (heartbeatTimer) clearTimeout(heartbeatTimer) @@ -113,15 +116,11 @@ function connectSingleton() { console.log('[SSE] Connecting singleton...') } - // 构建请求头,如果有认证信息则添加 - const headers: Record = {} - const authHeader = getAuthHeader() - if (authHeader) { - headers['Authorization'] = authHeader - } - + // 注册生命周期监听器(首次连接时) + registerLifecycleListeners() + + // 认证由浏览器原生处理(同源时遇到 401 自动弹认证对话框) fetch(`${getApiBaseUrl()}/global/event`, { - headers, signal: singletonController.signal, }) .then(async (response) => { @@ -131,6 +130,10 @@ function connectSingleton() { throw new Error(`Failed to subscribe: ${response.status}`) } + // 如果之前连接过,这次就是"重连",通知订阅者刷新数据 + const isReconnect = hasConnectedBefore + hasConnectedBefore = true + updateConnectionState({ state: 'connected', reconnectAttempt: 0, @@ -138,7 +141,12 @@ function connectSingleton() { }) resetHeartbeat() if (import.meta.env.DEV) { - console.log('[SSE] Singleton connected') + console.log('[SSE] Singleton connected', isReconnect ? '(reconnected)' : '(first)') + } + + // 重连成功后通知所有订阅者 + if (isReconnect) { + allSubscribers.forEach(cb => cb.onReconnected?.()) } const reader = response.body?.getReader() @@ -219,6 +227,94 @@ function disconnectSingleton() { updateConnectionState({ state: 'disconnected' }) } +// ============================================ +// Lifecycle Listeners (Visibility + Network) +// ============================================ + +function handleVisibilityChange() { + if (document.visibilityState === 'visible') { + // 页面恢复前台 + if (connectionInfo.state !== 'connected' && allSubscribers.size > 0) { + if (import.meta.env.DEV) { + console.log('[SSE] Page became visible, forcing reconnect...') + } + // 取消当前重连计划,立即重连 + if (reconnectTimer) clearTimeout(reconnectTimer) + reconnectTimer = null + updateConnectionState({ reconnectAttempt: 0 }) + + // 断开旧连接(如果还在) + if (singletonController) { + singletonController.abort() + singletonController = null + } + isConnecting = false + + connectSingleton() + } + } else { + // 页面进入后台 - 暂停心跳检测(移动端 timer 会被冻结) + if (heartbeatTimer) { + clearTimeout(heartbeatTimer) + heartbeatTimer = null + } + } +} + +function handleOnline() { + if (import.meta.env.DEV) { + console.log('[SSE] Network online, forcing reconnect...') + } + if (connectionInfo.state !== 'connected' && allSubscribers.size > 0) { + if (reconnectTimer) clearTimeout(reconnectTimer) + reconnectTimer = null + updateConnectionState({ reconnectAttempt: 0 }) + + if (singletonController) { + singletonController.abort() + singletonController = null + } + isConnecting = false + + connectSingleton() + } +} + +function handleOffline() { + if (import.meta.env.DEV) { + console.log('[SSE] Network offline') + } + // 标记为断连,但不尝试重连(没网重连也没用) + if (connectionInfo.state === 'connected' || connectionInfo.state === 'connecting') { + if (singletonController) { + singletonController.abort() + singletonController = null + } + isConnecting = false + if (heartbeatTimer) clearTimeout(heartbeatTimer) + if (reconnectTimer) clearTimeout(reconnectTimer) + updateConnectionState({ state: 'disconnected', error: 'Network offline' }) + } +} + +function registerLifecycleListeners() { + if (lifecycleListenersRegistered) return + lifecycleListenersRegistered = true + + document.addEventListener('visibilitychange', handleVisibilityChange) + window.addEventListener('online', handleOnline) + window.addEventListener('offline', handleOffline) +} + +function unregisterLifecycleListeners() { + if (!lifecycleListenersRegistered) return + lifecycleListenersRegistered = false + + document.removeEventListener('visibilitychange', handleVisibilityChange) + window.removeEventListener('online', handleOnline) + window.removeEventListener('offline', handleOffline) +} + // 广播事件给所有订阅者 function broadcastEvent(globalEvent: GlobalEvent) { const { type, properties } = globalEvent.payload @@ -316,9 +412,10 @@ export function subscribeToEvents(callbacks: EventCallbacks): () => void { return () => { allSubscribers.delete(callbacks) - // 如果没有订阅者了,断开连接 + // 如果没有订阅者了,断开连接并清理监听器 if (allSubscribers.size === 0) { disconnectSingleton() + unregisterLifecycleListeners() } } } diff --git a/src/api/http.ts b/src/api/http.ts index 184f350d..887d9019 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -4,7 +4,7 @@ // ============================================ import { API_BASE_URL } from '../constants' -import { serverStore, makeBasicAuthHeader } from '../store/serverStore' +import { serverStore } from '../store/serverStore' /** * 获取当前 API Base URL @@ -14,18 +14,6 @@ export function getApiBaseUrl(): string { return serverStore.getActiveBaseUrl() } -/** - * 获取当前活动服务器的 Authorization header - * 如果有认证信息则返回 Basic Auth header,否则返回 undefined - */ -export function getAuthHeader(): string | undefined { - const auth = serverStore.getActiveAuth() - if (auth) { - return makeBasicAuthHeader(auth) - } - return undefined -} - /** @deprecated 使用 getApiBaseUrl() 代替 */ export const API_BASE = API_BASE_URL @@ -72,9 +60,10 @@ export interface RequestOptions { /** * 通用 HTTP 请求函数 * - * 直接请求后端服务器,支持 CORS 跨域 - * - 后端需要设置 Access-Control-Allow-Origin - * - 如果需要发送凭证(cookies),后端还需设置 Access-Control-Allow-Credentials + * 直接请求后端服务器 + * - 同源部署时,浏览器遇到 401 + WWW-Authenticate 会自动弹出认证对话框 + * - 跨域部署时需要后端正确配置 CORS,或使用反向代理实现同源 + * - 前端不管理任何认证凭据,完全由浏览器原生机制处理 */ export async function request( path: string, @@ -83,14 +72,9 @@ export async function request( ): Promise { const { method = 'GET', body, headers = {} } = options - // 自动添加认证 header - const authHeader = getAuthHeader() const requestHeaders: Record = { ...headers, } - if (authHeader) { - requestHeaders['Authorization'] = authHeader - } const init: RequestInit = { method, diff --git a/src/features/settings/SettingsDialog.tsx b/src/features/settings/SettingsDialog.tsx index 2f07ba71..ffdb7b2e 100644 --- a/src/features/settings/SettingsDialog.tsx +++ b/src/features/settings/SettingsDialog.tsx @@ -12,7 +12,7 @@ import { autoApproveStore } from '../../store' import { KeybindingsSection } from './KeybindingsSection' import type { ThemeMode } from '../../hooks' import type { PathMode } from '../../utils/directoryUtils' -import type { ServerConfig, ServerHealth, ServerAuth } from '../../store/serverStore' +import type { ServerConfig, ServerHealth } from '../../store/serverStore' // ============================================ // Types @@ -222,15 +222,12 @@ function ServerItem({ server, health, isActive, onSelect, onDelete, onCheckHealt // ============================================ function AddServerForm({ onAdd, onCancel }: { - onAdd: (name: string, url: string, auth?: ServerAuth) => void + onAdd: (name: string, url: string) => void onCancel: () => void }) { const [name, setName] = useState('') const [url, setUrl] = useState('') - const [username, setUsername] = useState('') - const [password, setPassword] = useState('') const [error, setError] = useState('') - const [showAuth, setShowAuth] = useState(false) const handleSubmit = (e: React.FormEvent) => { e.preventDefault() @@ -238,10 +235,7 @@ function AddServerForm({ onAdd, onCancel }: { if (!url.trim()) { setError('URL required'); return } try { new URL(url) } catch { setError('Invalid URL'); return } - const auth: ServerAuth | undefined = (username.trim() && password.trim()) - ? { username: username.trim(), password: password.trim() } - : undefined - onAdd(name.trim(), url.trim(), auth) + onAdd(name.trim(), url.trim()) } const inputCls = "w-full h-8 px-3 text-[13px] bg-bg-000 border border-border-200 rounded-md focus:outline-none focus:border-accent-main-100/50 text-text-100 placeholder:text-text-400" @@ -258,18 +252,9 @@ function AddServerForm({ onAdd, onCancel }: { { setUrl(e.target.value); setError('') }} placeholder="http://192.168.1.100:4096" className={`${inputCls} font-mono`} /> - - {showAuth && ( -
- { setUsername(e.target.value); setError('') }} - placeholder="Username" className={inputCls} /> - { setPassword(e.target.value); setError('') }} - placeholder="Password" className={inputCls} /> -
- )} +
+ If the server requires a password, visit the URL in your browser first to authenticate +
{error &&

{error}

}
@@ -537,7 +522,7 @@ function GeneralSettings({ themeMode, onThemeChange, isWideMode, onToggleWideMod ))} {addingServer && ( { const s = addServer({ name: n, url: u, auth: a }); setAddingServer(false); checkHealth(s.id) }} + onAdd={(n, u) => { const s = addServer({ name: n, url: u }); setAddingServer(false); checkHealth(s.id) }} onCancel={() => setAddingServer(false)} /> )} diff --git a/src/hooks/useChatSession.ts b/src/hooks/useChatSession.ts index 44d5503f..f5704010 100644 --- a/src/hooks/useChatSession.ts +++ b/src/hooks/useChatSession.ts @@ -62,6 +62,7 @@ export function useChatSession({ chatAreaRef, currentModel }: UseChatSessionOpti // Session Manager const { + loadSession, loadMoreHistory, handleUndo, handleRedo, @@ -174,6 +175,12 @@ export function useChatSession({ chatAreaRef, currentModel }: UseChatSessionOpti directory: effectiveDirectory, }) }, + onReconnected: () => { + // SSE 重连后重新加载当前会话,补齐断连期间可能丢失的消息 + if (routeSessionId) { + loadSession(routeSessionId) + } + }, }) const handleVisibleMessageIdsChange = useCallback((ids: string[]) => { diff --git a/src/hooks/useGlobalEvents.ts b/src/hooks/useGlobalEvents.ts index 6d12da19..7d1e1613 100644 --- a/src/hooks/useGlobalEvents.ts +++ b/src/hooks/useGlobalEvents.ts @@ -27,6 +27,8 @@ interface GlobalEventsCallbacks { onScrollRequest?: () => void onSessionIdle?: (sessionID: string) => void onSessionError?: (sessionID: string) => void + /** SSE 重连成功后触发,调用方可刷新当前 session 数据 */ + onReconnected?: () => void } // ============================================ @@ -217,6 +219,17 @@ export function useGlobalEvents(callbacks?: GlobalEventsCallbacks) { callbacksRef.current?.onQuestionRejected?.(data) } }, + + // ============================================ + // Reconnected → 通知调用方刷新数据 + // ============================================ + + onReconnected: () => { + if (import.meta.env.DEV) { + console.log('[GlobalEvents] SSE reconnected, notifying for data refresh') + } + callbacksRef.current?.onReconnected?.() + }, }) return unsubscribe diff --git a/src/hooks/useServerStore.ts b/src/hooks/useServerStore.ts index 7da7467d..3e76f658 100644 --- a/src/hooks/useServerStore.ts +++ b/src/hooks/useServerStore.ts @@ -3,8 +3,8 @@ // ============================================ import { useSyncExternalStore, useCallback } from 'react' -import { serverStore, type ServerConfig, type ServerHealth, type ServerAuth } from '../store/serverStore' -export type { ServerConfig, ServerHealth, ServerAuth } +import { serverStore, type ServerConfig, type ServerHealth } from '../store/serverStore' +export type { ServerConfig, ServerHealth } /** * 订阅 serverStore 的 React hook diff --git a/src/store/index.ts b/src/store/index.ts index 765f5c91..e6249a1f 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -17,8 +17,8 @@ export { layoutStore, useLayoutStore } from './layoutStore' export { autoApproveStore } from './autoApproveStore' export type { AutoApproveRule } from './autoApproveStore' -export { serverStore, makeBasicAuthHeader } from './serverStore' -export type { ServerConfig, ServerHealth, ServerAuth } from './serverStore' +export { serverStore } from './serverStore' +export type { ServerConfig, ServerHealth } from './serverStore' export { keybindingStore, parseKeybinding, formatKeybinding, keyEventToString, matchesKeybinding } from './keybindingStore' export type { KeybindingAction, KeybindingConfig, ParsedKeybinding } from './keybindingStore' diff --git a/src/store/serverStore.ts b/src/store/serverStore.ts index 5eabbb66..47de0268 100644 --- a/src/store/serverStore.ts +++ b/src/store/serverStore.ts @@ -4,14 +4,6 @@ import { API_BASE_URL } from '../constants' -/** - * 服务器认证配置 - */ -export interface ServerAuth { - username: string - password: string -} - /** * 服务器配置 */ @@ -20,7 +12,6 @@ export interface ServerConfig { name: string // 显示名称 url: string // 服务器 URL (不含尾部斜杠) isDefault?: boolean // 是否为默认服务器 - auth?: ServerAuth // 认证信息 (HTTP Basic Auth) } /** @@ -34,14 +25,6 @@ export interface ServerHealth { version?: string // 服务器版本 } -/** - * 生成 Basic Auth header value - */ -export function makeBasicAuthHeader(auth: ServerAuth): string { - const credentials = btoa(`${auth.username}:${auth.password}`) - return `Basic ${credentials}` -} - type Listener = () => void const STORAGE_KEY = 'opencode-servers' @@ -172,20 +155,6 @@ class ServerStore { return server?.url ?? API_BASE_URL } - /** - * 获取当前活动服务器的认证信息 - */ - getActiveAuth(): ServerAuth | undefined { - return this.getActiveServer()?.auth - } - - /** - * 获取指定服务器的认证信息 - */ - getServerAuth(serverId: string): ServerAuth | undefined { - return this.servers.find(s => s.id === serverId)?.auth - } - /** * 获取服务器健康状态 */ @@ -295,15 +264,9 @@ class ServerStore { const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), 5000) - // 构建请求头,如果有认证信息则添加 - const headers: Record = {} - if (server.auth) { - headers['Authorization'] = makeBasicAuthHeader(server.auth) - } - + // 认证由浏览器原生处理(同源时遇到 401 自动弹认证对话框) const response = await fetch(`${server.url}/global/health`, { method: 'GET', - headers, signal: controller.signal, }) diff --git a/src/types/api/event.ts b/src/types/api/event.ts index a8f53f28..d422ddfd 100644 --- a/src/types/api/event.ts +++ b/src/types/api/event.ts @@ -186,4 +186,6 @@ export interface EventCallbacks { onWorktreeFailed?: (data: WorktreeFailedPayload) => void onVcsBranchUpdated?: (data: VcsBranchUpdatedPayload) => void onError?: (error: Error) => void + /** SSE 重连成功后触发,通知订阅者可能需要刷新数据 */ + onReconnected?: () => void } From 0a936abec6160860f6f1d95d65fb81bd92284a83 Mon Sep 17 00:00:00 2001 From: lehhair Date: Wed, 11 Feb 2026 17:06:39 +0800 Subject: [PATCH 007/890] fix: add slash command hint to input placeholder --- src/features/chat/InputBox.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/chat/InputBox.tsx b/src/features/chat/InputBox.tsx index f744b229..f35ede05 100644 --- a/src/features/chat/InputBox.tsx +++ b/src/features/chat/InputBox.tsx @@ -599,7 +599,7 @@ function InputBoxComponent({ onKeyDown={handleKeyDown} onPaste={handlePaste} onScroll={handleScroll} - placeholder="Reply to Agent (type @ to mention)" + placeholder="Reply to Agent (type @ to mention, / for commands)" className="w-full resize-none focus:outline-none focus:ring-0 bg-transparent text-text-100 placeholder:text-text-400 custom-scrollbar" style={{ ...TEXT_STYLE, From f0ab630a9580c50c7a21dd8495087b87a90a9613 Mon Sep 17 00:00:00 2001 From: lehhair Date: Wed, 11 Feb 2026 17:23:02 +0800 Subject: [PATCH 008/890] fix: tool icon glow animation, mobile task button visibility, sub-agent message line breaks - Add animated ping glow effect to tool icons during execution - Make task session open-in-new-tab button always visible on mobile (sm:opacity-0 instead of opacity-0) - Add whitespace-pre-wrap to user messages in sub-agent view for proper line breaks --- src/features/message/parts/ToolPartView.tsx | 8 +++++++- src/features/message/tools/renderers/TaskRenderer.tsx | 4 ++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/features/message/parts/ToolPartView.tsx b/src/features/message/parts/ToolPartView.tsx index a828fc0b..bad3bb85 100644 --- a/src/features/message/parts/ToolPartView.tsx +++ b/src/features/message/parts/ToolPartView.tsx @@ -50,11 +50,17 @@ export const ToolPartView = memo(function ToolPartView({ part, isFirst = false, {/* Tool icon — h-9 和右侧 header 等高,flex 自然居中 */}
+ {isActive && ( + + )} + {isActive && ( + + )} {getToolIcon(toolName)}
diff --git a/src/features/message/tools/renderers/TaskRenderer.tsx b/src/features/message/tools/renderers/TaskRenderer.tsx index 460c191e..21409db5 100644 --- a/src/features/message/tools/renderers/TaskRenderer.tsx +++ b/src/features/message/tools/renderers/TaskRenderer.tsx @@ -189,7 +189,7 @@ const TaskHeader = memo(function TaskHeader({ {sessionId && (
From 8785acb10dad4340f1ddf1387f3b39ab02395d5d Mon Sep 17 00:00:00 2001 From: lehhair Date: Wed, 11 Feb 2026 20:14:48 +0800 Subject: [PATCH 010/890] feat: restore frontend auth management with cross-origin CORS warning --- src/api/events.ts | 7 ++- src/api/http.ts | 22 +++++-- src/features/settings/SettingsDialog.tsx | 73 +++++++++++++++++++++--- src/store/index.ts | 4 +- src/store/serverStore.ts | 39 ++++++++++++- 5 files changed, 128 insertions(+), 17 deletions(-) diff --git a/src/api/events.ts b/src/api/events.ts index 5ba25ae9..d7c358f6 100644 --- a/src/api/events.ts +++ b/src/api/events.ts @@ -2,7 +2,7 @@ // Global Event Subscription (SSE) - Singleton Pattern // ============================================ -import { getApiBaseUrl } from './http' +import { getApiBaseUrl, getAuthHeader } from './http' import type { ApiMessageWithParts, ApiPart, @@ -119,9 +119,12 @@ function connectSingleton() { // 注册生命周期监听器(首次连接时) registerLifecycleListeners() - // 认证由浏览器原生处理(同源时遇到 401 自动弹认证对话框) + // 如果配置了密码,添加 Authorization header fetch(`${getApiBaseUrl()}/global/event`, { signal: singletonController.signal, + headers: { + ...getAuthHeader(), + }, }) .then(async (response) => { isConnecting = false diff --git a/src/api/http.ts b/src/api/http.ts index 887d9019..afcb1153 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -4,7 +4,7 @@ // ============================================ import { API_BASE_URL } from '../constants' -import { serverStore } from '../store/serverStore' +import { serverStore, makeBasicAuthHeader } from '../store/serverStore' /** * 获取当前 API Base URL @@ -14,6 +14,18 @@ export function getApiBaseUrl(): string { return serverStore.getActiveBaseUrl() } +/** + * 获取当前活动服务器的 Authorization header + * 如果服务器配置了密码则返回 Basic Auth header,否则返回 undefined + */ +export function getAuthHeader(): Record { + const auth = serverStore.getActiveAuth() + if (auth?.password) { + return { 'Authorization': makeBasicAuthHeader(auth) } + } + return {} +} + /** @deprecated 使用 getApiBaseUrl() 代替 */ export const API_BASE = API_BASE_URL @@ -60,10 +72,9 @@ export interface RequestOptions { /** * 通用 HTTP 请求函数 * - * 直接请求后端服务器 - * - 同源部署时,浏览器遇到 401 + WWW-Authenticate 会自动弹出认证对话框 - * - 跨域部署时需要后端正确配置 CORS,或使用反向代理实现同源 - * - 前端不管理任何认证凭据,完全由浏览器原生机制处理 + * 如果活动服务器配置了密码,会自动添加 Authorization header + * 注意:跨域场景下 Authorization header 会触发 CORS 预检请求, + * 需要后端正确处理 OPTIONS 请求 */ export async function request( path: string, @@ -73,6 +84,7 @@ export async function request( const { method = 'GET', body, headers = {} } = options const requestHeaders: Record = { + ...getAuthHeader(), ...headers, } diff --git a/src/features/settings/SettingsDialog.tsx b/src/features/settings/SettingsDialog.tsx index ffdb7b2e..7fbc3090 100644 --- a/src/features/settings/SettingsDialog.tsx +++ b/src/features/settings/SettingsDialog.tsx @@ -194,7 +194,10 @@ function ServerItem({ server, health, isActive, onSelect, onDelete, onCheckHealt {server.name} {isActive && } -
{server.url}
+
+ {server.url} + {server.auth?.password && } +
+ + {showAuth && ( + <> +
+ + { setUsername(e.target.value); setError('') }} + placeholder="opencode (default)" className={inputCls} /> +
+
+ + { setPassword(e.target.value); setError('') }} + placeholder="OPENCODE_SERVER_PASSWORD" className={inputCls} /> +
+ + {/* 跨域警告 */} + {isCrossOrigin && password.trim() && ( +
+ Cross-origin + password may not work due to a backend CORS limitation + (#10047). + Consider deploying the UI on the same origin or starting the server without a password. +
+ )} + +
+ Credentials are stored in localStorage. For same-origin setups, the browser can handle auth natively without entering credentials here. +
+ + )} + {error &&

{error}

}
@@ -522,7 +576,12 @@ function GeneralSettings({ themeMode, onThemeChange, isWideMode, onToggleWideMod ))} {addingServer && ( { const s = addServer({ name: n, url: u }); setAddingServer(false); checkHealth(s.id) }} + onAdd={(n, u, user, pass) => { + const auth = pass ? { username: user || 'opencode', password: pass } : undefined + const s = addServer({ name: n, url: u, auth }) + setAddingServer(false) + checkHealth(s.id) + }} onCancel={() => setAddingServer(false)} /> )} diff --git a/src/store/index.ts b/src/store/index.ts index e6249a1f..765f5c91 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -17,8 +17,8 @@ export { layoutStore, useLayoutStore } from './layoutStore' export { autoApproveStore } from './autoApproveStore' export type { AutoApproveRule } from './autoApproveStore' -export { serverStore } from './serverStore' -export type { ServerConfig, ServerHealth } from './serverStore' +export { serverStore, makeBasicAuthHeader } from './serverStore' +export type { ServerConfig, ServerHealth, ServerAuth } from './serverStore' export { keybindingStore, parseKeybinding, formatKeybinding, keyEventToString, matchesKeybinding } from './keybindingStore' export type { KeybindingAction, KeybindingConfig, ParsedKeybinding } from './keybindingStore' diff --git a/src/store/serverStore.ts b/src/store/serverStore.ts index 47de0268..f3f8af9f 100644 --- a/src/store/serverStore.ts +++ b/src/store/serverStore.ts @@ -4,6 +4,14 @@ import { API_BASE_URL } from '../constants' +/** + * 服务器认证信息 + */ +export interface ServerAuth { + username: string // 用户名 (默认 opencode) + password: string // 密码 +} + /** * 服务器配置 */ @@ -12,6 +20,7 @@ export interface ServerConfig { name: string // 显示名称 url: string // 服务器 URL (不含尾部斜杠) isDefault?: boolean // 是否为默认服务器 + auth?: ServerAuth // 认证信息 (可选) } /** @@ -155,6 +164,22 @@ class ServerStore { return server?.url ?? API_BASE_URL } + /** + * 获取当前活动服务器的认证信息 + */ + getActiveAuth(): ServerAuth | null { + const server = this.getActiveServer() + return server?.auth ?? null + } + + /** + * 获取指定服务器的认证信息 + */ + getServerAuth(serverId: string): ServerAuth | null { + const server = this.servers.find(s => s.id === serverId) + return server?.auth ?? null + } + /** * 获取服务器健康状态 */ @@ -264,10 +289,15 @@ class ServerStore { const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), 5000) - // 认证由浏览器原生处理(同源时遇到 401 自动弹认证对话框) + const headers: Record = {} + if (server.auth?.password) { + headers['Authorization'] = makeBasicAuthHeader(server.auth) + } + const response = await fetch(`${server.url}/global/health`, { method: 'GET', signal: controller.signal, + headers, }) clearTimeout(timeoutId) @@ -339,3 +369,10 @@ class ServerStore { // 单例导出 export const serverStore = new ServerStore() + +/** + * 生成 Basic Auth header 值 + */ +export function makeBasicAuthHeader(auth: ServerAuth): string { + return 'Basic ' + btoa(`${auth.username}:${auth.password}`) +} From c4d509374a082d7f073fc2264b13ea5dbc14b571 Mon Sep 17 00:00:00 2001 From: lehhair Date: Thu, 12 Feb 2026 00:23:44 +0800 Subject: [PATCH 011/890] feat: add dynamic aperture index, custom font size slider, and restructure settings into 4 tabs - Add OutlineIndex component with cosine-interpolation aperture animation for both desktop (hover) and mobile (touch-hold + release-to-jump) - ChatArea: expose scrollToMessageIndex via imperative handle - App: integrate OutlineIndex with visible message tracking - Settings: split single General tab into Appearance / General / Servers / Shortcuts - Font size: custom slider (12-24px) replacing 3-tier preset, stored as number in themeStore - InputBox: responsive placeholder (short on mobile, full on desktop) --- src/App.tsx | 22 +- src/components/OutlineIndex.tsx | 531 +++++++++++++++++++++ src/features/chat/ChatArea.tsx | 17 +- src/features/chat/InputBox.tsx | 6 +- src/features/settings/SettingsDialog.tsx | 584 +++++++++++++---------- src/hooks/useTheme.ts | 10 + src/store/index.ts | 2 +- src/store/themeStore.ts | 35 +- 8 files changed, 942 insertions(+), 265 deletions(-) create mode 100644 src/components/OutlineIndex.tsx diff --git a/src/App.tsx b/src/App.tsx index f86a6871..117025ef 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ import { type ModelSelectorHandle } from './features/chat/ModelSelector' import { SettingsDialog } from './features/settings/SettingsDialog' import { CommandPalette, type CommandItem } from './components/CommandPalette' import { RightPanel } from './components/RightPanel' +import { OutlineIndex } from './components/OutlineIndex' import { BottomPanel } from './components/BottomPanel' import { useTheme, useModels, useModelSelection, useChatSession, useGlobalKeybindings } from './hooks' import type { KeybindingHandlers } from './hooks/useKeybindings' @@ -51,6 +52,11 @@ function App() { restoreFromMessage, } = useModelSelection({ models }) + // ============================================ + // Visible Message IDs (for outline index) + // ============================================ + const [visibleMessageIds, setVisibleMessageIds] = useState([]) + // ============================================ // Wide Mode // ============================================ @@ -70,8 +76,8 @@ function App() { // Settings Dialog // ============================================ const [settingsDialogOpen, setSettingsDialogOpen] = useState(false) - const [settingsInitialTab, setSettingsInitialTab] = useState<'general' | 'keybindings'>('general') - const openSettings = useCallback(() => { setSettingsInitialTab('general'); setSettingsDialogOpen(true) }, []) + const [settingsInitialTab, setSettingsInitialTab] = useState<'appearance' | 'general' | 'servers' | 'keybindings'>('appearance') + const openSettings = useCallback(() => { setSettingsInitialTab('appearance'); setSettingsDialogOpen(true) }, []) const closeSettings = useCallback(() => setSettingsDialogOpen(false), []) // ============================================ @@ -370,10 +376,20 @@ function App() { canUndo={canUndo} registerMessage={registerMessage} isWideMode={isWideMode} - onVisibleMessageIdsChange={handleVisibleMessageIdsChange} + onVisibleMessageIdsChange={(ids) => { + handleVisibleMessageIdsChange(ids) + setVisibleMessageIds(ids) + }} />
+ {/* Outline Index - 消息目录索引 */} + chatAreaRef.current?.scrollToMessageIndex(index)} + visibleMessageIds={visibleMessageIds} + /> + {/* Floating Input Box */}
{/* Double-Esc cancel hint */} diff --git a/src/components/OutlineIndex.tsx b/src/components/OutlineIndex.tsx new file mode 100644 index 00000000..8fb98506 --- /dev/null +++ b/src/components/OutlineIndex.tsx @@ -0,0 +1,531 @@ +// ============================================ +// OutlineIndex - Dynamic Aperture Index +// 动态光圈索引 +// +// PC 端:右侧浮动,余弦插值光圈 + 滚轮导航 +// 移动端:右边缘触摸,背景模糊 + 震动反馈 +// +// 性能:rAF + 直接 DOM 操作,不用 React State 驱动动画 +// ============================================ + +import { memo, useMemo, useRef, useEffect, useCallback, useState } from 'react' +import type { Message } from '../types/message' +import { isUserMessage } from '../types/message' + +// ============================================ +// Types +// ============================================ + +interface OutlineEntry { + index: number + title: string + messageId: string +} + +interface OutlineIndexProps { + messages: Message[] + onScrollToIndex: (index: number) => void + visibleMessageIds?: string[] +} + +// ============================================ +// Constants +// ============================================ + +const INFLUENCE_RADIUS = 55 +const LERP_FACTOR = 0.18 +const EPSILON = 0.005 +const LABEL_THRESHOLD = 0.65 + +const TICK_W_MIN = 8 +const TICK_W_MAX = 22 +const TICK_H = 2 +const MARGIN_MIN = 1 +const MARGIN_MAX = 8 + +// ============================================ +// Data extraction (同 ChatArea 的过滤逻辑) +// ============================================ + +function messageHasContent(msg: Message): boolean { + if (msg.parts.length === 0) return true + return msg.parts.some(part => { + switch (part.type) { + case 'text': + return part.text?.trim().length > 0 + case 'reasoning': + return part.text?.trim().length > 0 + case 'tool': + case 'file': + case 'agent': + case 'step-finish': + case 'subtask': + return true + default: + return false + } + }) +} + +function extractOutlineEntries(messages: Message[]): OutlineEntry[] { + const entries: OutlineEntry[] = [] + const visible = messages.filter(messageHasContent) + for (let i = 0; i < visible.length; i++) { + const msg = visible[i] + if (isUserMessage(msg.info) && msg.info.summary?.title) { + entries.push({ + index: i, + title: msg.info.summary.title, + messageId: msg.info.id, + }) + } + } + return entries +} + +function truncate(s: string, max: number) { + return s.length <= max ? s : s.slice(0, max) + '…' +} + +// ============================================ +// Entry Component +// ============================================ + +export const OutlineIndex = memo(function OutlineIndex({ + messages, + onScrollToIndex, + visibleMessageIds, +}: OutlineIndexProps) { + const entries = useMemo(() => extractOutlineEntries(messages), [messages]) + + if (entries.length < 2) return null + + return ( + <> + + + + ) +}) + +// ============================================ +// Shared props +// ============================================ + +interface ApertureProps { + entries: OutlineEntry[] + onScrollToIndex: (index: number) => void + visibleMessageIds?: string[] +} + +// ============================================ +// PC 端:右侧光圈索引 +// ============================================ + +const DesktopAperture = memo(function DesktopAperture({ + entries, + onScrollToIndex, + visibleMessageIds, +}: ApertureProps) { + const containerRef = useRef(null) + const cursorYRef = useRef(null) + const strengthsRef = useRef([]) + const hlStateRef = useRef([]) + const rafIdRef = useRef(0) + const isHoveringRef = useRef(false) + + const activeSet = useMemo(() => { + if (!visibleMessageIds || visibleMessageIds.length === 0) return null + return new Set(visibleMessageIds) + }, [visibleMessageIds]) + + useEffect(() => { + strengthsRef.current = entries.map(() => 0) + hlStateRef.current = entries.map(() => false) + }, [entries.length]) // eslint-disable-line react-hooks/exhaustive-deps + + // ---- Animation loop ---- + const runLoop = useCallback(() => { + const container = containerRef.current + if (!container) return + + const items = container.querySelectorAll('[data-oi]') + const cursorY = cursorYRef.current + let alive = false + + items.forEach((item, i) => { + const tick = item.querySelector('[data-tick]') + const label = item.querySelector('[data-label]') + if (!tick || !label) return + + // target strength + let target = 0 + if (cursorY !== null) { + const rect = item.getBoundingClientRect() + const centerY = rect.top + rect.height / 2 + const d = Math.abs(cursorY - centerY) + if (d < INFLUENCE_RADIUS) { + target = Math.cos((d / INFLUENCE_RADIUS) * (Math.PI / 2)) + } + } + + // lerp + const prev = strengthsRef.current[i] ?? 0 + let s = prev + (target - prev) * LERP_FACTOR + if (Math.abs(s) < EPSILON && target === 0) s = 0 + strengthsRef.current[i] = s + if (Math.abs(s - target) > EPSILON) alive = true + + // active 标记 + const isActive = item.dataset.active === '1' + const baseW = isActive ? 13 : TICK_W_MIN + + // apply styles + tick.style.width = `${baseW + s * (TICK_W_MAX - TICK_W_MIN)}px` + item.style.marginTop = `${MARGIN_MIN + s * (MARGIN_MAX - MARGIN_MIN)}px` + item.style.marginBottom = `${MARGIN_MIN + s * (MARGIN_MAX - MARGIN_MIN)}px` + tick.style.opacity = `${(isActive ? 0.6 : 0.3) + s * 0.7}` + + // tick 颜色 + const shouldHL = s > 0.5 + if (shouldHL !== hlStateRef.current[i]) { + hlStateRef.current[i] = shouldHL + if (shouldHL) { + tick.classList.remove('bg-text-400/40', 'bg-text-300') + tick.classList.add('bg-accent-main-100') + } else { + tick.classList.remove('bg-accent-main-100') + tick.classList.add(isActive ? 'bg-text-300' : 'bg-text-400/40') + } + } + if (!shouldHL && isActive && !tick.classList.contains('bg-text-300')) { + tick.classList.remove('bg-text-400/40') + tick.classList.add('bg-text-300') + } + + // label + if (s > LABEL_THRESHOLD) { + const t = Math.min(1, (s - LABEL_THRESHOLD) / (1 - LABEL_THRESHOLD)) + label.style.opacity = `${t}` + label.style.transform = `translateX(${(1 - t) * 10}px)` + label.style.visibility = 'visible' + } else { + label.style.opacity = '0' + label.style.transform = 'translateX(10px)' + label.style.visibility = 'hidden' + } + }) + + if (isHoveringRef.current || alive) { + rafIdRef.current = requestAnimationFrame(runLoop) + } + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + const ensureLoop = useCallback(() => { + cancelAnimationFrame(rafIdRef.current) + rafIdRef.current = requestAnimationFrame(runLoop) + }, [runLoop]) + + // ---- Event handlers ---- + const handleMouseEnter = useCallback(() => { + isHoveringRef.current = true + ensureLoop() + }, [ensureLoop]) + + const handleMouseMove = useCallback((e: React.MouseEvent) => { + cursorYRef.current = e.clientY + }, []) + + const handleMouseLeave = useCallback(() => { + isHoveringRef.current = false + cursorYRef.current = null + ensureLoop() + }, [ensureLoop]) + + const handleClick = useCallback((entryIndex: number) => { + onScrollToIndex(entryIndex) + }, [onScrollToIndex]) + + useEffect(() => { + return () => cancelAnimationFrame(rafIdRef.current) + }, []) + + return ( +
+ {entries.map((entry) => { + const isActive = activeSet?.has(entry.messageId) ?? false + return ( +
handleClick(entry.index)} + > + {/* Label(在 tick 左侧) */} +
+ {truncate(entry.title, 14)} +
+ {/* Tick mark */} +
+
+ ) + })} +
+ ) +}) + +// ============================================ +// 移动端:按住滑动索引 +// 同样的 rAF + 余弦插值光圈引擎,输入源为 touch +// ============================================ + +const MOBILE_INFLUENCE_RADIUS = 45 +const MOBILE_MARGIN_MIN = 1 +const MOBILE_MARGIN_MAX = 10 +const MOBILE_TICK_W_MIN = 6 +const MOBILE_TICK_W_MAX = 20 +const MOBILE_LABEL_THRESHOLD = 0.6 + +const MobileAperture = memo(function MobileAperture({ + entries, + onScrollToIndex, + visibleMessageIds, +}: ApertureProps) { + const [overlayVisible, setOverlayVisible] = useState(false) + const containerRef = useRef(null) + const touchYRef = useRef(null) + const strengthsRef = useRef([]) + const rafIdRef = useRef(0) + const isTouchingRef = useRef(false) + const prevFocusIdxRef = useRef(-1) + + const activeSet = useMemo(() => { + if (!visibleMessageIds || visibleMessageIds.length === 0) return null + return new Set(visibleMessageIds) + }, [visibleMessageIds]) + + useEffect(() => { + strengthsRef.current = entries.map(() => 0) + }, [entries.length]) // eslint-disable-line react-hooks/exhaustive-deps + + // 震动 + const vibrate = useCallback(() => { + try { navigator.vibrate?.(5) } catch { /* ignore */ } + }, []) + + // ---- 动画循环 ---- + const runLoop = useCallback(() => { + const container = containerRef.current + if (!container) return + + const items = container.querySelectorAll('[data-moi]') + const touchY = touchYRef.current + let alive = false + let focusIdx = -1 + let maxS = 0 + + items.forEach((item, i) => { + const tick = item.querySelector('[data-mtick]') + const label = item.querySelector('[data-mlabel]') + if (!tick || !label) return + + // target + let target = 0 + if (touchY !== null) { + const rect = item.getBoundingClientRect() + const centerY = rect.top + rect.height / 2 + const d = Math.abs(touchY - centerY) + if (d < MOBILE_INFLUENCE_RADIUS) { + target = Math.cos((d / MOBILE_INFLUENCE_RADIUS) * (Math.PI / 2)) + } + } + + // lerp + const prev = strengthsRef.current[i] ?? 0 + let s = prev + (target - prev) * LERP_FACTOR + if (Math.abs(s) < EPSILON && target === 0) s = 0 + strengthsRef.current[i] = s + if (Math.abs(s - target) > EPSILON) alive = true + + // 找最强项 + if (s > maxS) { maxS = s; focusIdx = i } + + const isActive = item.dataset.active === '1' + const baseW = isActive ? 10 : MOBILE_TICK_W_MIN + + // styles + const w = baseW + s * (MOBILE_TICK_W_MAX - MOBILE_TICK_W_MIN) + const m = MOBILE_MARGIN_MIN + s * (MOBILE_MARGIN_MAX - MOBILE_MARGIN_MIN) + tick.style.width = `${w}px` + item.style.marginTop = `${m}px` + item.style.marginBottom = `${m}px` + tick.style.opacity = `${(isActive ? 0.5 : 0.25) + s * 0.75}` + + // tick 颜色 + if (s > 0.5) { + tick.style.backgroundColor = 'hsl(var(--accent-main-100))' + } else if (isActive) { + tick.style.backgroundColor = 'hsl(var(--text-300))' + } else { + tick.style.backgroundColor = 'hsl(var(--text-400) / 0.3)' + } + + // label + if (s > MOBILE_LABEL_THRESHOLD) { + const t = Math.min(1, (s - MOBILE_LABEL_THRESHOLD) / (1 - MOBILE_LABEL_THRESHOLD)) + label.style.opacity = `${t}` + label.style.transform = `translateX(${(1 - t) * 12}px)` + label.style.visibility = 'visible' + } else { + label.style.opacity = '0' + label.style.transform = 'translateX(12px)' + label.style.visibility = 'hidden' + } + }) + + // 焦点切换 → 震动(滑动中只反馈,不跳转) + if (focusIdx >= 0 && maxS > 0.5 && focusIdx !== prevFocusIdxRef.current) { + prevFocusIdxRef.current = focusIdx + vibrate() + } + + if (isTouchingRef.current || alive) { + rafIdRef.current = requestAnimationFrame(runLoop) + } else { + // 所有 strength 归零后收起 overlay + setOverlayVisible(false) + } + }, [entries, vibrate, onScrollToIndex]) // eslint-disable-line react-hooks/exhaustive-deps + + const ensureLoop = useCallback(() => { + cancelAnimationFrame(rafIdRef.current) + rafIdRef.current = requestAnimationFrame(runLoop) + }, [runLoop]) + + // ---- Touch handlers ---- + const handleTouchStart = useCallback((e: React.TouchEvent) => { + e.preventDefault() + isTouchingRef.current = true + prevFocusIdxRef.current = -1 + touchYRef.current = e.touches[0].clientY + setOverlayVisible(true) + ensureLoop() + }, [ensureLoop]) + + const handleTouchMove = useCallback((e: React.TouchEvent) => { + e.preventDefault() + touchYRef.current = e.touches[0].clientY + }, []) + + const handleTouchEnd = useCallback(() => { + // 松手 → 跳转到最后聚焦的条目 + const idx = prevFocusIdxRef.current + if (idx >= 0 && idx < entries.length) { + onScrollToIndex(entries[idx].index) + } + isTouchingRef.current = false + touchYRef.current = null + prevFocusIdxRef.current = -1 + // 回弹动画继续跑,归零后 runLoop 会 setOverlayVisible(false) + ensureLoop() + }, [entries, onScrollToIndex, ensureLoop]) + + useEffect(() => { + return () => cancelAnimationFrame(rafIdRef.current) + }, []) + + return ( +
+ {/* 背景模糊 overlay */} + {overlayVisible && ( +
+ )} + + {/* 索引条 */} +
+ {entries.map((entry) => { + const isVisibleEntry = activeSet?.has(entry.messageId) ?? false + return ( +
+ {/* Label */} +
+ {truncate(entry.title, 14)} +
+ {/* Tick */} +
+
+ ) + })} +
+
+ ) +}) diff --git a/src/features/chat/ChatArea.tsx b/src/features/chat/ChatArea.tsx index 61f0f0bc..4798d8ba 100644 --- a/src/features/chat/ChatArea.tsx +++ b/src/features/chat/ChatArea.tsx @@ -42,6 +42,8 @@ export type ChatAreaHandle = { scrollToLastMessage: () => void /** 临时禁用自动滚动(用于 undo/redo) */ suppressAutoScroll: (duration?: number) => void + /** 滚动到指定索引的消息(用于目录导航) */ + scrollToMessageIndex: (index: number) => void } // 检查消息是否有可见内容 @@ -193,7 +195,20 @@ export const ChatArea = memo(forwardRef(({ setTimeout(() => { suppressScrollRef.current = false }, duration) - } + }, + scrollToMessageIndex: (index: number) => { + if (index >= 0 && index < visibleMessagesCountRef.current) { + // 临时禁用自动滚动,避免被拉回底部 + suppressScrollRef.current = true + setTimeout(() => { suppressScrollRef.current = false }, 1000) + + virtuosoRef.current?.scrollToIndex({ + index, + align: 'start', + behavior: 'smooth', + }) + } + }, })) // followOutput: 完全禁用,改用手动控制 diff --git a/src/features/chat/InputBox.tsx b/src/features/chat/InputBox.tsx index f35ede05..a2b13df6 100644 --- a/src/features/chat/InputBox.tsx +++ b/src/features/chat/InputBox.tsx @@ -7,6 +7,7 @@ import { InputFooter } from './input/InputFooter' import { UndoStatus } from './input/UndoStatus' import { useImageCompressor } from '../../hooks/useImageCompressor' import { keybindingStore, matchesKeybinding } from '../../store/keybindingStore' +import { useIsMobile } from '../../hooks' import type { ApiAgent } from '../../api/client' import type { Command } from '../../api/command' @@ -86,6 +87,9 @@ function InputBoxComponent({ const [slashQuery, setSlashQuery] = useState('') const [slashStartIndex, setSlashStartIndex] = useState(-1) + // 响应式 placeholder + const isMobile = useIsMobile() + // Refs const textareaRef = useRef(null) const inputContainerRef = useRef(null) @@ -599,7 +603,7 @@ function InputBoxComponent({ onKeyDown={handleKeyDown} onPaste={handlePaste} onScroll={handleScroll} - placeholder="Reply to Agent (type @ to mention, / for commands)" + placeholder={isMobile ? "Reply to Agent..." : "Reply to Agent (type @ to mention, / for commands)"} className="w-full resize-none focus:outline-none focus:ring-0 bg-transparent text-text-100 placeholder:text-text-400 custom-scrollbar" style={{ ...TEXT_STYLE, diff --git a/src/features/settings/SettingsDialog.tsx b/src/features/settings/SettingsDialog.tsx index 7fbc3090..64107189 100644 --- a/src/features/settings/SettingsDialog.tsx +++ b/src/features/settings/SettingsDialog.tsx @@ -9,6 +9,7 @@ import { } from '../../components/Icons' import { usePathMode, useServerStore, useIsMobile, useNotification } from '../../hooks' import { autoApproveStore } from '../../store' +import { themeStore } from '../../store/themeStore' import { KeybindingsSection } from './KeybindingsSection' import type { ThemeMode } from '../../hooks' import type { PathMode } from '../../utils/directoryUtils' @@ -18,7 +19,7 @@ import type { ServerConfig, ServerHealth } from '../../store/serverStore' // Types // ============================================ -type SettingsTab = 'general' | 'keybindings' +type SettingsTab = 'appearance' | 'general' | 'servers' | 'keybindings' interface SettingsDialogProps { isOpen: boolean @@ -38,7 +39,7 @@ interface SettingsDialogProps { } // ============================================ -// Toggle Switch +// Shared UI Components // ============================================ function Toggle({ enabled, onChange }: { enabled: boolean; onChange: () => void }) { @@ -57,10 +58,6 @@ function Toggle({ enabled, onChange }: { enabled: boolean; onChange: () => void ) } -// ============================================ -// SegmentedControl (3-way selector) -// ============================================ - interface SegmentedControlProps { value: T options: { value: T; label: string; icon?: React.ReactNode }[] @@ -108,10 +105,6 @@ function SegmentedControl({ value, options, onChange }: Segmen ) } -// ============================================ -// SettingRow - generic setting item -// ============================================ - interface SettingRowProps { label: string description?: string @@ -148,180 +141,50 @@ function Divider() { } // ============================================ -// Server Item +// Font Size Slider // ============================================ -function ServerItem({ server, health, isActive, onSelect, onDelete, onCheckHealth }: { - server: ServerConfig - health: ServerHealth | null - isActive: boolean - onSelect: () => void - onDelete: () => void - onCheckHealth: () => void -}) { - const statusIcon = () => { - if (!health || health.status === 'checking') return - if (health.status === 'online') return - if (health.status === 'unauthorized') return - return - } - - const statusTitle = () => { - if (!health) return 'Check health' - switch (health.status) { - case 'checking': return 'Checking...' - case 'online': return `Online (${health.latency}ms)${health.version ? ` v${health.version}` : ''}` - case 'unauthorized': return 'Invalid credentials' - case 'offline': return health.error || 'Offline' - case 'error': return health.error || 'Error' - default: return 'Unknown' - } - } +function FontSizeSlider({ value, onChange }: { value: number; onChange: (v: number) => void }) { + const min = 12 + const max = 24 + const pct = ((value - min) / (max - min)) * 100 return ( -
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect() }}} - > - -
-
- {server.name} - {isActive && } -
-
- {server.url} - {server.auth?.password && } +
+
+ A +
+ onChange(Number(e.target.value))} + className="w-full h-1.5 rounded-full appearance-none cursor-pointer + [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 + [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent-main-100 + [&::-webkit-slider-thumb]:shadow-md [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-white + [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:relative [&::-webkit-slider-thumb]:z-10 + [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full + [&::-moz-range-thumb]:bg-accent-main-100 [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-white + [&::-moz-range-thumb]:cursor-pointer [&::-moz-range-thumb]:shadow-md" + style={{ + background: `linear-gradient(to right, hsl(var(--accent-main-100)) 0%, hsl(var(--accent-main-100)) ${pct}%, hsl(var(--bg-300)) ${pct}%, hsl(var(--bg-300)) 100%)` + }} + />
+ A
- - {!server.isDefault && ( - - )} -
- ) -} - -// ============================================ -// Add Server Form -// ============================================ - -function AddServerForm({ onAdd, onCancel }: { - onAdd: (name: string, url: string, username?: string, password?: string) => void - onCancel: () => void -}) { - const [name, setName] = useState('') - const [url, setUrl] = useState('') - const [username, setUsername] = useState('') - const [password, setPassword] = useState('') - const [showAuth, setShowAuth] = useState(false) - const [error, setError] = useState('') - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault() - if (!name.trim()) { setError('Name required'); return } - if (!url.trim()) { setError('URL required'); return } - try { new URL(url) } catch { setError('Invalid URL'); return } - - onAdd( - name.trim(), - url.trim(), - password.trim() ? (username.trim() || 'opencode') : undefined, - password.trim() || undefined - ) - } - - // 检测是否跨域 - const isCrossOrigin = (() => { - if (!url.trim()) return false - try { - const serverUrl = new URL(url) - return serverUrl.origin !== window.location.origin - } catch { return false } - })() - - const inputCls = "w-full h-8 px-3 text-[13px] bg-bg-000 border border-border-200 rounded-md focus:outline-none focus:border-accent-main-100/50 text-text-100 placeholder:text-text-400" - - return ( -
-
- - { setName(e.target.value); setError('') }} - placeholder="My Server" className={inputCls} autoFocus /> +
+ {min}px + {value}px + {max}px
-
- - { setUrl(e.target.value); setError('') }} - placeholder="http://192.168.1.100:4096" className={`${inputCls} font-mono`} /> -
- - {/* Auth toggle */} - - - {showAuth && ( - <> -
- - { setUsername(e.target.value); setError('') }} - placeholder="opencode (default)" className={inputCls} /> -
-
- - { setPassword(e.target.value); setError('') }} - placeholder="OPENCODE_SERVER_PASSWORD" className={inputCls} /> -
- - {/* 跨域警告 */} - {isCrossOrigin && password.trim() && ( -
- Cross-origin + password may not work due to a backend CORS limitation - (#10047). - Consider deploying the UI on the same origin or starting the server without a password. -
- )} - -
- Credentials are stored in localStorage. For same-origin setups, the browser can handle auth natively without entering credentials here. -
- - )} - - {error &&

{error}

} -
- - -
- +
) } -// ============================================ -// General Settings -// ============================================ - // ============================================ // Theme Preset Card // ============================================ @@ -347,7 +210,6 @@ function PresetCard({ id, name, description, isActive, onClick }: { : 'border-border-200/50 hover:border-border-300 hover:bg-bg-100/50' }`} > - {/* Color preview swatch */}
| null>(null) - // Sync external changes useEffect(() => { setLocalValue(value) }, [value]) const handleChange = (newVal: string) => { @@ -389,25 +250,18 @@ function CustomCSSEditor({ value, onChange }: { value: string; onChange: (css: s /* Backgrounds */ --bg-000: 220 20% 98%; --bg-100: 220 15% 95%; - --bg-200: 220 12% 92%; - --bg-300: 220 10% 88%; - --bg-400: 220 8% 84%; /* Text */ --text-100: 220 15% 15%; - --text-200: 220 10% 35%; /* Accent */ - --accent-brand: 260 70% 55%; --accent-main-100: 260 70% 55%; }` return (
-
-
- Custom CSS applied globally. Use :root:root to override theme variables. -
+
+ Custom CSS applied globally. Use :root:root to override theme variables.