🐛 fix(native-window): 修复 AI 子窗重开、设置遮挡与关闭恢复

- 单次快捷键可重新唤起停驻 AI 子窗,并过滤重复键盘事件
- 打开 AI 设置前原子隐藏子窗,避免设置窗口被遮挡
- 用可见性版本阻止延迟隐藏或设置事件覆盖新焦点
- 拒绝复用终态子进程,并恢复失败的附加与关闭操作
- 串行展示、隐藏和终态退出,消除关闭门闩并发竞态
- 增加原生窗口生命周期、结果回滚和焦点恢复回归测试
This commit is contained in:
Syngnat
2026-07-22 18:45:55 +08:00
parent 75749d2465
commit c19fcdd183
19 changed files with 2256 additions and 153 deletions

View File

@@ -15,8 +15,10 @@ import {
buildNativeDetachedSyncStoreSnapshot,
buildNativeDetachedWorkbenchMutableStoreSnapshot,
buildNativeDetachedWorkbenchPayload,
closeCurrentNativeDetachedWindow,
fetchNativeDetachedWindowBootstrap,
hideCurrentNativeDetachedWindow,
hideCurrentNativeDetachedWindowForAISettings,
hideNativeDetachedWindow,
hydrateNativeDetachedStore,
isNativeDetachedWindow,
@@ -578,6 +580,32 @@ describe('nativeDetachedWindowClient', () => {
}
});
it('rejects a terminal action that the parent ignored as stale', async () => {
const action = vi.fn(async () => ({
success: true,
applied: false,
message: 'stale detached action ignored',
}));
const previousWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window');
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: { __GONAVI_DETACHED__: { action } },
});
try {
await expect(attachNativeDetachedWindow({
id: 'window-1',
kind: 'workbench',
tab: queryTab,
})).rejects.toThrow('stale detached action ignored');
} finally {
if (previousWindowDescriptor) {
Object.defineProperty(globalThis, 'window', previousWindowDescriptor);
} else {
Reflect.deleteProperty(globalThis, 'window');
}
}
});
it('reuses the child runtime bootstrap cache instead of fetching the payload twice', async () => {
const bootstrap = {
id: 'workbench:query-1',
@@ -687,6 +715,48 @@ describe('nativeDetachedWindowClient', () => {
}
});
it('uses the atomic native hide-and-open control for AI settings', async () => {
const hideForAISettings = vi.fn(async () => ({ success: true }));
const previousWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window');
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: { go: { nativewindow: { Control: { HideForAISettings: hideForAISettings } } } },
});
try {
await hideCurrentNativeDetachedWindowForAISettings(13);
expect(hideForAISettings).toHaveBeenCalledWith(13);
} finally {
if (previousWindowDescriptor) {
Object.defineProperty(globalThis, 'window', previousWindowDescriptor);
} else {
Reflect.deleteProperty(globalThis, 'window');
}
}
});
it('rejects when the native close control reports a failure', async () => {
const close = vi.fn(async () => ({
success: false,
message: 'native window is not ready',
}));
const previousWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window');
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: { go: { nativewindow: { Control: { Close: close } } } },
});
try {
await expect(closeCurrentNativeDetachedWindow())
.rejects.toThrow('native window is not ready');
expect(close).toHaveBeenCalledOnce();
} finally {
if (previousWindowDescriptor) {
Object.defineProperty(globalThis, 'window', previousWindowDescriptor);
} else {
Reflect.deleteProperty(globalThis, 'window');
}
}
});
it('detects injected flags and the detached query parameter', () => {
expect(isNativeDetachedWindow({ pathname: '/', search: '?__gonavi_detached=window-1' })).toBe(true);
expect(isNativeDetachedWindow({ pathname: '/', search: '' })).toBe(false);

View File

@@ -13,6 +13,7 @@ export const NATIVE_DETACHED_BOOTSTRAP_URL = '/__gonavi/detached/bootstrap';
export const NATIVE_DETACHED_ACTION_URL = '/__gonavi/detached/action';
export { NATIVE_DETACHED_WINDOW_QUERY_PARAM } from './nativeDetachedWindowRoute';
export const NATIVE_DETACHED_WINDOW_COMMAND_EVENT = 'gonavi:native-detached-command';
export const NATIVE_DETACHED_QUERY_RESULT_REDETACH_EVENT = 'gonavi:redetach-query-result';
export const NATIVE_DETACHED_HOST_EVENTS_KEY = '__gonaviNativeHostEvents';
@@ -59,12 +60,14 @@ export interface NativeDetachedWindowBootstrap {
kind: NativeDetachedWindowKind;
title: string;
payload: NativeDetachedWindowPayload;
actionRevision?: number;
}
export interface NativeDetachedWindowActionPayload {
id: string;
kind: NativeDetachedWindowKind;
revision?: number;
rollbackAction?: 'attach' | 'hide' | 'close';
storeState?: NativeDetachedStoreSnapshot;
tab?: TabData;
resultWindow?: DetachedQueryResultWindow;
@@ -113,6 +116,7 @@ export interface NativeDetachedWindowActionRequest {
export interface NativeDetachedWindowActionResult {
success: boolean;
applied?: boolean;
message?: string;
id?: string;
visibilityRevision?: number;
@@ -123,6 +127,7 @@ export interface NativeDetachedHostStateCommand {
action: 'sync-host-state' | string;
payload?: {
revision?: number;
visibilityRevision?: number;
storeState?: NativeDetachedStoreSnapshot;
};
}
@@ -917,8 +922,12 @@ export const postNativeDetachedWindowAction = async (
if (result?.success === false) {
throw new Error(String(result.message || `Native detached ${action} failed`));
}
if (result?.applied === false) {
throw new Error(String(result.message || `Native detached ${action} was ignored`));
}
return {
success: result?.success !== false,
...(typeof result?.applied === 'boolean' ? { applied: result.applied } : {}),
...(result?.message ? { message: String(result.message) } : {}),
...(result?.id ? { id: String(result.id) } : {}),
...(Number.isFinite(Number(result?.visibilityRevision))
@@ -942,6 +951,9 @@ export const postNativeDetachedWindowAction = async (
if (result?.success === false) {
throw new Error(String(result.message || `Native detached ${action} failed`));
}
if (result?.applied === false) {
throw new Error(String(result.message || `Native detached ${action} was ignored`));
}
return result;
};
@@ -1008,7 +1020,10 @@ export const closeCurrentNativeDetachedWindow = async (): Promise<void> => {
? (window as any).go?.nativewindow?.Control?.Close
: undefined;
if (typeof nativeClose === 'function') {
await nativeClose();
const result = await nativeClose();
if (result?.success === false) {
throw new Error(String(result.message || 'Failed to close native detached window'));
}
return;
}
if (typeof window !== 'undefined' && typeof window.close === 'function') {
@@ -1031,6 +1046,21 @@ export const hideCurrentNativeDetachedWindow = async (
}
};
export const hideCurrentNativeDetachedWindowForAISettings = async (
visibilityRevision: number,
): Promise<void> => {
const hideForAISettings = typeof window !== 'undefined'
? (window as any).go?.nativewindow?.Control?.HideForAISettings
: undefined;
if (typeof hideForAISettings !== 'function') {
throw new Error('Native detached AI settings control is unavailable');
}
const result = await hideForAISettings(Math.trunc(visibilityRevision));
if (result?.success === false) {
throw new Error(String(result.message || 'Failed to open AI settings from native window'));
}
};
export const cancelCurrentNativeDetachedWindowClose = async (): Promise<void> => {
const cancelClose = typeof window !== 'undefined'
? (window as any).go?.nativewindow?.Control?.CancelClose

View File

@@ -11,6 +11,7 @@ import {
shouldApplyNativeDetachedHideRevision,
syncNativeAIChatHostState,
syncNativeDetachedShortcutOptions,
toggleOrFocusNativeAIChatFromMainWindow,
type NativeDetachedWindowManager,
} from './nativeDetachedWindowHost';
import { clearQueryTabDraft, setQueryTabDraft } from './sqlFileTabDrafts';
@@ -289,6 +290,28 @@ describe('nativeDetachedWindowHost', () => {
expect(shouldApplyNativeDetachedHideRevision('ai-chat', 3)).toBe(false);
});
it('focuses an already-visible native AI child from the main shortcut without closing it first', async () => {
useStore.setState({
aiPanelVisible: true,
detachedAIChatWindow: {
x: 20,
y: 30,
width: 440,
height: 720,
zIndex: 1201,
coordinateSpace: 'screen',
},
});
await expect(toggleOrFocusNativeAIChatFromMainWindow(manager)).resolves.toBe(true);
expect(useStore.getState().aiPanelVisible).toBe(true);
expect(manager.Focus).toHaveBeenCalledOnce();
expect(manager.Focus).toHaveBeenCalledWith('ai-chat');
expect(manager.Hide).not.toHaveBeenCalled();
expect(manager.Open).not.toHaveBeenCalled();
});
it('resends the latest AI shortcut after native open completes', async () => {
const initialShortcutOptions = useStore.getState().shortcutOptions;
const latestShortcutOptions = {

View File

@@ -538,6 +538,18 @@ export const openNativeAIChatWindow = async (
return opened;
};
export const toggleOrFocusNativeAIChatFromMainWindow = async (
managerOverride?: NativeDetachedWindowManager,
): Promise<boolean> => {
const state = useStore.getState();
const manager = managerOverride ?? resolveNativeDetachedWindowManager();
if (manager && state.aiPanelVisible && state.detachedAIChatWindow) {
return openNativeAIChatWindow(undefined, manager);
}
state.toggleAIPanel();
return useStore.getState().aiPanelVisible;
};
const refreshNativeAIChatWindow = async (
manager: NativeDetachedWindowManager,
): Promise<boolean> => {