From e24b5f314df5112848f470988b01b139499a6277 Mon Sep 17 00:00:00 2001 From: limityan Date: Sun, 26 Apr 2026 12:04:23 +0800 Subject: [PATCH 1/4] fix(session): sort sessions by lastActiveAt to auto-promote on user message Include lastActiveAt in session sort priority so that sending a message moves the session to the top of the workspace session list. Priority: lastActiveAt > lastFinishedAt > createdAt --- .../src/flow_chat/utils/sessionOrdering.test.ts | 17 ++++++++++++++--- .../src/flow_chat/utils/sessionOrdering.ts | 8 ++++---- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/web-ui/src/flow_chat/utils/sessionOrdering.test.ts b/src/web-ui/src/flow_chat/utils/sessionOrdering.test.ts index b2a8f83a3..0915583b5 100644 --- a/src/web-ui/src/flow_chat/utils/sessionOrdering.test.ts +++ b/src/web-ui/src/flow_chat/utils/sessionOrdering.test.ts @@ -34,20 +34,31 @@ function createSession(overrides: Partial = {}): Session { } describe('sessionOrdering', () => { - it('uses createdAt for sessions without completed tasks', () => { + it('uses lastActiveAt when available', () => { + const session = createSession({ createdAt: 1234, lastActiveAt: 5678 }); + expect(getSessionSortTimestamp(session)).toBe(5678); + }); + + it('uses lastFinishedAt when lastActiveAt is missing', () => { + const session = createSession({ createdAt: 1234, lastFinishedAt: 9999 }); + expect(getSessionSortTimestamp(session)).toBe(9999); + }); + + it('uses createdAt as fallback', () => { const session = createSession({ createdAt: 1234 }); expect(getSessionSortTimestamp(session)).toBe(1234); }); - it('sorts sessions by lastFinishedAt before createdAt', () => { + it('sorts sessions by lastActiveAt before lastFinishedAt and createdAt', () => { const sessions = [ createSession({ sessionId: 'older-new', createdAt: 1000 }), createSession({ sessionId: 'completed', createdAt: 500, lastFinishedAt: 3000 }), + createSession({ sessionId: 'just-active', createdAt: 200, lastActiveAt: 5000 }), createSession({ sessionId: 'newest-new', createdAt: 2000 }), ]; const orderedIds = [...sessions].sort(compareSessionsForDisplay).map(session => session.sessionId); - expect(orderedIds).toEqual(['completed', 'newest-new', 'older-new']); + expect(orderedIds).toEqual(['just-active', 'completed', 'newest-new', 'older-new']); }); it('falls back to stable ordering when timestamps are equal', () => { diff --git a/src/web-ui/src/flow_chat/utils/sessionOrdering.ts b/src/web-ui/src/flow_chat/utils/sessionOrdering.ts index af089941a..532145eca 100644 --- a/src/web-ui/src/flow_chat/utils/sessionOrdering.ts +++ b/src/web-ui/src/flow_chat/utils/sessionOrdering.ts @@ -62,13 +62,13 @@ export function sessionBelongsToWorkspaceNavRow( return true; } -export function getSessionSortTimestamp(session: Pick): number { - return session.lastFinishedAt ?? session.createdAt; +export function getSessionSortTimestamp(session: Pick): number { + return session.lastActiveAt ?? session.lastFinishedAt ?? session.createdAt; } export function compareSessionsForDisplay( - a: Pick, - b: Pick + a: Pick, + b: Pick ): number { const timestampDiff = getSessionSortTimestamp(b) - getSessionSortTimestamp(a); if (timestampDiff !== 0) { From 4bca9bcc9da7c9d576e15ba9af547774a7344b5d Mon Sep 17 00:00:00 2001 From: limityan Date: Sun, 26 Apr 2026 12:52:08 +0800 Subject: [PATCH 2/4] feat(desktop): add devtools feature with interactive element inspector Add a `devtools` Cargo feature to the desktop app that enables: - Native webview DevTools (Ctrl/Cmd+Shift+J) - Interactive element inspector (Ctrl/Cmd+Shift+I) with hover highlight, tooltip, and click-to-capture for computed styles, CSS variables, box model, colors, and attributes The feature is automatically enabled in dev builds and release-fast profile builds, but never in release profile builds intended for end users. All debug code is conditionally compiled via `#[cfg]` to ensure zero overhead in production. New files: - src/apps/desktop/src/api/debug_api.rs - src/web-ui/src/infrastructure/debug/mainWindowInspector.ts - src/web-ui/src/infrastructure/debug/useDebugInspector.ts Updated: - Cargo.toml, package.json, theme.rs, lib.rs, App.tsx, AGENTS.md --- CONTRIBUTING.md | 18 + CONTRIBUTING_CN.md | 18 + package.json | 4 +- src/apps/desktop/AGENTS-CN.md | 9 + src/apps/desktop/AGENTS.md | 9 + src/apps/desktop/Cargo.toml | 7 + src/apps/desktop/src/api/debug_api.rs | 111 ++++++ src/apps/desktop/src/api/mod.rs | 1 + src/apps/desktop/src/lib.rs | 4 + src/apps/desktop/src/theme.rs | 6 +- src/web-ui/src/app/App.tsx | 4 + .../debug/mainWindowInspector.ts | 319 ++++++++++++++++++ .../infrastructure/debug/useDebugInspector.ts | 118 +++++++ 13 files changed, 623 insertions(+), 5 deletions(-) create mode 100644 src/apps/desktop/src/api/debug_api.rs create mode 100644 src/web-ui/src/infrastructure/debug/mainWindowInspector.ts create mode 100644 src/web-ui/src/infrastructure/debug/useDebugInspector.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bcad27d0c..7c9e3d58f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -46,6 +46,24 @@ pnpm run e2e:test > For the full script list, see [`package.json`](package.json). For agent-specific commands, verification, and architecture rules, see [`AGENTS.md`](AGENTS.md). +### Desktop debugging tools + +When working on desktop UI/UX, the `devtools` Cargo feature provides additional debugging capabilities. It is automatically enabled in `dev` builds and `release-fast` profile builds, but never in `release` builds for end users. + +| Shortcut | Action | +|---|---| +| `Cmd/Ctrl + Shift + I` | Toggle element inspector — hover to highlight elements, click to capture metadata | +| `Cmd/Ctrl + Shift + J` | Open native webview DevTools window | + +The element inspector injects a lightweight script into the main webview. When you click an element, it captures: +- Tag, id, class, CSS selector path +- Computed styles and CSS variables +- Box model (margin, padding, border) +- Color values (text, background, border) +- Element attributes + +Captured data is logged as structured JSON under the `bitfun::devtools` target. + ## Code Standards and Architecture Constraints ### Logging diff --git a/CONTRIBUTING_CN.md b/CONTRIBUTING_CN.md index 24d028843..cd5ae6f47 100644 --- a/CONTRIBUTING_CN.md +++ b/CONTRIBUTING_CN.md @@ -46,6 +46,24 @@ pnpm run e2e:test > 完整脚本列表见 [`package.json`](package.json)。agent 专用命令、验证与架构规则见 [`AGENTS.md`](AGENTS.md)。 +### 桌面端调试工具 + +开发桌面端 UI/UX 时,`devtools` Cargo feature 提供额外的调试能力。它在 `dev` 构建和 `release-fast` profile 构建中自动启用,但在面向最终用户的 `release` 构建中永不启用。 + +| 快捷键 | 功能 | +|---|---| +| `Cmd/Ctrl + Shift + I` | 切换元素检查器 — 悬停高亮元素,点击采集元数据 | +| `Cmd/Ctrl + Shift + J` | 打开原生 webview DevTools 窗口 | + +元素检查器向主 webview 注入一个轻量脚本。点击元素后会采集: +- 标签、id、class、CSS 选择器路径 +- Computed styles 和 CSS 变量 +- Box model(margin、padding、border) +- 颜色值(文本、背景、边框) +- 元素属性 + +采集的数据以结构化 JSON 形式输出到 `bitfun::devtools` 日志目标下。 + ## 代码规范与架构约束 ### 日志规范 diff --git a/package.json b/package.json index 945efa9cd..1f8acaaa1 100644 --- a/package.json +++ b/package.json @@ -32,10 +32,10 @@ "desktop:dev:raw": "cross-env-shell CI=true \"cd src/apps/desktop && tauri dev\"", "desktop:build": "node scripts/desktop-tauri-build.mjs", "desktop:build:fast": "node scripts/desktop-tauri-build.mjs --debug --no-bundle", - "desktop:build:release-fast": "node scripts/desktop-tauri-build.mjs --no-bundle -- --profile release-fast", + "desktop:build:release-fast": "node scripts/desktop-tauri-build.mjs --no-bundle -- --profile release-fast --features devtools", "desktop:build:exe": "node scripts/desktop-tauri-build.mjs --no-bundle", "desktop:build:nsis": "node scripts/desktop-tauri-build.mjs --bundles nsis", - "desktop:build:nsis:fast": "node scripts/desktop-tauri-build.mjs --bundles nsis -- --profile release-fast", + "desktop:build:nsis:fast": "node scripts/desktop-tauri-build.mjs --bundles nsis -- --profile release-fast --features devtools", "desktop:build:arm64": "node scripts/desktop-tauri-build.mjs --target aarch64-apple-darwin --bundles dmg", "desktop:build:x86_64": "node scripts/desktop-tauri-build.mjs --target x86_64-apple-darwin --bundles dmg", "desktop:build:linux": "node scripts/desktop-tauri-build.mjs", diff --git a/src/apps/desktop/AGENTS-CN.md b/src/apps/desktop/AGENTS-CN.md index 3539a189e..239ee278a 100644 --- a/src/apps/desktop/AGENTS-CN.md +++ b/src/apps/desktop/AGENTS-CN.md @@ -44,6 +44,15 @@ pnpm run desktop:build:fast `release-fast` profile(`Cargo.toml`):继承 `release`,但关闭 LTO、`codegen-units` 提高到 16、启用增量编译。编译速度显著提升,代价是二进制体积增大和边际运行时性能下降。 +## DevTools feature(模型规则) + +`devtools` Cargo feature 用于桌面端 UI/UX 调试。添加或修改调试相关代码时: + +- 所有调试专用 API 和 command 必须用 `#[cfg(any(debug_assertions, feature = "devtools"))]` 保护 +- 在 `#[cfg(not(any(debug_assertions, feature = "devtools")))]` 下提供 no-op stub,确保 command 始终可以注册到 `invoke_handler` +- 该 feature 通过 `--features devtools` 在 `dev` 构建和 `release-fast` profile 构建中自动启用 +- 面向最终用户的 `release` profile 构建中永不启用 + ## 验证 ```bash diff --git a/src/apps/desktop/AGENTS.md b/src/apps/desktop/AGENTS.md index e5dfbcb22..db24f9b10 100644 --- a/src/apps/desktop/AGENTS.md +++ b/src/apps/desktop/AGENTS.md @@ -44,6 +44,15 @@ pnpm run desktop:build:fast `release-fast` profile (`Cargo.toml`): inherits `release` but disables LTO, increases `codegen-units` to 16, enables incremental compilation. Significantly faster at the cost of binary size and marginal runtime performance. +## DevTools feature (model rule) + +The `devtools` Cargo feature exists for debugging UI/UX in the desktop app. When adding or modifying debug-related code: + +- Guard all debug-only APIs and commands with `#[cfg(any(debug_assertions, feature = "devtools"))]` +- Provide no-op stubs under `#[cfg(not(any(debug_assertions, feature = "devtools")))]` so commands can always be registered in `invoke_handler` +- The feature is enabled automatically in `dev` builds and `release-fast` profile builds via `--features devtools` +- Never enable in `release` profile builds intended for end users + ## Verification ```bash diff --git a/src/apps/desktop/Cargo.toml b/src/apps/desktop/Cargo.toml index e42f69dbf..21f210142 100644 --- a/src/apps/desktop/Cargo.toml +++ b/src/apps/desktop/Cargo.toml @@ -79,6 +79,13 @@ windows = { version = "0.61.3", features = [ ] } windows-core = "0.61.2" +[features] +default = [] +# Enable webview devtools and element inspector for development builds. +# Only active in debug builds or when explicitly requested via --features devtools. +# Never enabled in release profile intended for end users. +devtools = ["tauri/devtools"] + [target.'cfg(target_os = "linux")'.dependencies] atspi = "0.29" leptess = "0.14.0" diff --git a/src/apps/desktop/src/api/debug_api.rs b/src/apps/desktop/src/api/debug_api.rs new file mode 100644 index 000000000..d1a1de6ce --- /dev/null +++ b/src/apps/desktop/src/api/debug_api.rs @@ -0,0 +1,111 @@ +//! Debug API for desktop development. +//! +//! Provides element inspector, devtools control, and screenshot debugging. +//! +//! # Compilation guards +//! All public items in this module are guarded by `#[cfg(any(debug_assertions, feature = "devtools"))]`. +//! This ensures zero debug code is compiled into release builds intended for end users. + +use serde::Deserialize; + +#[cfg(any(debug_assertions, feature = "devtools"))] +use tauri::Manager; + +// --------------------------------------------------------------------------- +// Request / Response types +// --------------------------------------------------------------------------- + +/// Payload sent by the injected inspector script when user clicks an element. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DebugElementPickedRequest { + pub tag_name: String, + pub path: String, + pub id: Option, + pub class_name: Option, + pub text_content: String, + pub outer_html: String, + pub computed_styles: serde_json::Value, + pub css_variables: serde_json::Value, + pub color_info: serde_json::Value, + pub box_model: serde_json::Value, + pub attributes: serde_json::Value, +} + +// --------------------------------------------------------------------------- +// Commands +// --------------------------------------------------------------------------- + +/// Called by the injected inspector script when user clicks an element. +/// +/// Logs the full element information as structured JSON so developers can +/// inspect tag, classes, computed styles, colors, box-model, etc. +#[tauri::command] +#[cfg(any(debug_assertions, feature = "devtools"))] +pub async fn debug_element_picked(request: DebugElementPickedRequest) -> Result<(), String> { + let payload = serde_json::json!({ + "tag_name": request.tag_name, + "path": request.path, + "id": request.id, + "class_name": request.class_name, + "text_content": request.text_content, + "outer_html_preview": request.outer_html, + "computed_styles": request.computed_styles, + "css_variables": request.css_variables, + "color_info": request.color_info, + "box_model": request.box_model, + "attributes": request.attributes, + }); + + log::info!( + target: "bitfun::devtools", + "Element picked: {}", + serde_json::to_string_pretty(&payload).unwrap_or_default() + ); + + Ok(()) +} + +/// Open the native webview DevTools window for the main window. +#[tauri::command] +#[cfg(any(debug_assertions, feature = "devtools"))] +pub async fn debug_open_devtools(app: tauri::AppHandle) -> Result<(), String> { + let window = app + .get_webview_window("main") + .ok_or("Main window not found")?; + window.open_devtools(); + Ok(()) +} + +/// Close the native webview DevTools window for the main window. +#[tauri::command] +#[cfg(any(debug_assertions, feature = "devtools"))] +pub async fn debug_close_devtools(app: tauri::AppHandle) -> Result<(), String> { + let window = app + .get_webview_window("main") + .ok_or("Main window not found")?; + window.close_devtools(); + Ok(()) +} + +// --------------------------------------------------------------------------- +// No-op stubs for release builds (so the module always compiles) +// --------------------------------------------------------------------------- + +#[tauri::command] +#[cfg(not(any(debug_assertions, feature = "devtools")))] +pub async fn debug_element_picked(_request: DebugElementPickedRequest) -> Result<(), String> { + Err("DevTools not available in release builds".to_string()) +} + +#[tauri::command] +#[cfg(not(any(debug_assertions, feature = "devtools")))] +pub async fn debug_open_devtools(_app: tauri::AppHandle) -> Result<(), String> { + Err("DevTools not available in release builds".to_string()) +} + +#[tauri::command] +#[cfg(not(any(debug_assertions, feature = "devtools")))] +pub async fn debug_close_devtools(_app: tauri::AppHandle) -> Result<(), String> { + Err("DevTools not available in release builds".to_string()) +} diff --git a/src/apps/desktop/src/api/mod.rs b/src/apps/desktop/src/api/mod.rs index 093d3227b..b3f7e0c57 100644 --- a/src/apps/desktop/src/api/mod.rs +++ b/src/apps/desktop/src/api/mod.rs @@ -14,6 +14,7 @@ pub mod computer_use_api; pub mod config_api; pub mod context_upload_api; pub mod cron_api; +pub mod debug_api; pub mod diff_api; pub mod dto; pub mod editor_ai_api; diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 36a9a0c69..316abfb96 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -770,6 +770,10 @@ pub async fn run() { api::announcement_api::never_show_announcement, api::announcement_api::trigger_announcement, api::announcement_api::get_announcement_tips, + // Debug API (no-op stubs in release builds) + api::debug_api::debug_element_picked, + api::debug_api::debug_open_devtools, + api::debug_api::debug_close_devtools, ]) .run(tauri::generate_context!()); if let Err(e) = run_result { diff --git a/src/apps/desktop/src/theme.rs b/src/apps/desktop/src/theme.rs index 418c09595..91a690199 100644 --- a/src/apps/desktop/src/theme.rs +++ b/src/apps/desktop/src/theme.rs @@ -278,17 +278,17 @@ pub fn create_main_window(app_handle: &tauri::AppHandle) { match builder.build() { Ok(window) => { - #[cfg(debug_assertions)] + #[cfg(any(debug_assertions, feature = "devtools"))] { if std::env::var("BITFUN_OPEN_DEVTOOLS") .map(|v| v == "1") .unwrap_or(false) { - window.open_devtools(); + let _ = window.open_devtools(); } } - #[cfg(not(debug_assertions))] + #[cfg(not(any(debug_assertions, feature = "devtools")))] let _ = window; } Err(e) => { diff --git a/src/web-ui/src/app/App.tsx b/src/web-ui/src/app/App.tsx index d4c7afe17..acfd3a893 100644 --- a/src/web-ui/src/app/App.tsx +++ b/src/web-ui/src/app/App.tsx @@ -13,6 +13,7 @@ import { createLogger } from '@/shared/utils/logger'; import { useWorkspaceContext } from '../infrastructure/contexts/WorkspaceContext'; import SplashScreen from './components/SplashScreen/SplashScreen'; import { useGlobalSceneShortcuts } from './hooks/useGlobalSceneShortcuts'; +import { useDebugInspector } from '@/infrastructure/debug/useDebugInspector'; // Toolbar Mode import { ToolbarModeProvider } from '../flow_chat'; @@ -189,6 +190,9 @@ function App() { // Top SceneBar: Mod+Alt+1..9 / Mod+Alt+PageUp/PageDown useGlobalSceneShortcuts(); + // Debug inspector shortcuts (desktop devtools only) + useDebugInspector(); + // Unified layout via a single AppLayout return ( diff --git a/src/web-ui/src/infrastructure/debug/mainWindowInspector.ts b/src/web-ui/src/infrastructure/debug/mainWindowInspector.ts new file mode 100644 index 000000000..b7cd8342c --- /dev/null +++ b/src/web-ui/src/infrastructure/debug/mainWindowInspector.ts @@ -0,0 +1,319 @@ +/* eslint-disable no-useless-escape -- regex patterns inside template strings are eval'd as JS at runtime, so \\s becomes \s */ +/** + * Main-window element inspector for desktop debugging. + * + * Injected into the main BitFun webview to provide interactive element + * inspection without relying on external DevTools. + * + * Activation: Cmd/Ctrl + Shift + I (or programmatic via toggleInspector()) + * Exit: Escape key + * + * Features: + * - Hover highlight with animated overlay + * - Tooltip showing tag, id, class, dimensions, color, background + * - Click to capture full element metadata (computed styles, CSS vars, + * box model, colors, attributes) and send to Rust via Tauri command + * - Zero performance impact when inactive + */ + +const INSPECTOR_SCRIPT_BODY = /* js */ ` +(function () { + if (window.__bitfun_main_inspector_active) { + window.__bitfun_main_inspector_cancel && window.__bitfun_main_inspector_cancel(); + return; + } + window.__bitfun_main_inspector_active = true; + + // ── overlay elements ───────────────────────────────────────────────────── + var overlay = document.createElement('div'); + overlay.style.cssText = [ + 'position:fixed', + 'border:2px solid #3b82f6', + 'background:rgba(59,130,246,0.12)', + 'pointer-events:none', + 'z-index:2147483646', + 'box-sizing:border-box', + 'display:none', + 'border-radius:2px', + 'transition:top 0.05s,left 0.05s,width 0.05s,height 0.05s', + ].join(';'); + + var tooltip = document.createElement('div'); + tooltip.style.cssText = [ + 'position:fixed', + 'background:rgba(15,23,42,0.95)', + 'color:#e2e8f0', + 'padding:8px 12px', + 'border-radius:6px', + 'font-size:11px', + 'font-family:ui-monospace,SFMono-Regular,Menlo,monospace', + 'z-index:2147483647', + 'pointer-events:none', + 'display:none', + 'max-width:520px', + 'box-shadow:0 4px 12px rgba(0,0,0,0.5)', + 'line-height:1.5', + 'border:1px solid rgba(59,130,246,0.4)', + ].join(';'); + + var sizeLabel = document.createElement('div'); + sizeLabel.style.cssText = [ + 'position:fixed', + 'background:#3b82f6', + 'color:#fff', + 'padding:2px 6px', + 'border-radius:3px', + 'font-size:10px', + 'font-family:monospace', + 'z-index:2147483647', + 'pointer-events:none', + 'display:none', + 'white-space:nowrap', + ].join(';'); + + document.documentElement.appendChild(overlay); + document.documentElement.appendChild(tooltip); + document.documentElement.appendChild(sizeLabel); + + var hoveredEl = null; + + // ── helpers ────────────────────────────────────────────────────────────── + function cssPath(el) { + var parts = []; + var cur = el; + while (cur && cur.nodeType === 1 && cur !== document.documentElement) { + var seg = cur.tagName.toLowerCase(); + if (cur.id) { + try { seg += '#' + CSS.escape(cur.id); } catch (e) { seg += '#' + cur.id; } + parts.unshift(seg); + break; + } + if (cur.parentElement) { + var siblings = Array.prototype.filter.call( + cur.parentElement.children, + function (s) { return s.tagName === cur.tagName; } + ); + if (siblings.length > 1) { + seg += ':nth-of-type(' + (siblings.indexOf(cur) + 1) + ')'; + } + } + var classes = Array.prototype.slice.call(cur.classList, 0, 3).join('.'); + if (classes) seg += '.' + classes; + parts.unshift(seg); + cur = cur.parentElement; + } + return parts.join(' > ') || 'html'; + } + + function getComputedStyles(el) { + var cs = window.getComputedStyle(el); + return { + display: cs.display, + position: cs.position, + width: cs.width, + height: cs.height, + margin: cs.margin, + padding: cs.padding, + border: cs.border, + borderRadius: cs.borderRadius, + backgroundColor: cs.backgroundColor, + color: cs.color, + fontSize: cs.fontSize, + fontFamily: cs.fontFamily, + zIndex: cs.zIndex, + opacity: cs.opacity, + overflow: cs.overflow, + boxShadow: cs.boxShadow, + }; + } + + function getCSSVariables(el) { + var vars = {}; + var cs = window.getComputedStyle(el); + for (var i = 0; i < cs.length; i++) { + var prop = cs[i]; + if (prop.startsWith('--')) { + vars[prop] = cs.getPropertyValue(prop); + } + } + return vars; + } + + function getColorInfo(el) { + var cs = window.getComputedStyle(el); + return { + color: cs.color, + backgroundColor: cs.backgroundColor, + borderColor: cs.borderColor, + borderTopColor: cs.borderTopColor, + borderRightColor: cs.borderRightColor, + borderBottomColor: cs.borderBottomColor, + borderLeftColor: cs.borderLeftColor, + outlineColor: cs.outlineColor, + }; + } + + function getBoxModel(el) { + var rect = el.getBoundingClientRect(); + var cs = window.getComputedStyle(el); + return { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + top: rect.top, + left: rect.left, + margin: { + top: cs.marginTop, + right: cs.marginRight, + bottom: cs.marginBottom, + left: cs.marginLeft, + }, + padding: { + top: cs.paddingTop, + right: cs.paddingRight, + bottom: cs.paddingBottom, + left: cs.paddingLeft, + }, + border: { + top: cs.borderTopWidth, + right: cs.borderRightWidth, + bottom: cs.borderBottomWidth, + left: cs.borderLeftWidth, + }, + }; + } + + function tooltipContent(el) { + var tag = el.tagName.toLowerCase(); + var id = el.id ? '#' + el.id : ''; + var cls = el.classList.length + ? '.' + Array.prototype.slice.call(el.classList, 0, 2).join('.') + : ''; + var rect = el.getBoundingClientRect(); + var cs = window.getComputedStyle(el); + return tag + id + cls + '\n' + + 'size: ' + Math.round(rect.width) + ' x ' + Math.round(rect.height) + '\n' + + 'color: ' + cs.color + '\n' + + 'bg: ' + cs.backgroundColor; + } + + function updateOverlay(el) { + if (!el) { + overlay.style.display = 'none'; + tooltip.style.display = 'none'; + sizeLabel.style.display = 'none'; + return; + } + var rect = el.getBoundingClientRect(); + overlay.style.display = 'block'; + overlay.style.top = rect.top + 'px'; + overlay.style.left = rect.left + 'px'; + overlay.style.width = rect.width + 'px'; + overlay.style.height = rect.height + 'px'; + + tooltip.innerHTML = tooltipContent(el).replace(/\n/g, '
'); + tooltip.style.display = 'block'; + var ty = rect.top - tooltip.offsetHeight - 8; + if (ty < 4) ty = rect.bottom + 8; + var tx = rect.left; + if (tx + tooltip.offsetWidth > window.innerWidth) { + tx = window.innerWidth - tooltip.offsetWidth - 4; + } + if (tx < 4) tx = 4; + tooltip.style.top = ty + 'px'; + tooltip.style.left = tx + 'px'; + + sizeLabel.textContent = Math.round(rect.width) + ' x ' + Math.round(rect.height); + sizeLabel.style.display = 'block'; + sizeLabel.style.top = (rect.bottom + 4) + 'px'; + sizeLabel.style.left = rect.left + 'px'; + } + + function invokeTauri(cmd, payload) { + if (window.__TAURI_INTERNALS__ && window.__TAURI_INTERNALS__.invoke) { + window.__TAURI_INTERNALS__.invoke(cmd, { request: payload }).catch(function(){}); + } + } + + // ── cleanup ────────────────────────────────────────────────────────────── + function cleanup() { + document.removeEventListener('mouseover', onMouseOver, true); + document.removeEventListener('click', onClick, true); + document.removeEventListener('keydown', onKeyDown, true); + try { overlay.parentNode && overlay.parentNode.removeChild(overlay); } catch (e) {} + try { tooltip.parentNode && tooltip.parentNode.removeChild(tooltip); } catch (e) {} + try { sizeLabel.parentNode && sizeLabel.parentNode.removeChild(sizeLabel); } catch (e) {} + delete window.__bitfun_main_inspector_active; + delete window.__bitfun_main_inspector_cancel; + } + + // ── event handlers ─────────────────────────────────────────────────────── + function onMouseOver(e) { + var el = e.target; + if (el === overlay || el === tooltip || el === sizeLabel) return; + hoveredEl = el; + updateOverlay(el); + } + + function onClick(e) { + if (!hoveredEl) return; + e.preventDefault(); + e.stopPropagation(); + + var data = { + tagName: hoveredEl.tagName.toLowerCase(), + path: cssPath(hoveredEl), + id: hoveredEl.id || null, + className: hoveredEl.className || null, + textContent: (hoveredEl.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 500), + outerHTML: (hoveredEl.outerHTML || '').slice(0, 2000), + computedStyles: getComputedStyles(hoveredEl), + cssVariables: getCSSVariables(hoveredEl), + colorInfo: getColorInfo(hoveredEl), + boxModel: getBoxModel(hoveredEl), + attributes: (function() { + var attrs = {}; + for (var i = 0; i < hoveredEl.attributes.length; i++) { + attrs[hoveredEl.attributes[i].name] = hoveredEl.attributes[i].value; + } + return attrs; + })(), + }; + + invokeTauri('debug_element_picked', data); + + overlay.style.borderColor = '#22c55e'; + overlay.style.background = 'rgba(34,197,94,0.18)'; + setTimeout(function () { + overlay.style.borderColor = '#3b82f6'; + overlay.style.background = 'rgba(59,130,246,0.12)'; + }, 400); + } + + function onKeyDown(e) { + if (e.key === 'Escape') { + cleanup(); + } + } + + window.__bitfun_main_inspector_cancel = cleanup; + + document.addEventListener('mouseover', onMouseOver, true); + document.addEventListener('click', onClick, true); + document.addEventListener('keydown', onKeyDown, true); +})(); +`; + +/** Returns the JavaScript string to eval() inside the main webview. */ +export function createMainWindowInspectorScript(): string { + return INSPECTOR_SCRIPT_BODY; +} + +/** Script to cancel an active inspector session. */ +export const CANCEL_MAIN_WINDOW_INSPECTOR_SCRIPT = + `if (window.__bitfun_main_inspector_cancel) { window.__bitfun_main_inspector_cancel(); }`; + +/** Check whether the inspector is currently active in the page. */ +export const IS_INSPECTOR_ACTIVE_SCRIPT = + `typeof window.__bitfun_main_inspector_active !== 'undefined' && window.__bitfun_main_inspector_active`; diff --git a/src/web-ui/src/infrastructure/debug/useDebugInspector.ts b/src/web-ui/src/infrastructure/debug/useDebugInspector.ts new file mode 100644 index 000000000..e19561b15 --- /dev/null +++ b/src/web-ui/src/infrastructure/debug/useDebugInspector.ts @@ -0,0 +1,118 @@ +/** + * Desktop debug inspector hook. + * + * Provides Cmd/Ctrl + Shift + I shortcut to toggle the interactive element + * inspector in the main webview. Only active in development or when the + * desktop app is built with the `devtools` feature. + * + * The inspector is injected via `eval()` into the current page, so it works + * without any server-side changes and has zero overhead when inactive. + */ + +import { useCallback } from 'react'; +import { useShortcut } from '@/infrastructure/hooks/useShortcut'; +import { createLogger } from '@/shared/utils/logger'; +import { + createMainWindowInspectorScript, + CANCEL_MAIN_WINDOW_INSPECTOR_SCRIPT, + IS_INSPECTOR_ACTIVE_SCRIPT, +} from './mainWindowInspector'; + +const log = createLogger('DebugInspector'); + +/** Detect whether we are running inside a Tauri desktop webview with devtools available. */ +function isDevToolsAvailable(): boolean { + // In a standard web build (non-Tauri) the inspector is useless because we + // already have browser DevTools. Only enable in the desktop webview. + if (typeof window === 'undefined') return false; + if (!('__TAURI__' in window)) return false; + + // The backend only exposes debug commands when compiled with devtools feature + // or in debug builds. We optimistically enable the shortcut here; the invoke + // will gracefully fail if the backend does not support it. + return true; +} + +/** Toggle the element inspector by eval-ing the inspector script into the page. */ +async function toggleInspector(): Promise { + try { + // Check if already active + const isActive = await evalInPage(IS_INSPECTOR_ACTIVE_SCRIPT); + if (isActive) { + await evalInPage(CANCEL_MAIN_WINDOW_INSPECTOR_SCRIPT); + log.info('Element inspector deactivated'); + return; + } + + // Inject and activate + const script = createMainWindowInspectorScript(); + await evalInPage(script); + log.info('Element inspector activated — hover to highlight, click to capture, Escape to exit'); + } catch (error) { + log.error('Failed to toggle element inspector', error); + } +} + +/** Eval a JS snippet in the current page context. */ +async function evalInPage(script: string): Promise { + // We use the Function constructor to run in the page's global scope + // rather than the current module scope. The script may be a void IIFE, + // so we wrap it to ensure it is evaluated as an expression. + const fn = new Function(script); + return fn() as T; +} + +/** Open the native webview DevTools window. */ +async function openNativeDevTools(): Promise { + try { + const { invoke } = await import('@tauri-apps/api/core'); + await invoke('debug_open_devtools'); + log.info('Native DevTools opened'); + } catch (error) { + log.error('Failed to open native DevTools', error); + } +} + +/** + * Register debug shortcuts when running in a Tauri desktop environment. + * + * Shortcuts: + * Cmd/Ctrl + Shift + I → Toggle element inspector + * Cmd/Ctrl + Shift + J → Open native DevTools + */ +export function useDebugInspector(): void { + const available = isDevToolsAvailable(); + + const handleToggleInspector = useCallback(() => { + void toggleInspector(); + }, []); + + const handleOpenDevTools = useCallback(() => { + void openNativeDevTools(); + }, []); + + // Ctrl/Cmd + Shift + I — toggle element inspector + // ctrl: true maps to Cmd on macOS and Ctrl on Windows/Linux (handled by ShortcutManager) + useShortcut( + 'debug.toggleInspector', + { key: 'i', ctrl: true, shift: true, scope: 'app', allowInInput: true }, + handleToggleInspector, + { + enabled: available, + priority: 100, + description: 'Toggle element inspector', + } + ); + + // Ctrl/Cmd + Shift + J — open native DevTools + useShortcut( + 'debug.openDevTools', + { key: 'j', ctrl: true, shift: true, scope: 'app', allowInInput: true }, + handleOpenDevTools, + { + enabled: available, + priority: 100, + description: 'Open native DevTools', + } + ); +} From 887657064b91a36156c073567832d479884ff8ea Mon Sep 17 00:00:00 2001 From: limityan Date: Sun, 26 Apr 2026 13:10:13 +0800 Subject: [PATCH 3/4] feat(deep-review): improve action bar UX with priority grouping and dynamic layout - Group remediation items by priority (must_fix/should_improve/needs_decision/verification) - Add per-group select-all toggle with colored headers - Add hint text explaining read-only default behavior - Increase overall size for better high-DPI visibility - Use ResizeObserver to dynamically adjust body padding and prevent content overlap - Add ungrouped locale key for fallback items Generated with BitFun Co-Authored-By: BitFun --- .../flow_chat/components/FlowTextBlock.scss | 4 + .../components/btw/BtwSessionPanel.scss | 23 +- .../components/btw/BtwSessionPanel.tsx | 40 ++- .../components/btw/DeepReviewActionBar.scss | 298 ++++++++++-------- .../components/btw/DeepReviewActionBar.tsx | 126 ++++++-- .../components/modern/ExploreRegion.scss | 2 +- .../components/modern/SubagentItems.scss | 2 +- .../modern/TerminalGroupRenderer.scss | 4 +- .../store/deepReviewActionBarStore.ts | 23 ++ .../flow_chat/tool-cards/BaseToolCard.scss | 2 +- .../tool-cards/ModelThinkingDisplay.scss | 5 +- .../flow_chat/tool-cards/TaskToolDisplay.tsx | 40 ++- .../flow_chat/utils/codeReviewRemediation.ts | 2 +- src/web-ui/src/locales/en-US/flow-chat.json | 3 + src/web-ui/src/locales/zh-CN/flow-chat.json | 3 + src/web-ui/src/locales/zh-TW/flow-chat.json | 8 + .../src/shared/services/reviewTeamService.ts | 31 ++ 17 files changed, 443 insertions(+), 173 deletions(-) diff --git a/src/web-ui/src/flow_chat/components/FlowTextBlock.scss b/src/web-ui/src/flow_chat/components/FlowTextBlock.scss index 5e09c55ae..89da22b11 100644 --- a/src/web-ui/src/flow_chat/components/FlowTextBlock.scss +++ b/src/web-ui/src/flow_chat/components/FlowTextBlock.scss @@ -14,6 +14,10 @@ margin: 0 0 0.6rem 0; font-size: var(--flowchat-font-size-base); line-height: 1.65; + /* Transparent border to match the box-sizing of bordered cards (BaseToolCard, + * TerminalRegion, etc.) so that text and card content share the same left edge. */ + border: 1px solid transparent; + box-sizing: border-box; /* * Markdown rendering inside a flow text block: diff --git a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.scss b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.scss index 56da1cb40..7f8ffddc6 100644 --- a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.scss +++ b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.scss @@ -136,21 +136,26 @@ padding: 12px 16px; display: block; - &::after { - content: ''; - display: block; - height: 140px; - min-height: 140px; - width: 100%; - pointer-events: none; - } - .virtual-item-wrapper { width: 100%; display: block; } } + &__action-bar-wrapper { + position: absolute; + bottom: 0; + left: 0; + right: 0; + z-index: 10; + padding: 0 14px 14px; + pointer-events: none; + + > * { + pointer-events: auto; + } + } + &__empty-state { display: flex; align-items: center; diff --git a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx index a5a98b761..0422140d7 100644 --- a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx +++ b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx @@ -70,6 +70,8 @@ export const BtwSessionPanel: React.FC = ({ const [stoppingReview, setStoppingReview] = useState(false); const [showScrollToBottom, setShowScrollToBottom] = useState(false); const scrollContainerRef = useRef(null); + const actionBarRef = useRef(null); + const [actionBarHeight, setActionBarHeight] = useState(0); const shouldAutoScrollRef = useRef(true); useEffect(() => { @@ -358,6 +360,32 @@ export const BtwSessionPanel: React.FC = ({ } }, [childSession, childSessionId, parentSessionId, isReviewSession, isDeepReview]); + // Observe action bar height to adjust body padding dynamically + useEffect(() => { + if (!showReviewActionBar) { + setActionBarHeight(0); + return; + } + + const el = actionBarRef.current; + if (!el) return; + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + const h = entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height; + setActionBarHeight(h); + } + }); + + observer.observe(el); + // Initial measurement + setActionBarHeight(el.getBoundingClientRect().height); + + return () => { + observer.disconnect(); + }; + }, [showReviewActionBar]); + const btwOrigin = childSession?.btwOrigin; const parentLabel = resolveSessionTitle(parentSession, t('btw.parent')); const backTooltip = btwOrigin?.parentTurnIndex @@ -473,7 +501,11 @@ export const BtwSessionPanel: React.FC = ({ -
+
0 ? { paddingBottom: `${actionBarHeight + 20}px` } : undefined} + > {virtualItems.length === 0 ? (
{t('session.empty')}
) : ( @@ -497,7 +529,11 @@ export const BtwSessionPanel: React.FC = ({ onClick={handleScrollToBottom} className="btw-session-panel__scroll-to-bottom" /> - {showReviewActionBar && } + {showReviewActionBar && ( +
+ +
+ )}
); diff --git a/src/web-ui/src/flow_chat/components/btw/DeepReviewActionBar.scss b/src/web-ui/src/flow_chat/components/btw/DeepReviewActionBar.scss index 2fe3facc3..19c4220ca 100644 --- a/src/web-ui/src/flow_chat/components/btw/DeepReviewActionBar.scss +++ b/src/web-ui/src/flow_chat/components/btw/DeepReviewActionBar.scss @@ -1,89 +1,78 @@ .deep-review-action-bar { position: absolute; - bottom: 12px; - left: 12px; - right: 12px; - z-index: 20; - padding: 12px 16px; - border: 1px solid var(--border-base); + bottom: 14px; + left: 14px; + right: 14px; + z-index: 10; border-radius: 12px; - background: var(--color-bg-elevated); - box-shadow: var(--shadow-base); + padding: 16px 18px; + max-height: 520px; display: flex; flex-direction: column; - gap: 10px; - animation: deep-review-action-bar__slide-up 0.2s ease-out; - - // ---- variant backgrounds ---- + gap: 12px; + font-size: 13px; + line-height: 1.5; + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(255, 255, 255, 0.06); + overflow: hidden; + transition: transform 0.3s ease, opacity 0.3s ease; + + /* Variants */ &--success { - background: linear-gradient( - 180deg, - color-mix(in srgb, var(--color-bg-elevated) 96%, transparent), - color-mix(in srgb, var(--color-success, #22c55e) 6%, var(--color-bg-primary)), - ); - border-color: color-mix(in srgb, var(--color-success, #22c55e) 40%, var(--border-base)); + background: color-mix(in srgb, var(--color-success, #22c55e) 10%, var(--color-bg-secondary)); + border: 1px solid color-mix(in srgb, var(--color-success, #22c55e) 22%, transparent); } &--loading { - background: linear-gradient( - 180deg, - color-mix(in srgb, var(--color-bg-elevated) 96%, transparent), - color-mix(in srgb, var(--color-accent-500, #60a5fa) 6%, var(--color-bg-primary)), - ); - border-color: color-mix(in srgb, var(--color-accent-500, #60a5fa) 40%, var(--border-base)); + background: color-mix(in srgb, var(--color-accent-500, #60a5fa) 10%, var(--color-bg-secondary)); + border: 1px solid color-mix(in srgb, var(--color-accent-500, #60a5fa) 22%, transparent); } &--error { - background: linear-gradient( - 180deg, - color-mix(in srgb, var(--color-bg-elevated) 96%, transparent), - color-mix(in srgb, var(--color-error, #ef4444) 6%, var(--color-bg-primary)), - ); - border-color: color-mix(in srgb, var(--color-error, #ef4444) 40%, var(--border-base)); + background: color-mix(in srgb, var(--color-error, #ef4444) 10%, var(--color-bg-secondary)); + border: 1px solid color-mix(in srgb, var(--color-error, #ef4444) 22%, transparent); } &--warning { - background: linear-gradient( - 180deg, - color-mix(in srgb, var(--color-bg-elevated) 96%, transparent), - color-mix(in srgb, var(--color-warning, #f59e0b) 6%, var(--color-bg-primary)), - ); - border-color: color-mix(in srgb, var(--color-warning, #f59e0b) 40%, var(--border-base)); + background: color-mix(in srgb, var(--color-warning, #f59e0b) 10%, var(--color-bg-secondary)); + border: 1px solid color-mix(in srgb, var(--color-warning, #f59e0b) 22%, transparent); } &--info { - background: var(--color-bg-elevated); + background: var(--color-bg-secondary); + border: 1px solid var(--border-base); } - // ---- close button ---- + /* Close button */ &__close { position: absolute; - top: 6px; - right: 8px; + top: 10px; + right: 10px; display: flex; align-items: center; justify-content: center; - width: 22px; - height: 22px; - border: none; + width: 28px; + height: 28px; border-radius: 6px; background: transparent; + border: none; color: var(--color-text-muted); cursor: pointer; - transition: background 0.12s, color 0.12s; + transition: background 0.2s, color 0.2s; &:hover { - background: color-mix(in srgb, var(--color-text-muted) 12%, transparent); + background: var(--element-bg-soft); color: var(--color-text-primary); } } - // ---- status header ---- + /* Status header */ &__status { display: flex; align-items: center; gap: 8px; - min-width: 0; + padding-right: 32px; } &__icon { @@ -95,7 +84,7 @@ &--loading { color: var(--color-accent-500, #60a5fa); - animation: deep-review-action-bar__spin 1s linear infinite; + animation: deep-review-action-bar-spin 1s linear infinite; } &--error { @@ -108,197 +97,258 @@ } &__status-title { - font-size: 13px; - font-weight: 700; + font-weight: 600; + font-size: 14px; color: var(--color-text-primary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; } &__error-message { + margin-left: auto; font-size: 12px; color: var(--color-error, #ef4444); - margin-left: 4px; + max-width: 50%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - // ---- remediation selection ---- + /* Remediation section */ &__remediation { display: flex; flex-direction: column; - gap: 6px; + gap: 8px; } &__remediation-toggle { display: flex; align-items: center; - gap: 6px; - background: none; - border: none; - padding: 2px 0; + gap: 8px; + padding: 6px 8px; + border-radius: 8px; + background: transparent; + border: 1px solid transparent; + color: var(--color-text-primary); + font-size: 13px; + font-weight: 500; cursor: pointer; - color: var(--color-text-secondary); - font-size: 12px; - width: 100%; - text-align: left; + transition: background 0.2s, border-color 0.2s; + width: fit-content; &:hover { - color: var(--color-text-primary); + background: var(--element-bg-soft); + border-color: var(--border-base); + } + + .checkbox { + pointer-events: none; } } &__remediation-label { flex: 1; - min-width: 0; } &__remediation-list { - max-height: 160px; + display: flex; + flex-direction: column; + gap: 10px; + max-height: 260px; overflow-y: auto; - padding: 6px 8px; + padding-right: 4px; + } + + /* Remediation group */ + &__remediation-group { + display: flex; + flex-direction: column; + gap: 2px; + border: 1px solid var(--border-base); border-radius: 8px; - background: color-mix(in srgb, var(--color-bg-primary) 60%, transparent); + overflow: hidden; + } + + &__remediation-group-header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + background: var(--element-bg-subtle); + border: none; + cursor: pointer; + width: 100%; + text-align: left; + transition: background 0.2s; + font-size: 13px; + color: var(--color-text-primary); + + &:hover { + background: var(--element-bg-soft); + } + + .checkbox { + pointer-events: none; + } + } + + &__remediation-group-title { + font-weight: 600; + flex: 1; + } + + &__remediation-group-count { + font-size: 11px; + color: var(--color-text-muted); + font-variant-numeric: tabular-nums; + } + + &__remediation-group-items { display: flex; flex-direction: column; - gap: 4px; } &__remediation-item { display: flex; align-items: flex-start; - gap: 6px; - font-size: 12px; - line-height: 1.4; - color: var(--color-text-secondary); + gap: 8px; + padding: 7px 10px 7px 38px; cursor: pointer; - padding: 2px 0; + transition: background 0.15s; + border-bottom: 1px solid var(--border-base); + + &:last-child { + border-bottom: none; + } &:hover { - color: var(--color-text-primary); + background: var(--element-bg-subtle); } } &__remediation-text { flex: 1; min-width: 0; - overflow: hidden; - text-overflow: ellipsis; + font-size: 12px; + line-height: 1.5; + color: var(--color-text-secondary); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; + overflow: hidden; } &__remediation-tag { display: inline-flex; align-items: center; - margin-right: 6px; - padding: 0 5px; - border-radius: 5px; - background: color-mix(in srgb, var(--color-warning, #f59e0b) 16%, transparent); - color: color-mix(in srgb, var(--color-warning, #f59e0b) 82%, var(--color-text-primary)); + padding: 1px 6px; + border-radius: 4px; font-size: 10px; - font-weight: 600; - line-height: 16px; + font-weight: 500; + color: var(--color-accent-500, #60a5fa); + background: color-mix(in srgb, var(--color-accent-500, #60a5fa) 12%, transparent); + margin-right: 6px; + vertical-align: middle; + flex-shrink: 0; + } + + /* Hint texts */ + &__remediation-hint { + font-size: 11px; + color: var(--color-text-muted); + line-height: 1.5; + padding: 0 2px; } &__empty-selection { - color: color-mix(in srgb, var(--color-warning, #f59e0b) 80%, var(--color-text-secondary)); + padding: 8px 10px; + border-radius: 6px; + background: var(--element-bg-subtle); font-size: 12px; - line-height: 1.45; + color: var(--color-text-muted); + text-align: center; } - // ---- no issues found ---- + /* No issues found */ &__no-issues { display: flex; align-items: center; gap: 8px; - padding: 8px 10px; + padding: 10px 12px; border-radius: 8px; - background: color-mix(in srgb, var(--color-success, #22c55e) 8%, transparent); - color: var(--color-text-primary); - font-size: 13px; - line-height: 1.45; + background: var(--element-bg-subtle); } &__no-issues-icon { - flex-shrink: 0; color: var(--color-success, #22c55e); + flex-shrink: 0; } &__no-issues-text { - font-weight: 500; + font-size: 13px; + color: var(--color-text-secondary); } - // ---- custom instructions ---- + /* Custom instructions */ &__custom { display: flex; flex-direction: column; - gap: 6px; + gap: 8px; } &__custom-toggle { - display: flex; + display: inline-flex; align-items: center; gap: 6px; - background: none; - border: none; - padding: 2px 0; - cursor: pointer; + padding: 4px 8px; + border-radius: 6px; + background: transparent; + border: 1px solid transparent; color: var(--color-text-muted); font-size: 12px; + cursor: pointer; + transition: background 0.2s, border-color 0.2s, color 0.2s; + width: fit-content; &:hover { + background: var(--element-bg-subtle); + border-color: var(--border-base); color: var(--color-text-primary); } } &__custom-textarea { width: 100%; - min-height: 48px; - max-height: 120px; padding: 8px 10px; - border: 1px solid var(--border-base); border-radius: 8px; - background: color-mix(in srgb, var(--color-bg-primary) 70%, transparent); + border: 1px solid var(--border-base); + background: var(--color-bg-primary); color: var(--color-text-primary); font-size: 12px; - line-height: 1.45; + line-height: 1.5; resize: vertical; - outline: none; - font-family: inherit; + min-height: 52px; + max-height: 120px; &::placeholder { color: var(--color-text-muted); } &:focus { - border-color: color-mix(in srgb, var(--color-accent-500, #60a5fa) 50%, var(--border-base)); + outline: none; + border-color: var(--color-accent-500, #60a5fa); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-accent-500, #60a5fa) 20%, transparent); } } - // ---- actions ---- + /* Action buttons */ &__actions { display: flex; + align-items: center; gap: 8px; flex-wrap: wrap; } - // ---- animations ---- - @keyframes deep-review-action-bar__slide-up { - from { - opacity: 0; - transform: translateY(12px); - } - to { - opacity: 1; - transform: translateY(0); - } - } - - @keyframes deep-review-action-bar__spin { + /* Animations */ + @keyframes deep-review-action-bar-spin { from { transform: rotate(0deg); } diff --git a/src/web-ui/src/flow_chat/components/btw/DeepReviewActionBar.tsx b/src/web-ui/src/flow_chat/components/btw/DeepReviewActionBar.tsx index 2d93746f8..14ecba7f9 100644 --- a/src/web-ui/src/flow_chat/components/btw/DeepReviewActionBar.tsx +++ b/src/web-ui/src/flow_chat/components/btw/DeepReviewActionBar.tsx @@ -16,7 +16,8 @@ import { import { Button, Checkbox, Tooltip } from '@/component-library'; import { useReviewActionBarStore, type ReviewActionPhase } from '../../store/deepReviewActionBarStore'; import type { ReviewRemediationItem } from '../../utils/codeReviewRemediation'; -import { buildSelectedReviewRemediationPrompt } from '../../utils/codeReviewRemediation'; +import { buildSelectedReviewRemediationPrompt, REMEDIATION_GROUP_ORDER } from '../../utils/codeReviewRemediation'; +import type { RemediationGroupId } from '../../utils/codeReviewReport'; import { continueDeepReviewSession } from '../../services/DeepReviewContinuationService'; import { flowChatManager } from '../../services/FlowChatManager'; import { globalEventBus } from '@/infrastructure/event-bus'; @@ -46,6 +47,13 @@ const PHASE_CONFIG: Record = { + must_fix: { color: 'var(--color-error, #ef4444)' }, + should_improve: { color: 'var(--color-warning, #f59e0b)' }, + needs_decision: { color: 'var(--color-accent-500, #60a5fa)' }, + verification: { color: 'var(--color-success, #22c55e)' }, +}; + export const ReviewActionBar: React.FC = () => { const { t } = useTranslation('flow-chat'); const store = useReviewActionBarStore(); @@ -76,6 +84,26 @@ export const ReviewActionBar: React.FC = () => { const phaseConfig = PHASE_CONFIG[phase]; const PhaseIcon = phaseConfig.icon; + // Group items by priority + const groupedItems = useMemo(() => { + const groups: Record = {}; + for (const item of remediationItems) { + const gid = item.groupId ?? 'ungrouped'; + if (!groups[gid]) groups[gid] = []; + groups[gid].push(item); + } + return groups; + }, [remediationItems]); + + const groupOrder = useMemo(() => { + const ordered: string[] = []; + for (const gid of REMEDIATION_GROUP_ORDER) { + if (groupedItems[gid]?.length) ordered.push(gid); + } + if (groupedItems.ungrouped?.length) ordered.push('ungrouped'); + return ordered; + }, [groupedItems]); + const handleToggleRemediation = useCallback((id: string) => { store.toggleRemediation(id); }, [store]); @@ -84,6 +112,11 @@ export const ReviewActionBar: React.FC = () => { store.toggleAllRemediation(); }, [store]); + const handleToggleGroup = useCallback((groupId: string) => { + if (groupId === 'ungrouped') return; + store.toggleGroupRemediation(groupId as RemediationGroupId); + }, [store]); + const handleStartFixing = useCallback(async (rerunReview: boolean) => { if (!reviewData || !childSessionId) return; @@ -313,13 +346,13 @@ export const ReviewActionBar: React.FC = () => { onClick={handleDismiss} aria-label={t('deepReviewActionBar.dismiss', { defaultValue: 'Dismiss' })} > - + {/* Phase status header */}
{phaseTitle} @@ -349,31 +382,76 @@ export const ReviewActionBar: React.FC = () => { defaultValue: '{{selected}}/{{total}} selected', })} - {showRemediationList ? : } + {showRemediationList ? : } {showRemediationList && (
- {remediationItems.map((item: ReviewRemediationItem) => ( -
)} + {/* Hint text */} +
+ {t('toolCards.codeReview.remediationActions.hint', { + defaultValue: 'Deep Review is read-only by default. Select the remediation items to fix.', + })} +
+ {selectedCount === 0 && (
{t('toolCards.codeReview.remediationActions.noSelectionHint', { @@ -387,7 +465,7 @@ export const ReviewActionBar: React.FC = () => { {/* Friendly message when review completed with no remediation items */} {phase === 'review_completed' && remediationItems.length === 0 && (
- + {t('reviewActionBar.noIssuesFound', { defaultValue: 'No issues found. Great job!', @@ -404,7 +482,7 @@ export const ReviewActionBar: React.FC = () => { className="deep-review-action-bar__custom-toggle" onClick={() => setShowCustomInput(!showCustomInput)} > - + {showCustomInput ? t('deepReviewActionBar.hideCustomInput', { defaultValue: 'Hide instructions' }) @@ -471,7 +549,7 @@ export const ReviewActionBar: React.FC = () => { disabled={activeAction !== null} onClick={() => void handleContinueReview()} > - + {t('deepReviewActionBar.resumeReview', { defaultValue: 'Continue review' })} diff --git a/src/web-ui/src/flow_chat/components/modern/ExploreRegion.scss b/src/web-ui/src/flow_chat/components/modern/ExploreRegion.scss index d6cbf88ac..ed6b81930 100644 --- a/src/web-ui/src/flow_chat/components/modern/ExploreRegion.scss +++ b/src/web-ui/src/flow_chat/components/modern/ExploreRegion.scss @@ -98,7 +98,7 @@ .explore-region__content { position: relative; - padding: 4px 10px; + padding: 4px 0; &::-webkit-scrollbar { width: 4px; diff --git a/src/web-ui/src/flow_chat/components/modern/SubagentItems.scss b/src/web-ui/src/flow_chat/components/modern/SubagentItems.scss index f51922d43..512516e9b 100644 --- a/src/web-ui/src/flow_chat/components/modern/SubagentItems.scss +++ b/src/web-ui/src/flow_chat/components/modern/SubagentItems.scss @@ -18,7 +18,7 @@ // Subagent container for items under the same parent task. .subagent-items-container { - padding: 12px 14px; + padding: 12px 0; // Continue the task card border on left/right/bottom; top border is hidden. background: var(--color-bg-flowchat); diff --git a/src/web-ui/src/flow_chat/components/modern/TerminalGroupRenderer.scss b/src/web-ui/src/flow_chat/components/modern/TerminalGroupRenderer.scss index ba90c0f16..5eb85d73f 100644 --- a/src/web-ui/src/flow_chat/components/modern/TerminalGroupRenderer.scss +++ b/src/web-ui/src/flow_chat/components/modern/TerminalGroupRenderer.scss @@ -42,7 +42,7 @@ /* Tighter spacing for cards inside a terminal group */ .flowchat-tool-wrapper { margin-bottom: 0.25rem; - padding: 0 10px; + padding: 0; &:last-child { margin-bottom: 0; @@ -173,7 +173,7 @@ /* Tighter spacing for cards inside a terminal group */ .flowchat-tool-wrapper { margin-bottom: 0.25rem; - padding: 0 10px; + padding: 0; &:last-child { margin-bottom: 0; diff --git a/src/web-ui/src/flow_chat/store/deepReviewActionBarStore.ts b/src/web-ui/src/flow_chat/store/deepReviewActionBarStore.ts index 77d20748e..9c7034013 100644 --- a/src/web-ui/src/flow_chat/store/deepReviewActionBarStore.ts +++ b/src/web-ui/src/flow_chat/store/deepReviewActionBarStore.ts @@ -15,6 +15,7 @@ import { buildReviewRemediationItems, getDefaultSelectedRemediationIds, } from '../utils/codeReviewRemediation'; +import type { RemediationGroupId } from '../utils/codeReviewReport'; import type { DeepReviewInterruption } from '../utils/deepReviewContinuation'; export type ReviewActionMode = 'standard' | 'deep'; @@ -77,6 +78,7 @@ export interface ReviewActionBarState { updatePhase: (phase: ReviewActionPhase, errorMessage?: string | null) => void; toggleRemediation: (id: string) => void; toggleAllRemediation: () => void; + toggleGroupRemediation: (groupId: RemediationGroupId) => void; setActiveAction: (action: 'fix' | 'fix-review' | 'resume' | null) => void; setCustomInstructions: (value: string) => void; dismiss: () => void; @@ -164,6 +166,27 @@ export const useReviewActionBarStore = create((set, get) = } }, + toggleGroupRemediation: (groupId) => { + const { remediationItems, selectedRemediationIds } = get(); + const groupIds = new Set(remediationItems.filter((i) => i.groupId === groupId).map((i) => i.id)); + if (groupIds.size === 0) return; + + const allGroupSelected = [...groupIds].every((id) => selectedRemediationIds.has(id)); + const next = new Set(selectedRemediationIds); + + if (allGroupSelected) { + for (const id of groupIds) { + next.delete(id); + } + } else { + for (const id of groupIds) { + next.add(id); + } + } + + set({ selectedRemediationIds: next }); + }, + setActiveAction: (action) => set({ activeAction: action }), setCustomInstructions: (value) => set({ customInstructions: value }), dismiss: () => set({ dismissed: true }), diff --git a/src/web-ui/src/flow_chat/tool-cards/BaseToolCard.scss b/src/web-ui/src/flow_chat/tool-cards/BaseToolCard.scss index d5655e0f4..badf42a4d 100644 --- a/src/web-ui/src/flow_chat/tool-cards/BaseToolCard.scss +++ b/src/web-ui/src/flow_chat/tool-cards/BaseToolCard.scss @@ -6,7 +6,7 @@ /* ========== Shared header variables ========== */ :root { --tool-card-header-pad-y: 7px; - --tool-card-header-pad-x: 10px; + --tool-card-header-pad-x: 0px; --tool-card-header-icon-rail: 24px; } diff --git a/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.scss b/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.scss index d27201fba..ab888da1d 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.scss +++ b/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.scss @@ -22,7 +22,10 @@ * wrappers — every flow item shares the exact same visual gap to its sibling. */ margin: 0 0 0.6rem 0; background: transparent; - border: none; + /* Transparent border to match the box-sizing of bordered cards so that thinking + * items and card content share the same left edge. */ + border: 1px solid transparent; + box-sizing: border-box; font-size: var(--flowchat-font-size-base); line-height: 1.65; transition: padding 0.2s ease; diff --git a/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.tsx b/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.tsx index 598c74428..f2f359321 100644 --- a/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.tsx @@ -17,6 +17,8 @@ import { BaseToolCard } from './BaseToolCard'; import { taskCollapseStateManager } from '../store/TaskCollapseStateManager'; import { useToolCardHeightContract } from './useToolCardHeightContract'; import { ToolTimeoutIndicator } from './ToolTimeoutIndicator'; +import { getReviewerContextBySubagentId } from '@/shared/services/reviewTeamService'; +import type { ReviewerContext } from '@/shared/services/reviewTeamService'; import './TaskToolDisplay.scss'; import './ModelThinkingDisplay.scss'; @@ -108,22 +110,32 @@ export const TaskToolDisplay: React.FC = ({ const getTaskInput = () => { if (!toolCall?.input) return null; - + const isEarlyDetection = toolCall.input._early_detection === true; const isPartialParams = toolCall.input._partial_params === true; - + if (isEarlyDetection || isPartialParams) { return null; } - + const inputKeys = Object.keys(toolCall.input).filter(key => !key.startsWith('_')); if (inputKeys.length === 0) return null; - + const { description, prompt, subagent_type } = toolCall.input; + const agentType = subagent_type || 'Not provided'; + + // For built-in review-team reviewers, surface role context instead of + // the raw prompt so internal directives stay private. + const reviewerContext: ReviewerContext | null = + agentType !== 'Not provided' + ? getReviewerContextBySubagentId(agentType) + : null; + return { description: description || (prompt ? truncateByVisualWidth(prompt, 70) : 'Not provided'), prompt: prompt || 'Not provided', - agentType: subagent_type || 'Not provided' + agentType, + reviewerContext, }; }; @@ -304,13 +316,27 @@ export const TaskToolDisplay: React.FC = ({ return null; } - if (!hasRealPrompt && !needsConfirmation) { + if (!hasRealPrompt && !needsConfirmation && !taskInput?.reviewerContext) { return null; } return (
- {hasRealPrompt && ( + {taskInput?.reviewerContext ? ( +
+
+ {taskInput.reviewerContext.roleName} +
+
+ {taskInput.reviewerContext.description} +
+
    + {taskInput.reviewerContext.responsibilities.map((resp, idx) => ( +
  • {resp}
  • + ))} +
+
+ ) : hasRealPrompt && (
role.subagentId === subagentId, + ); + if (!coreRole) return null; + return { + roleName: coreRole.roleName, + description: coreRole.description, + responsibilities: coreRole.responsibilities, + accentColor: coreRole.accentColor, + }; +} + export function isReviewTeamCoreSubagent(subagentId: string): boolean { return CORE_ROLE_IDS.has(subagentId); } From 3a7a09f06925c055a7933bea69c76d2abe1cc59f Mon Sep 17 00:00:00 2001 From: limityan Date: Sun, 26 Apr 2026 13:30:03 +0800 Subject: [PATCH 4/4] feat(deep-review): replace raw prompt with reviewer context in task cards and detail panel - In TaskToolDisplay, show reviewer role name, description, and responsibilities instead of the raw prompt for built-in review-team subagents. - In TaskDetailPanel, add a reviewer context section with role info, falling back to the raw prompt only for non-reviewer tasks. - Add ReviewerContext interface and getReviewerContextBySubagentId() helper in reviewTeamService (already committed in previous change). - Add locale keys: reviewerContextLabel (en/zh-CN/zh-TW). - Add corresponding SCSS styles for both card and panel views. Generated with BitFun Co-Authored-By: BitFun --- .../TaskDetailPanel/TaskDetailPanel.scss | 62 +++++++++++++++++++ .../TaskDetailPanel/TaskDetailPanel.tsx | 21 ++++++- .../flow_chat/tool-cards/TaskToolDisplay.scss | 30 +++++++++ src/web-ui/src/locales/en-US/flow-chat.json | 2 + src/web-ui/src/locales/zh-CN/flow-chat.json | 2 + src/web-ui/src/locales/zh-TW/flow-chat.json | 2 + 6 files changed, 118 insertions(+), 1 deletion(-) diff --git a/src/web-ui/src/flow_chat/components/TaskDetailPanel/TaskDetailPanel.scss b/src/web-ui/src/flow_chat/components/TaskDetailPanel/TaskDetailPanel.scss index d0151e489..299646498 100644 --- a/src/web-ui/src/flow_chat/components/TaskDetailPanel/TaskDetailPanel.scss +++ b/src/web-ui/src/flow_chat/components/TaskDetailPanel/TaskDetailPanel.scss @@ -163,6 +163,68 @@ } } + // Reviewer context section replaces raw prompt for review-team members. + &__reviewer-section { + background: transparent; + flex-shrink: 0; + + summary { + padding: 8px 12px; + font-size: 12px; + font-weight: 500; + color: var(--color-text-secondary); + cursor: pointer; + user-select: none; + transition: all 0.15s ease; + border: 1px dashed var(--tool-card-border, rgba(255, 255, 255, 0.15)); + border-radius: 6px; + + &:hover { + color: var(--color-text-primary); + } + + &::marker { + color: var(--color-text-muted); + } + } + + &[open] summary { + border-radius: 6px 6px 0 0; + border-bottom: none; + } + } + + &__reviewer-context { + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px 12px; + border: 1px dashed var(--tool-card-border, rgba(255, 255, 255, 0.15)); + border-top: none; + border-radius: 0 0 6px 6px; + font-size: 12px; + line-height: 1.5; + } + + &__reviewer-role { + font-weight: 600; + font-size: 12px; + } + + &__reviewer-desc { + color: var(--color-text-muted); + } + + &__reviewer-responsibilities { + margin: 0; + padding-left: 16px; + list-style: disc; + + li { + color: var(--color-text-secondary); + } + } + &__execution { display: flex; flex-direction: column; diff --git a/src/web-ui/src/flow_chat/components/TaskDetailPanel/TaskDetailPanel.tsx b/src/web-ui/src/flow_chat/components/TaskDetailPanel/TaskDetailPanel.tsx index 41f959e49..a74d97b25 100644 --- a/src/web-ui/src/flow_chat/components/TaskDetailPanel/TaskDetailPanel.tsx +++ b/src/web-ui/src/flow_chat/components/TaskDetailPanel/TaskDetailPanel.tsx @@ -19,6 +19,7 @@ import { ToolTimeoutIndicator } from '../../tool-cards/ToolTimeoutIndicator'; import { Button, Tooltip, DotMatrixLoader } from '@/component-library'; import { createLogger } from '@/shared/utils/logger'; import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI'; +import type { ReviewerContext } from '@/shared/services/reviewTeamService'; import './TaskDetailPanel.scss'; const log = createLogger('TaskDetailPanel'); @@ -29,6 +30,7 @@ export interface TaskDetailData { description: string; prompt: string; agentType: string; + reviewerContext?: ReviewerContext | null; } | null; sessionId?: string; } @@ -295,7 +297,24 @@ export const TaskDetailPanel: React.FC = ({ data }) => { ref={contentRef} className="task-detail-panel__content" > - {taskInput?.prompt && taskInput.prompt !== 'Not provided' && ( + {taskInput?.reviewerContext ? ( +
+ {t('toolCards.taskDetailPanel.reviewerContextLabel')} +
+
+ {taskInput.reviewerContext.roleName} +
+
+ {taskInput.reviewerContext.description} +
+
    + {taskInput.reviewerContext.responsibilities.map((resp, idx) => ( +
  • {resp}
  • + ))} +
+
+
+ ) : taskInput?.prompt && taskInput.prompt !== 'Not provided' && (
{t('toolCards.taskDetailPanel.promptLabel')}
{taskInput.prompt}
diff --git a/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.scss b/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.scss index c7237803a..8aa19951a 100644 --- a/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.scss +++ b/src/web-ui/src/flow_chat/tool-cards/TaskToolDisplay.scss @@ -516,6 +516,36 @@ animation: tool-card-text-fade 1.6s ease-in-out infinite; } +/* Reviewer context block — replaces raw prompt for review-team members. */ +.task-reviewer-context { + display: flex; + flex-direction: column; + gap: 6px; + padding: 6px 0; + font-size: 12px; + line-height: 1.45; + color: var(--color-text-secondary); + + &__role { + font-weight: 600; + font-size: 12px; + } + + &__description { + color: var(--color-text-muted); + } + + &__responsibilities { + margin: 0; + padding-left: 16px; + list-style: disc; + + li { + color: var(--color-text-secondary); + } + } +} + @keyframes slideDown { from { opacity: 0; diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index 5057b0ac2..071f44d9b 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -397,6 +397,7 @@ "openModelSettings": "Open model settings", "copyDiagnostics": "Copy diagnostics", "diagnosticsCopied": "Diagnostics copied", + "diagnosticsCopyFailed": "Failed to copy diagnostics", "diagnosticsTitle": "=== Deep Review Interruption Diagnostics ===", "diagnosticsErrorType": "Error type", "diagnosticsDescription": "Description", @@ -777,6 +778,7 @@ "untitled": "Untitled Task", "statusLabel": "Status", "promptLabel": "Task Prompt", + "reviewerContextLabel": "Reviewer", "errorLabel": "Error", "resultLabel": "Result", "noData": "Unable to load task data", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index 6ec3f09ec..eddf60891 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -397,6 +397,7 @@ "openModelSettings": "打开模型设置", "copyDiagnostics": "复制诊断信息", "diagnosticsCopied": "诊断信息已复制", + "diagnosticsCopyFailed": "复制诊断信息失败", "diagnosticsTitle": "=== 深度审核中断诊断信息 ===", "diagnosticsErrorType": "错误类型", "diagnosticsDescription": "错误描述", @@ -777,6 +778,7 @@ "untitled": "未命名任务", "statusLabel": "状态", "promptLabel": "任务提示词", + "reviewerContextLabel": "审核员", "errorLabel": "错误信息", "resultLabel": "执行结果", "noData": "无法加载任务数据", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index 2f93356d0..7983d17a4 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -388,6 +388,7 @@ "openModelSettings": "打開模型設置", "copyDiagnostics": "複製診斷信息", "diagnosticsCopied": "診斷信息已複製", + "diagnosticsCopyFailed": "複製診斷信息失敗", "diagnosticsTitle": "=== 深度審核中斷診斷信息 ===", "diagnosticsErrorType": "錯誤類型", "diagnosticsDescription": "錯誤描述", @@ -735,6 +736,7 @@ "untitled": "未命名任務", "statusLabel": "狀態", "promptLabel": "任務提示詞", + "reviewerContextLabel": "審核員", "errorLabel": "錯誤信息", "resultLabel": "執行結果", "noData": "無法加載任務數據",