Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions docs/github-tracking/issue-153-qa-helper-native-spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
## Issue #153 Native Spec

Scope: Windows-native helper-window implementation for QA panel

Status:

- This document is the implementation-spec layer for `#153`
- It replaces "keep trying shared drag carriers" with a platform-layered plan
- Product goal stays shared with macOS; implementation becomes Windows-native

### Shared Product Contract

The QA panel must satisfy the same product goal across platforms:

1. Non-disruptive to the source app context
2. Clickable controls:
- close
- pin
3. Draggable from toolbar/background drag affordance
4. Safe multi-turn follow-up usage
5. Dismiss means non-participating, not merely visually hidden

### Windows Native Contract

Windows should not reuse macOS's implementation shape as the primary carrier.

Instead, the Windows-specific helper-window must explicitly define:

1. Window creation attributes
- topmost helper window
- transparent window is allowed only if it does not break hit-test semantics
- no taskbar entry
- activation behavior must be explicitly chosen, not inherited accidentally

2. Native drag semantics
- drag region must be recognized at the native window/message layer
- do not depend on shared async drag helpers as the primary mechanism
- toolbar drag and control-hit regions must be natively separated

3. Native click semantics
- close and pin controls must remain clickable
- drag region must not swallow control clicks
- hit-test mapping must distinguish:
- drag region
- control buttons
- normal client area

4. Source-app context relation
- normal show path should avoid stealing the user's upstream context unnecessarily
- if Windows drag requires temporary focus/activation semantics, this must be treated as an explicit transition, not an accident
- after drag/dismiss, helper-window semantics must be restored

### Current Baseline Findings

Upstream-based repro branch confirms:

- QA feature exists
- hotkey path can be made healthy
- close path can be healthy
- drag is the remaining isolated interaction gap

This means the implementation target is now narrow:

```text
Fix Windows-native draggable semantics for QA helper-window
without reopening unrelated QA / UI / hotkey scope.
```

### Implementation Boundaries

In scope:

- Windows QA helper-window creation/runtime behavior
- native hit-test / message routing for drag region
- preserving clickable controls while enabling drag

Out of scope:

- main window frame/radius/shadow issues
- Capsule geometry / lifecycle work from other families
- unrelated provider / insertion / polish changes

### Acceptance Criteria

- [ ] `Ctrl+Shift+;` still opens and closes QA panel
- [ ] close button remains clickable
- [ ] pin remains clickable
- [ ] toolbar drag works on Windows
- [ ] no regression in follow-up QA flow
- [ ] implementation remains scoped to Windows-native helper-window semantics only

### Notes

The key design rule is:

```text
Same product goal, different OS carriers.
Windows must own its native helper-window carrier instead of inheriting
macOS-shaped interaction assumptions.
```
65 changes: 65 additions & 0 deletions docs/github-tracking/pr-184-qa-helper-native.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
## Summary

Closes #153

This code PR is the upstream-based delivery branch for the narrowed Windows repair:

```text
Windows native drag semantics for QA helper-window
```

This PR follows a layered strategy:

- keep the same product goal as macOS
- stop assuming the same implementation carrier works on Windows
- move Windows behavior toward a native helper-window contract

## Scope

In scope:

- Windows-native QA helper-window interaction semantics
- drag-region / click-region separation
- preserving non-disruptive QA workflow

Out of scope:

- main window appearance
- Capsule family work
- unrelated QA renderer redesign

## Shared Product Goal

The PR keeps the same product target:

- non-disruptive helper window
- clickable close / pin
- draggable toolbar region
- follow-up QA flow stays healthy
- dismiss returns to non-participating state

## Windows-native Direction

This PR is not meant to keep stacking shared drag workarounds.

It exists to drive the implementation toward:

- Windows-specific helper-window carrier
- native hit-test / message ownership where needed
- explicit separation of drag surface and control surface

## Validation Target

- [x] upstream-based branch exists and builds
- [x] narrowed baseline established: hotkey + close can be healthy while drag remains broken
- [ ] Windows drag becomes healthy on this branch
- [ ] reviewer-side Windows regression confirms:
- open
- close
- drag
- follow-up QA

## Related Anchors

- #156 remains the tracking / design-convergence PR
- #158 remains the governance anchor for helper-window / native-window contract thinking
134 changes: 134 additions & 0 deletions openless-all/app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,19 @@ mod types;
#[cfg(target_os = "macos")]
use std::sync::mpsc;
use std::sync::atomic::{AtomicBool, Ordering};
#[cfg(target_os = "windows")]
use std::sync::atomic::AtomicIsize;
use std::sync::Arc;
#[cfg(target_os = "macos")]
use std::time::Duration;

/// 第一次 show 时把 QA 浮窗摆到屏幕底部居中;之后的 show 不再 reposition,
/// 让用户拖动后的位置在 hide → show 之间得以保持。详见 issue #118 v2。
static QA_WINDOW_POSITIONED: AtomicBool = AtomicBool::new(false);
#[cfg(target_os = "windows")]
static QA_WNDPROC_INSTALLED: AtomicBool = AtomicBool::new(false);
#[cfg(target_os = "windows")]
static QA_ORIGINAL_WNDPROC: AtomicIsize = AtomicIsize::new(0);
use tauri::menu::{MenuBuilder, MenuItemBuilder};
use tauri::tray::{MouseButton, TrayIconBuilder, TrayIconEvent};
use tauri::{AppHandle, Emitter, LogicalPosition, LogicalSize, Manager, RunEvent, Runtime};
Expand Down Expand Up @@ -77,6 +83,8 @@ pub fn run() {
}
#[cfg(target_os = "macos")]
make_qa_window_draggable_macos(&qa);
#[cfg(target_os = "windows")]
install_qa_drag_hit_test(&qa);
let _ = qa.hide();
} else {
log::info!("[qa] qa 窗口未在 tauri.conf.json 中声明,前端 agent 会补上");
Expand Down Expand Up @@ -516,6 +524,9 @@ pub(crate) fn show_qa_window<R: tauri::Runtime>(app: &AppHandle<R>, content_kind
}
#[cfg(target_os = "windows")]
{
if let Err(e) = window.set_ignore_cursor_events(false) {
log::warn!("[qa] show: set_ignore_cursor_events(false) failed: {e}");
}
if !show_qa_window_no_activate(&window) {
log::warn!("[qa] show_no_activate failed; falling back to window.show()");
if let Err(e) = window.show() {
Expand Down Expand Up @@ -565,6 +576,15 @@ fn make_qa_window_draggable_macos<R: tauri::Runtime>(window: &tauri::WebviewWind
/// 隐藏 QA 窗口。供 commands::qa_window_dismiss / coordinator session 收尾共用。
pub(crate) fn hide_qa_window<R: tauri::Runtime>(app: &AppHandle<R>) {
if let Some(window) = app.get_webview_window("qa") {
#[cfg(target_os = "windows")]
{
if let Err(e) = window.set_ignore_cursor_events(true) {
log::warn!("[qa] hide: set_ignore_cursor_events(true) failed: {e}");
}
if hide_qa_window_non_participating() {
return;
}
}
let _ = window.hide();
}
}
Expand Down Expand Up @@ -604,6 +624,120 @@ fn show_qa_window_no_activate<R: tauri::Runtime>(window: &tauri::WebviewWindow<R
true
}

#[cfg(target_os = "windows")]
fn install_qa_drag_hit_test<R: tauri::Runtime>(window: &tauri::WebviewWindow<R>) {
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
use windows::Win32::Foundation::{HWND, LRESULT};
use windows::Win32::UI::WindowsAndMessaging::{
CallWindowProcW, GetClientRect, GetWindowRect, SetWindowLongPtrW, GWLP_WNDPROC,
HTCAPTION, HTCLIENT, WM_NCHITTEST, WNDPROC,
};

if QA_WNDPROC_INSTALLED.load(Ordering::SeqCst) {
return;
}

let Ok(handle) = window.window_handle() else {
log::warn!("[qa] window_handle unavailable; drag hit-test install skipped");
return;
};
let RawWindowHandle::Win32(raw) = handle.as_raw() else {
log::warn!("[qa] raw window handle is not Win32; drag hit-test install skipped");
return;
};
let hwnd = HWND(raw.hwnd.get() as *mut _);
if hwnd.0.is_null() {
log::warn!("[qa] hwnd null; drag hit-test install skipped");
return;
}

unsafe extern "system" fn qa_wndproc(
hwnd: HWND,
msg: u32,
wparam: windows::Win32::Foundation::WPARAM,
lparam: windows::Win32::Foundation::LPARAM,
) -> LRESULT {
if msg == WM_NCHITTEST {
let mut rect = windows::Win32::Foundation::RECT::default();
let _ = GetClientRect(hwnd, &mut rect);
let mut window_rect = windows::Win32::Foundation::RECT::default();
let _ = GetWindowRect(hwnd, &mut window_rect);

let point_x = (lparam.0 & 0xffff) as i16 as i32 - window_rect.left;
let point_y = ((lparam.0 >> 16) & 0xffff) as i16 as i32 - window_rect.top;
let toolbar_height = 32;
let right_controls_width = 76;
let draggable_right = (rect.right - right_controls_width).max(0);
Comment on lines +668 to +670
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Scale hit-test bounds with monitor DPI

WM_NCHITTEST coordinates and GetClientRect are in physical pixels, but the drag thresholds here (toolbar_height = 32, right_controls_width = 76) are hard-coded as if they were CSS/logical pixels. On Windows displays using 125%/150% scaling, the non-draggable controls area becomes too small and the draggable caption zone intrudes into the pin/close buttons, so clicks on controls can be interpreted as drag starts. Please derive these bounds from the current DPI (or from runtime window scale factor) before comparing against hit-test coordinates.

Useful? React with 👍 / 👎.


if point_y >= 0
&& point_y < toolbar_height
&& point_x >= 0
&& point_x < draggable_right
{
return LRESULT(HTCAPTION as isize);
}
return LRESULT(HTCLIENT as isize);
}

let original = QA_ORIGINAL_WNDPROC.load(Ordering::SeqCst);
if original == 0 {
return LRESULT(0);
}
let proc: WNDPROC = Some(std::mem::transmute(original));
unsafe { CallWindowProcW(proc, hwnd, msg, wparam, lparam) }
}

let previous = unsafe {
SetWindowLongPtrW(hwnd, GWLP_WNDPROC, qa_wndproc as *const () as usize as isize)
};
if previous != 0 {
QA_ORIGINAL_WNDPROC.store(previous, Ordering::SeqCst);
QA_WNDPROC_INSTALLED.store(true, Ordering::SeqCst);
log::info!("[qa] installed native drag hit-test on QA window");
} else {
log::warn!("[qa] SetWindowLongPtrW returned 0; drag hit-test install may have failed");
}
}

#[cfg(target_os = "windows")]
fn hide_qa_window_non_participating() -> bool {
use std::iter::once;
use windows::Win32::Foundation::HWND;
use windows::Win32::UI::WindowsAndMessaging::{
FindWindowW, SetWindowPos, ShowWindow, HWND_NOTOPMOST, SWP_HIDEWINDOW, SWP_NOACTIVATE,
SWP_NOMOVE, SWP_NOSIZE, SW_HIDE,
};
use windows::core::PCWSTR;

let title: Vec<u16> = "OpenLess QA".encode_utf16().chain(once(0)).collect();
let hwnd = match unsafe { FindWindowW(PCWSTR::null(), PCWSTR(title.as_ptr())) } {
Ok(hwnd) => hwnd,
Err(_) => return false,
};
if hwnd.0.is_null() {
return false;
}

let _ = unsafe { ShowWindow(hwnd, SW_HIDE) };
let _ = unsafe {
SetWindowPos(
hwnd,
HWND_NOTOPMOST,
0,
0,
0,
0,
SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_HIDEWINDOW,
)
};
true
}

#[cfg(not(target_os = "windows"))]
fn hide_qa_window_non_participating() -> bool {
false
}

/// 把 capsule 窗口移到屏幕底部居中,与 Swift `CapsuleWindowController.repositionToBottomCenter` 同效。
/// 留 80pt 给 macOS Dock;Windows 任务栏一般在底部 48pt 以内,整体也合适。
pub(crate) fn position_capsule_bottom_center<R: tauri::Runtime>(
Expand Down
29 changes: 3 additions & 26 deletions openless-all/app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
checkAccessibilityPermission,
checkMicrophonePermission,
getHotkeyStatus,
handleWindowHotkeyEvent,
isTauri,
} from './lib/ipc';
import { QaPanel } from './pages/QaPanel';
Expand Down Expand Up @@ -109,21 +108,9 @@ export function App({ isCapsule, isQa }: AppProps) {

useEffect(() => {
if (!isTauri || os !== 'win') return;
const forwardKey = (event: KeyboardEvent) => {
if (!isWindowHotkeyCandidate(event)) return;
void handleWindowHotkeyEvent(
event.type as 'keydown' | 'keyup',
event.key,
event.code,
event.repeat,
).catch(error => console.warn('[window-hotkey] forward failed', error));
};
window.addEventListener('keydown', forwardKey, true);
window.addEventListener('keyup', forwardKey, true);
return () => {
window.removeEventListener('keydown', forwardKey, true);
window.removeEventListener('keyup', forwardKey, true);
};
// Windows 听写 / QA lifecycle 由 backend low-level keyboard hook 单独拥有。
// 不再让前台 main window 额外转发 keydown/keyup,避免双事件源共同驱动同一状态机。
return;
}, [os]);

if (gate === 'checking') {
Expand All @@ -136,16 +123,6 @@ export function App({ isCapsule, isQa }: AppProps) {
);
}

function isWindowHotkeyCandidate(event: KeyboardEvent): boolean {
return (
event.key === 'Escape' ||
event.code === 'ControlRight' ||
event.code === 'ControlLeft' ||
event.code === 'AltRight' ||
event.code === 'MetaRight'
);
}

function StartupShell() {
return (
<div
Expand Down
Loading
Loading