From fda0733f117350d873255d4e01e655c82c81dcf9 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Tue, 21 Jul 2026 23:40:17 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=EF=B8=8F=20perf(native-window):=20?= =?UTF-8?q?=E4=BC=98=E5=8C=96=20AI=20=E7=8B=AC=E7=AB=8B=E7=AA=97=E5=8F=A3?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E4=B8=8E=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 关闭时隐藏保活,重开复用现有进程并通过可见性版本消除竞态 - 拆分 AI 与工作台资源,使用启动状态白名单减少序列化开销 - 修复设置中心层级、Windows 前台切换及独立窗口关闭交互 - 补齐状态同步、断线重放与窗口生命周期回归测试 --- frontend/src/App.settings-center.test.ts | 15 +- frontend/src/App.tsx | 9 + frontend/src/components/AIChatPanel.tsx | 1 + frontend/src/components/DataGrid.tsx | 1 + .../NativeDetachedWindowApp.test.tsx | 223 +++++ .../components/NativeDetachedWindowApp.tsx | 209 +++- .../NativeDetachedWindowController.test.ts | 109 +++ .../NativeDetachedWindowController.tsx | 48 +- .../src/components/WorkbenchTabContent.tsx | 2 + .../src/nativeDetachedMain.styles.test.ts | 47 +- frontend/src/nativeDetachedMain.tsx | 1 - frontend/src/store.test.ts | 14 +- frontend/src/store.ts | 8 +- frontend/src/styles/v2-theme-ai.css | 902 ++++++++++++++++++ frontend/src/styles/v2-theme-workbench.css | 902 ------------------ frontend/src/test/readV2ThemeCss.ts | 1 + .../utils/nativeDetachedWindowClient.test.ts | 45 + .../src/utils/nativeDetachedWindowClient.ts | 70 +- .../utils/nativeDetachedWindowHost.test.ts | 99 +- .../src/utils/nativeDetachedWindowHost.ts | 172 +++- frontend/wailsjs/go/models.ts | 4 + frontend/wailsjs/go/nativewindow/Manager.d.ts | 2 + frontend/wailsjs/go/nativewindow/Manager.js | 4 + internal/nativewindow/bridge.go | 168 +++- internal/nativewindow/bridge_sse_test.go | 109 +++ internal/nativewindow/close_gate_test.go | 29 + internal/nativewindow/dock_menu.go | 2 +- internal/nativewindow/dock_menu_test.go | 1 + .../foreground_permission_other.go | 7 + .../foreground_permission_test.go | 82 ++ .../foreground_permission_windows.go | 28 + .../foreground_permission_windows_test.go | 40 + internal/nativewindow/manager.go | 327 ++++++- internal/nativewindow/manager_test.go | 312 ++++++ internal/nativewindow/runtime_script.go | 23 +- internal/nativewindow/runtime_script_test.go | 18 + internal/nativewindow/types.go | 15 +- internal/nativewindow/visibility_gate_test.go | 149 +++ 38 files changed, 3147 insertions(+), 1051 deletions(-) create mode 100644 frontend/src/styles/v2-theme-ai.css create mode 100644 internal/nativewindow/foreground_permission_other.go create mode 100644 internal/nativewindow/foreground_permission_test.go create mode 100644 internal/nativewindow/foreground_permission_windows.go create mode 100644 internal/nativewindow/foreground_permission_windows_test.go diff --git a/frontend/src/App.settings-center.test.ts b/frontend/src/App.settings-center.test.ts index 7b46f8f7..0af35d35 100644 --- a/frontend/src/App.settings-center.test.ts +++ b/frontend/src/App.settings-center.test.ts @@ -126,7 +126,7 @@ describe('settings center layout', () => { expect(openDetailsSource).toContain('setIsSettingsModalOpen(true);'); expect(appSource).toContain("const detailsWereOpen = isSettingsModalOpen && activeSettingsCenterPane?.key === 'security-update';"); expect(appSource.match(/ { expect(appSource).not.toContain(' { + const settingsModalStart = appSource.indexOf( + "title={renderUtilityModalTitle(, t('app.settings.title')", + ); + const settingsModalSource = appSource.slice(settingsModalStart, settingsModalStart + 900); + + expect(settingsModalStart).toBeGreaterThan(-1); + expect(appSource).toContain('const SETTINGS_CENTER_MODAL_Z_INDEX = 10001;'); + expect(appSource).toContain('const settingsCenterModalZIndex = Math.max('); + expect(appSource).toContain('Number.isFinite(detachedAIChatZIndex) ? detachedAIChatZIndex + 1'); + expect(settingsModalSource).toContain('zIndex={settingsCenterModalZIndex}'); + }); + it('opens the about group directly instead of showing a one-item list', () => { expect(appSource).toContain('const resolveSettingsCenterGroupInitialPane = (group: SettingsCenterGroupKey): SettingsCenterPaneState | null => ('); expect(appSource).toContain("group === 'about' ? { key: 'about-go-navi', group: 'about' } : null"); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5d136405..f72d655f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -202,12 +202,15 @@ import { useI18n } from './i18n/provider'; import './App.css'; import './v2-theme.css'; import './styles/v2-theme-workbench.css'; +import './styles/v2-theme-ai.css'; const { Sider, Content } = Layout; const MIN_UI_SCALE = 0.8; const MAX_UI_SCALE = 1.25; const MIN_FONT_SIZE = 12; const MAX_FONT_SIZE = 20; +// Keep the settings center above in-WebView detached windows and context menus. +const SETTINGS_CENTER_MODAL_Z_INDEX = 10001; type ApplicationQuitConfirmedAction = () => Promise; /** 设置页 Slider 底部预设刻度 */ const UI_SCALE_SLIDER_MARKS: Record = { @@ -1003,6 +1006,11 @@ function App() { const detachedAIChatWindow = useStore(state => state.detachedAIChatWindow); const detachAIChatPanel = useStore(state => state.detachAIChatPanel); const aiChatDetached = Boolean(detachedAIChatWindow); + const detachedAIChatZIndex = Number(detachedAIChatWindow?.zIndex); + const settingsCenterModalZIndex = Math.max( + SETTINGS_CENTER_MODAL_Z_INDEX, + Number.isFinite(detachedAIChatZIndex) ? detachedAIChatZIndex + 1 : SETTINGS_CENTER_MODAL_Z_INDEX, + ); const toggleAIPanel = useStore(state => state.toggleAIPanel); const setAIPanelVisible = useStore(state => state.setAIPanelVisible); useEffect(() => { @@ -7816,6 +7824,7 @@ function App() { footer={null} centered width={1080} + zIndex={settingsCenterModalZIndex} styles={{ content: toolCenterModalContentStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, diff --git a/frontend/src/components/AIChatPanel.tsx b/frontend/src/components/AIChatPanel.tsx index 94a82c21..71e88b82 100644 --- a/frontend/src/components/AIChatPanel.tsx +++ b/frontend/src/components/AIChatPanel.tsx @@ -9,6 +9,7 @@ import type { JVMDiagnosticPlanContext, } from '../types'; import './AIChatPanel.css'; +import '../styles/v2-theme-ai.css'; import { AIChatHeader } from './ai/AIChatHeader'; import { AIChatInput } from './ai/AIChatInput'; diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index d3d176cb..c4892444 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -30,6 +30,7 @@ import { useOptionalI18n } from '../i18n/provider'; import type { ColumnDefinition, ForeignKeyDefinition, IndexDefinition } from '../types'; import { v4 as generateUuid } from 'uuid'; import 'react-resizable/css/styles.css'; +import '../styles/v2-theme-workbench.css'; import { buildOrderBySQL, buildPaginatedSelectSQL, buildWhereSQL, escapeLiteral, hasExplicitSort, quoteIdentPart, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql'; import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance'; import { isConnectionDataImportRestricted } from '../utils/connectionReadOnly'; diff --git a/frontend/src/components/NativeDetachedWindowApp.test.tsx b/frontend/src/components/NativeDetachedWindowApp.test.tsx index cda78253..43bb7f7b 100644 --- a/frontend/src/components/NativeDetachedWindowApp.test.tsx +++ b/frontend/src/components/NativeDetachedWindowApp.test.tsx @@ -1119,6 +1119,229 @@ describe('NativeDetachedWindowApp', () => { } }); + it('parks the AI child instead of terminating it when its close button is clicked', async () => { + const previousWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window'); + const eventTarget = new EventTarget(); + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: Object.assign(eventTarget, { + clearTimeout: globalThis.clearTimeout, + innerWidth: 440, + outerHeight: 720, + outerWidth: 440, + screenX: -1200, + screenY: 80, + setTimeout: globalThis.setTimeout, + }), + }); + const bootstrap: NativeDetachedWindowBootstrap = { + id: 'ai-chat', + kind: 'ai-chat', + title: 'GoNavi AI', + payload: { storeState: { appearance: { uiVersion: 'v2' }, theme: 'light' } }, + }; + const callOrder: string[] = []; + aiTerminalGuard.mockImplementationOnce(async () => { + callOrder.push('guard'); + return true; + }); + flushAIChatSessionPersistence.mockImplementationOnce(async () => { + callOrder.push('flush'); + }); + const client = { + load: vi.fn(async () => bootstrap), + ready: vi.fn(async () => undefined), + sync: vi.fn(async () => undefined), + attach: vi.fn(async () => undefined), + hide: vi.fn(async () => { + callOrder.push('hide'); + return 9; + }), + close: vi.fn(async () => undefined), + openAISettings: vi.fn(async () => undefined), + closeCurrentWindow: vi.fn(async () => undefined), + hideCurrentWindow: vi.fn(async (revision: number) => { + callOrder.push(`hide-window:${revision}`); + }), + }; + + try { + let renderer: TestRenderer.ReactTestRenderer; + await act(async () => { + renderer = TestRenderer.create(); + await flushEffects(); + }); + + await act(async () => { + renderer!.root.findByProps({ 'data-ai-chat-close': true }).props.onClick(); + await flushEffects(); + await flushEffects(); + }); + + expect(callOrder).toEqual(['guard', 'flush', 'hide', 'hide-window:9']); + expect(client.close).not.toHaveBeenCalled(); + expect(client.closeCurrentWindow).not.toHaveBeenCalled(); + expect(renderer!.root.findByProps({ 'data-ai-chat-presentation': 'detached' })).toBeTruthy(); + await act(async () => renderer!.unmount()); + } finally { + if (previousWindowDescriptor) { + Object.defineProperty(globalThis, 'window', previousWindowDescriptor); + } else { + Reflect.deleteProperty(globalThis, 'window'); + } + } + }); + + it('lets a graceful close preempt an in-flight AI hide', async () => { + const previousWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window'); + const eventTarget = new EventTarget(); + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: Object.assign(eventTarget, { + clearTimeout: globalThis.clearTimeout, + innerWidth: 440, + outerHeight: 720, + outerWidth: 440, + screenX: 120, + screenY: 80, + setTimeout: globalThis.setTimeout, + }), + }); + const bootstrap: NativeDetachedWindowBootstrap = { + id: 'ai-chat', + kind: 'ai-chat', + title: 'GoNavi AI', + payload: { storeState: { appearance: { uiVersion: 'v2' }, theme: 'light' } }, + }; + const callOrder: string[] = []; + let releaseHide: (() => void) | undefined; + let markHideStarted: (() => void) | undefined; + const hideStarted = new Promise((resolve) => { + markHideStarted = resolve; + }); + const client = { + load: vi.fn(async () => bootstrap), + ready: vi.fn(async () => undefined), + sync: vi.fn(async () => undefined), + attach: vi.fn(async () => undefined), + hide: vi.fn(async () => { + callOrder.push('hide-started'); + markHideStarted?.(); + return new Promise((resolve) => { + releaseHide = () => resolve(15); + }); + }), + close: vi.fn(async () => { + callOrder.push('close'); + }), + openAISettings: vi.fn(async () => undefined), + closeCurrentWindow: vi.fn(async () => { + callOrder.push('close-window'); + }), + hideCurrentWindow: vi.fn(async () => { + callOrder.push('hide-window'); + }), + }; + + try { + let renderer: TestRenderer.ReactTestRenderer; + await act(async () => { + renderer = TestRenderer.create(); + await flushEffects(); + }); + + await act(async () => { + renderer!.root.findByProps({ 'data-ai-chat-close': true }).props.onClick(); + await flushEffects(); + }); + await hideStarted; + + await act(async () => { + eventTarget.dispatchEvent(new Event('gonavi:native-detached-request-close')); + releaseHide?.(); + await flushEffects(); + await flushEffects(); + }); + + expect(client.close).toHaveBeenCalledWith(expect.objectContaining({ + id: 'ai-chat', + kind: 'ai-chat', + })); + expect(client.hideCurrentWindow).not.toHaveBeenCalled(); + expect(callOrder).toEqual(['hide-started', 'close', 'close-window']); + await act(async () => renderer!.unmount()); + } finally { + if (previousWindowDescriptor) { + Object.defineProperty(globalThis, 'window', previousWindowDescriptor); + } else { + Reflect.deleteProperty(globalThis, 'window'); + } + } + }); + + it('uses the host visibility revision when the main window requests an AI hide', async () => { + const previousWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window'); + const eventTarget = new EventTarget(); + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: Object.assign(eventTarget, { + clearTimeout: globalThis.clearTimeout, + innerWidth: 440, + outerHeight: 720, + outerWidth: 440, + screenX: 80, + screenY: 60, + setTimeout: globalThis.setTimeout, + }), + }); + const bootstrap: NativeDetachedWindowBootstrap = { + id: 'ai-chat', + kind: 'ai-chat', + title: 'GoNavi AI', + payload: { storeState: { appearance: { uiVersion: 'v2' }, theme: 'light' } }, + }; + const client = { + load: vi.fn(async () => bootstrap), + ready: vi.fn(async () => undefined), + sync: vi.fn(async () => undefined), + attach: vi.fn(async () => undefined), + hide: vi.fn(async () => 99), + close: vi.fn(async () => undefined), + openAISettings: vi.fn(async () => undefined), + closeCurrentWindow: vi.fn(async () => undefined), + hideCurrentWindow: vi.fn(async () => undefined), + }; + + try { + let renderer: TestRenderer.ReactTestRenderer; + await act(async () => { + renderer = TestRenderer.create(); + await flushEffects(); + }); + const hideEvent = new Event('gonavi:native-detached-request-hide'); + Object.defineProperty(hideEvent, 'detail', { value: { visibilityRevision: 12 } }); + await act(async () => { + eventTarget.dispatchEvent(hideEvent); + await flushEffects(); + await flushEffects(); + }); + + expect(client.sync).toHaveBeenCalledWith(expect.objectContaining({ + id: 'ai-chat', + kind: 'ai-chat', + })); + expect(client.hide).not.toHaveBeenCalled(); + expect(client.hideCurrentWindow).toHaveBeenCalledWith(12); + await act(async () => renderer!.unmount()); + } finally { + if (previousWindowDescriptor) { + Object.defineProperty(globalThis, 'window', previousWindowDescriptor); + } else { + Reflect.deleteProperty(globalThis, 'window'); + } + } + }); + it('locks AI interactions while a native terminal handoff is waiting', async () => { let releaseGuard: (() => void) | undefined; aiTerminalGuard.mockImplementationOnce(() => new Promise((resolve) => { diff --git a/frontend/src/components/NativeDetachedWindowApp.tsx b/frontend/src/components/NativeDetachedWindowApp.tsx index 1c3ccb3a..5ef7d659 100644 --- a/frontend/src/components/NativeDetachedWindowApp.tsx +++ b/frontend/src/components/NativeDetachedWindowApp.tsx @@ -24,6 +24,8 @@ import { closeNativeDetachedWindow, fetchNativeDetachedWindowBootstrap, hydrateNativeDetachedStore, + hideCurrentNativeDetachedWindow, + hideNativeDetachedWindow, openNativeDetachedAISettings, presentCurrentNativeDetachedWindow, readyNativeDetachedWindow, @@ -51,10 +53,9 @@ import { isShortcutMatch, resolveShortcutBinding, } from '../utils/shortcuts'; -import WorkbenchTabContent from './WorkbenchTabContent'; - const AIChatPanel = React.lazy(() => import('./AIChatPanel')); const DataGrid = React.lazy(() => import('./DataGrid')); +const WorkbenchTabContent = React.lazy(() => import('./WorkbenchTabContent')); const NativeDetachedWindowController = React.lazy( () => import('./NativeDetachedWindowController'), ); @@ -88,11 +89,13 @@ type NativeDetachedWindowClient = { ready: (payload: NativeDetachedWindowActionPayload) => Promise; sync: (payload: NativeDetachedWindowActionPayload) => Promise; attach: (payload: NativeDetachedWindowActionPayload) => Promise; + hide?: (payload: NativeDetachedWindowActionPayload) => Promise; close: (payload: NativeDetachedWindowActionPayload) => Promise; cancelCloseRequest?: (payload: NativeDetachedWindowActionPayload) => Promise; openAISettings: (payload: NativeDetachedWindowActionPayload) => Promise; hostEvent?: (payload: NativeDetachedWindowActionPayload) => Promise; closeCurrentWindow: () => Promise; + hideCurrentWindow?: (visibilityRevision: number) => Promise; cancelClose?: () => Promise; }; @@ -102,11 +105,13 @@ const defaultClient: NativeDetachedWindowClient = { ready: readyNativeDetachedWindow, sync: syncNativeDetachedWindow, attach: attachNativeDetachedWindow, + hide: hideNativeDetachedWindow, close: closeNativeDetachedWindow, cancelCloseRequest: cancelNativeDetachedWindowClose, openAISettings: openNativeDetachedAISettings, hostEvent: sendNativeDetachedHostEvent, closeCurrentWindow: closeCurrentNativeDetachedWindow, + hideCurrentWindow: hideCurrentNativeDetachedWindow, cancelClose: cancelCurrentNativeDetachedWindowClose, }; @@ -305,9 +310,12 @@ const NativeDetachedWindowApp: React.FC = ({ const [contentReady, setContentReady] = useState(false); const [controllerEnabled, setControllerEnabled] = useState(false); const markContentReady = useCallback(() => setContentReady(true), []); - const [terminalAction, setTerminalAction] = useState<'attach' | 'close' | null>(null); + const [terminalAction, setTerminalAction] = useState<'attach' | 'hide' | 'close' | null>(null); const terminalActionStartedRef = useRef(false); const terminalActionRequestedRef = useRef(false); + const activeTerminalActionRef = useRef<'attach' | 'hide' | 'close' | null>(null); + const closePreemptionRequestedRef = useRef(false); + const hideVisibilityRevisionRef = useRef(0); const aiTerminalGuardRef = useRef<(() => Promise) | null>(null); const resultSessionRef = useRef(null); const queryResultWindowRef = useRef(null); @@ -680,9 +688,27 @@ const NativeDetachedWindowApp: React.FC = ({ }; }, [bootstrap, scheduleSync]); - const requestTerminalAction = useCallback((action: 'attach' | 'close') => { - if (!bootstrap || terminalActionRequestedRef.current) return; + const requestTerminalAction = useCallback(( + action: 'attach' | 'hide' | 'close', + visibilityRevision = 0, + ) => { + if (!bootstrap) return; + if (terminalActionRequestedRef.current) { + if (action === 'close' && activeTerminalActionRef.current === 'hide') { + closePreemptionRequestedRef.current = true; + } + return; + } + if (action === 'hide' && bootstrap.kind !== 'ai-chat') return; terminalActionRequestedRef.current = true; + activeTerminalActionRef.current = action; + closePreemptionRequestedRef.current = false; + const normalizedVisibilityRevision = Math.trunc(Number(visibilityRevision)); + hideVisibilityRevisionRef.current = action === 'hide' + && Number.isFinite(normalizedVisibilityRevision) + && normalizedVisibilityRevision > 0 + ? normalizedVisibilityRevision + : 0; if (syncTimerRef.current !== null) { clearTimeout(syncTimerRef.current); syncTimerRef.current = null; @@ -701,15 +727,27 @@ const NativeDetachedWindowApp: React.FC = ({ useEffect(() => { if (typeof window === 'undefined') return undefined; const handleGracefulCloseRequest = () => requestTerminalAction('close'); + const handleHideRequest = (event: Event) => requestTerminalAction( + 'hide', + Number((event as CustomEvent<{ visibilityRevision?: unknown }>).detail?.visibilityRevision), + ); window.addEventListener( 'gonavi:native-detached-request-close', handleGracefulCloseRequest as EventListener, ); + window.addEventListener( + 'gonavi:native-detached-request-hide', + handleHideRequest as EventListener, + ); return () => { window.removeEventListener( 'gonavi:native-detached-request-close', handleGracefulCloseRequest as EventListener, ); + window.removeEventListener( + 'gonavi:native-detached-request-hide', + handleHideRequest as EventListener, + ); }; }, [requestTerminalAction]); @@ -730,6 +768,26 @@ const NativeDetachedWindowApp: React.FC = ({ ? peekQueryEditorResultSession(bootstrap.payload.tab.id) || resultSessionRef.current : null; void (async () => { + let actionToRun = terminalAction; + let closeActionSubmitted = false; + const submitPreemptingClose = async () => { + const closeWorkbench = readWorkbenchSyncData(); + await client.close(buildActionPayload( + bootstrap, + readCurrentTab(), + currentSession, + false, + readUnsyncedSqlLogs(), + nextActionRevision(), + closeWorkbench.workbenchState, + closeWorkbench.workbenchStateBase, + closeWorkbench.openedTabs, + sqlLogsClearPendingRef.current, + queryResultWindowRef.current, + )); + closeActionSubmitted = true; + actionToRun = 'close'; + }; try { await actionQueueRef.current; if (bootstrap.kind === 'ai-chat') { @@ -739,7 +797,8 @@ const NativeDetachedWindowApp: React.FC = ({ } await flushAIChatSessionPersistence(); } - if (terminalAction === 'attach' && bootstrap.kind === 'workbench') { + actionToRun = closePreemptionRequestedRef.current ? 'close' : terminalAction; + if (actionToRun === 'attach' && bootstrap.kind === 'workbench') { const finalSqlLogs = readUnsyncedSqlLogs(); const clearSqlLogs = sqlLogsClearPendingRef.current; const finalWorkbench = readWorkbenchSyncData(); @@ -777,7 +836,7 @@ const NativeDetachedWindowApp: React.FC = ({ bootstrap, readCurrentTab(), currentSession, - terminalAction === 'attach', + actionToRun === 'attach', readUnsyncedSqlLogs(), nextActionRevision(), terminalWorkbench.workbenchState, @@ -786,47 +845,113 @@ const NativeDetachedWindowApp: React.FC = ({ sqlLogsClearPendingRef.current, queryResultWindowRef.current, ); - if (terminalAction === 'attach') { + if (actionToRun === 'attach') { await client.attach(payload); + } else if (actionToRun === 'hide') { + let visibilityRevision = hideVisibilityRevisionRef.current; + if (visibilityRevision > 0) { + await client.sync(payload); + } else { + if (!client.hide) throw new Error('Native detached hide action is unavailable'); + visibilityRevision = await client.hide(payload); + } + if (closePreemptionRequestedRef.current) { + await submitPreemptingClose(); + } else { + if (!client.hideCurrentWindow) { + throw new Error('Native detached hide control is unavailable'); + } + await client.hideCurrentWindow(visibilityRevision); + if (closePreemptionRequestedRef.current) { + await submitPreemptingClose(); + } + } } else { await client.close(payload); + closeActionSubmitted = true; } } catch (error) { - console.error(`[Native Detached Window] Failed to ${terminalAction}`, error); - const cancelWorkbench = readWorkbenchSyncData(); - const cancelPayload = buildActionPayload( - bootstrap, - readCurrentTab(), - currentSession, - false, - readUnsyncedSqlLogs(), - nextActionRevision(), - cancelWorkbench.workbenchState, - cancelWorkbench.workbenchStateBase, - cancelWorkbench.openedTabs, - sqlLogsClearPendingRef.current, - queryResultWindowRef.current, - ); - try { - await client.cancelCloseRequest?.(cancelPayload); - } catch (parentCancelError) { - console.error( - '[Native Detached Window] Failed to cancel parent close fallback', - parentCancelError, - ); + console.error(`[Native Detached Window] Failed to ${actionToRun}`, error); + if (closePreemptionRequestedRef.current && !closeActionSubmitted) { + try { + await submitPreemptingClose(); + } catch (closeError) { + console.error('[Native Detached Window] Failed to continue with requested close', closeError); + } } - try { - await client.cancelClose?.(); - } catch (localCancelError) { - console.error( - '[Native Detached Window] Failed to cancel local close fallback', - localCancelError, - ); + if (actionToRun === 'hide' && !closeActionSubmitted) { + const visibilityRevision = hideVisibilityRevisionRef.current; + if (visibilityRevision > 0) { + try { + await client.hideCurrentWindow?.(visibilityRevision); + } catch (localHideError) { + console.error('[Native Detached Window] Failed to apply requested hide', localHideError); + } + } + if (closePreemptionRequestedRef.current && !closeActionSubmitted) { + try { + await submitPreemptingClose(); + } catch (closeError) { + console.error('[Native Detached Window] Failed to continue with requested close', closeError); + } + } } + if (actionToRun === 'hide' && !closeActionSubmitted) { + terminalActionStartedRef.current = false; + terminalActionRequestedRef.current = false; + activeTerminalActionRef.current = null; + closePreemptionRequestedRef.current = false; + hideVisibilityRevisionRef.current = 0; + setTerminalAction(null); + return; + } + if (!closeActionSubmitted) { + const cancelWorkbench = readWorkbenchSyncData(); + const cancelPayload = buildActionPayload( + bootstrap, + readCurrentTab(), + currentSession, + false, + readUnsyncedSqlLogs(), + nextActionRevision(), + cancelWorkbench.workbenchState, + cancelWorkbench.workbenchStateBase, + cancelWorkbench.openedTabs, + sqlLogsClearPendingRef.current, + queryResultWindowRef.current, + ); + try { + await client.cancelCloseRequest?.(cancelPayload); + } catch (parentCancelError) { + console.error( + '[Native Detached Window] Failed to cancel parent close fallback', + parentCancelError, + ); + } + try { + await client.cancelClose?.(); + } catch (localCancelError) { + console.error( + '[Native Detached Window] Failed to cancel local close fallback', + localCancelError, + ); + } + terminalActionStartedRef.current = false; + terminalActionRequestedRef.current = false; + activeTerminalActionRef.current = null; + closePreemptionRequestedRef.current = false; + setTerminalAction(null); + setContentMounted(true); + return; + } + } + if (actionToRun === 'hide') { terminalActionStartedRef.current = false; terminalActionRequestedRef.current = false; + activeTerminalActionRef.current = null; + closePreemptionRequestedRef.current = false; + hideVisibilityRevisionRef.current = 0; setTerminalAction(null); - setContentMounted(true); return; } try { @@ -848,6 +973,10 @@ const NativeDetachedWindowApp: React.FC = ({ terminalAction, ]); + const requestWindowClose = useCallback(() => { + requestTerminalAction(bootstrap?.kind === 'ai-chat' ? 'hide' : 'close'); + }, [bootstrap?.kind, requestTerminalAction]); + const chromeLabels = useMemo(() => ({ attach: bootstrap?.kind === 'workbench' ? translate('tab_manager.detached.restore') @@ -1018,7 +1147,7 @@ const NativeDetachedWindowApp: React.FC = ({ icon={} aria-label={chromeLabels.close} disabled={!bootstrap || Boolean(terminalAction)} - onClick={() => requestTerminalAction('close')} + onClick={requestWindowClose} /> @@ -1036,7 +1165,7 @@ const NativeDetachedWindowApp: React.FC = ({ bootstrap={bootstrap} onContentReady={markContentReady} onAttach={() => requestTerminalAction('attach')} - onClose={() => requestTerminalAction('close')} + onClose={requestWindowClose} onOpenSettings={requestOpenAISettings} onRegisterAITerminalGuard={(guard) => { aiTerminalGuardRef.current = guard; diff --git a/frontend/src/components/NativeDetachedWindowController.test.ts b/frontend/src/components/NativeDetachedWindowController.test.ts index 76b0d863..6cb03492 100644 --- a/frontend/src/components/NativeDetachedWindowController.test.ts +++ b/frontend/src/components/NativeDetachedWindowController.test.ts @@ -3,6 +3,10 @@ import TestRenderer, { act } from 'react-test-renderer'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { useStore } from '../store'; +import { + clearNativeDetachedHostEvents, + recordNativeDetachedVisibilityRevision, +} from '../utils/nativeDetachedWindowHost'; import { peekQueryEditorResultSession } from '../utils/queryEditorResultSessionCache'; import { applyNativeDetachedWindowEvent, @@ -20,6 +24,7 @@ const buildQueryTab = (id: string, query: string) => ({ describe('NativeDetachedWindowController', () => { beforeEach(() => { + clearNativeDetachedHostEvents('ai-chat'); useStore.setState({ tabs: [buildQueryTab('query-a', 'select 1'), buildQueryTab('query-b', 'select 2')], activeTabId: 'query-a', @@ -523,6 +528,43 @@ describe('NativeDetachedWindowController', () => { expect(onOpenAISettings).toHaveBeenCalledOnce(); }); + it('raises the main window without restoring a maximized window before opening settings', () => { + const previousWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window'); + const calls: string[] = []; + const windowUnminimise = vi.fn(() => calls.push('unminimise-window')); + const show = vi.fn(() => calls.push('show-app')); + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + runtime: { + WindowUnminimise: windowUnminimise, + Show: show, + WindowShow: vi.fn(() => calls.push('show-window')), + }, + }, + }); + + try { + applyNativeDetachedWindowEvent({ + id: 'ai-chat', + kind: 'ai-chat', + action: 'open-ai-settings', + }, undefined, { + onOpenAISettings: () => calls.push('open-settings'), + }); + + expect(windowUnminimise).not.toHaveBeenCalled(); + expect(show).toHaveBeenCalledOnce(); + expect(calls).toEqual(['show-app', 'show-window', 'open-settings']); + } finally { + if (previousWindowDescriptor) { + Object.defineProperty(globalThis, 'window', previousWindowDescriptor); + } else { + Reflect.deleteProperty(globalThis, 'window'); + } + } + }); + it('reattaches, closes, and crash-recovers the native AI window', () => { useStore.setState({ aiPanelVisible: true, @@ -735,6 +777,73 @@ describe('NativeDetachedWindowController', () => { expect(useStore.getState().activeTabId).toBe('query-a'); }); + it('parks a native AI child without discarding its detached identity', () => { + useStore.setState({ + aiPanelVisible: true, + detachedAIChatWindow: { x: 20, y: 30, width: 440, height: 720, zIndex: 1203 }, + aiChatHistory: { + 'session-1': [{ id: 'message-1', role: 'assistant', content: 'kept', timestamp: 1 }], + }, + }); + + applyNativeDetachedWindowEvent({ + id: 'ai-chat', + kind: 'ai-chat', + action: 'hide', + payload: { visibilityRevision: 3 }, + }); + + expect(useStore.getState().aiPanelVisible).toBe(false); + expect(useStore.getState().detachedAIChatWindow).toEqual(expect.objectContaining({ + width: 440, + height: 720, + })); + expect(useStore.getState().aiChatHistory['session-1'][0]?.content).toBe('kept'); + }); + + it('ignores a delayed hide event older than the latest native focus', () => { + useStore.setState({ + aiPanelVisible: true, + detachedAIChatWindow: { x: 20, y: 30, width: 440, height: 720, zIndex: 1203 }, + }); + recordNativeDetachedVisibilityRevision('ai-chat', 7); + + applyNativeDetachedWindowEvent({ + id: 'ai-chat', + kind: 'ai-chat', + action: 'hide', + payload: { visibilityRevision: 6 }, + }); + + expect(useStore.getState().aiPanelVisible).toBe(true); + expect(useStore.getState().detachedAIChatWindow).not.toBeNull(); + + applyNativeDetachedWindowEvent({ + id: 'ai-chat', + kind: 'ai-chat', + action: 'hide', + payload: { visibilityRevision: 8 }, + }); + expect(useStore.getState().aiPanelVisible).toBe(false); + }); + + it('drops a parked AI identity when its child process exits', () => { + useStore.setState({ + aiPanelVisible: false, + detachedAIChatWindow: { x: 20, y: 30, width: 440, height: 720, zIndex: 1203 }, + }); + + applyNativeDetachedWindowEvent({ + id: 'ai-chat', + kind: 'ai-chat', + action: 'close', + payload: { reason: 'process-error', exited: true }, + }); + + expect(useStore.getState().aiPanelVisible).toBe(false); + expect(useStore.getState().detachedAIChatWindow).toBeNull(); + }); + it('closes only the tab whose native window sent an explicit close action', () => { const event: NativeDetachedWindowEvent = { id: 'workbench:query-a', diff --git a/frontend/src/components/NativeDetachedWindowController.tsx b/frontend/src/components/NativeDetachedWindowController.tsx index 597740e0..266f1a1b 100644 --- a/frontend/src/components/NativeDetachedWindowController.tsx +++ b/frontend/src/components/NativeDetachedWindowController.tsx @@ -1,6 +1,6 @@ import { useEffect } from 'react'; -import { EventsOn, WindowShow } from '../../wailsjs/runtime'; +import { EventsOn, Show, WindowShow } from '../../wailsjs/runtime'; import { type SqlLog, useStore } from '../store'; import type { TabData } from '../types'; import type { DetachedQueryResultWindow } from '../utils/detachedWindow'; @@ -9,6 +9,8 @@ import { closeNativeDetachedWindowById, forwardNativeDetachedHostEvent, hasNativeDetachedWindowManager, + hideNativeDetachedWindowById, + shouldApplyNativeDetachedHideRevision, syncNativeAIChatHostState, syncNativeDetachedShortcutOptions, } from '../utils/nativeDetachedWindowHost'; @@ -37,6 +39,7 @@ export type NativeDetachedWindowEvent = { | 'opened' | 'sync' | 'attach' + | 'hide' | 'close' | 'cancel-close' | 'open-ai-settings' @@ -183,9 +186,10 @@ const restoreQueryResult = (windowId: string): void => { }; const showMainWindow = (): void => { - if (typeof window !== 'undefined' && typeof (window as any).runtime?.WindowShow === 'function') { - void WindowShow(); - } + if (typeof window === 'undefined') return; + const runtime = (window as any).runtime; + if (typeof runtime?.Show === 'function') Show(); + if (typeof runtime?.WindowShow === 'function') WindowShow(); }; export const applyNativeDetachedWindowEvent = ( @@ -354,6 +358,13 @@ export const applyNativeDetachedWindowEvent = ( } if (event.action === 'sync') return; + if (event.action === 'hide') { + if (event.kind === 'ai-chat') { + if (!shouldApplyNativeDetachedHideRevision(id, event.payload?.visibilityRevision)) return; + useStore.getState().setAIPanelVisible(false); + } + return; + } if (event.action === 'close') { clearNativeDetachedHostEvents(id); callbacks.workbenchStateSources?.delete(id); @@ -381,13 +392,22 @@ export const applyNativeDetachedWindowEvent = ( } if (event.payload?.exited === true) { if (stillDetached) { - useStore.getState().attachAIChatPanel(); - showMainWindow(); + if (useStore.getState().aiPanelVisible) { + useStore.getState().attachAIChatPanel(); + showMainWindow(); + } else { + useStore.setState({ detachedAIChatWindow: null }); + } } return; } if (!stillDetached) return; useStore.getState().setAIPanelVisible(false); + if (event.action === 'close') { + // A real close discards the parked-process identity; only `hide` keeps it + // for the next warm reopen. + useStore.setState({ detachedAIChatWindow: null }); + } } else if (event.kind === 'workbench') { const tabId = tab?.id || id.replace(/^workbench:/, ''); const reason = String(event.payload?.reason || '').trim(); @@ -507,6 +527,7 @@ const NativeDetachedWindowController = ({ }); }); let previousIds = currentNativeWindowIds(); + let previousAIVisible = useStore.getState().aiPanelVisible; let previousAIHostStateRefs = readAIHostStateRefs(); let previousShortcutOptions = useStore.getState().shortcutOptions; let aiHostSyncTimer: ReturnType | null = null; @@ -522,10 +543,14 @@ const NativeDetachedWindowController = ({ }; if (!currentWindowId && useStore.getState().detachedAIChatWindow) { scheduleAIHostStateSync(); + if (!previousAIVisible) { + void hideNativeDetachedWindowById('ai-chat').catch(() => undefined); + } } const unsubscribeStore = useStore.subscribe(() => { + const nextState = useStore.getState(); const nextIds = currentNativeWindowIds(); - const nextShortcutOptions = useStore.getState().shortcutOptions; + const nextShortcutOptions = nextState.shortcutOptions; const newlyOpenedIds = new Set(); const aiWindowJustOpened = !previousIds.has('ai-chat') && nextIds.has('ai-chat'); for (const id of nextIds) { @@ -547,6 +572,15 @@ const NativeDetachedWindowController = ({ } } previousIds = nextIds; + if ( + !currentWindowId + && previousAIVisible + && !nextState.aiPanelVisible + && nextState.detachedAIChatWindow + ) { + void hideNativeDetachedWindowById('ai-chat').catch(() => undefined); + } + previousAIVisible = nextState.aiPanelVisible; const shortcutOptionsChanged = nextShortcutOptions !== previousShortcutOptions; if (shortcutOptionsChanged) { previousShortcutOptions = nextShortcutOptions; diff --git a/frontend/src/components/WorkbenchTabContent.tsx b/frontend/src/components/WorkbenchTabContent.tsx index 854e5f1e..e952db5b 100644 --- a/frontend/src/components/WorkbenchTabContent.tsx +++ b/frontend/src/components/WorkbenchTabContent.tsx @@ -1,6 +1,8 @@ import React from 'react'; import { Spin } from 'antd'; import type { TabData } from '../types'; +import '../styles/v2-theme-workbench.css'; + const DataViewer = React.lazy(() => import('./DataViewer')); const QueryEditor = React.lazy(() => import('./QueryEditor')); const TableDesigner = React.lazy(() => import('./TableDesigner')); diff --git a/frontend/src/nativeDetachedMain.styles.test.ts b/frontend/src/nativeDetachedMain.styles.test.ts index 1c348f7e..cf223b50 100644 --- a/frontend/src/nativeDetachedMain.styles.test.ts +++ b/frontend/src/nativeDetachedMain.styles.test.ts @@ -6,11 +6,54 @@ const detachedEntrySource = readFileSync( fileURLToPath(new globalThis.URL('./nativeDetachedMain.tsx', import.meta.url)), 'utf8', ); +const detachedAppSource = readFileSync( + fileURLToPath(new globalThis.URL('./components/NativeDetachedWindowApp.tsx', import.meta.url)), + 'utf8', +); +const aiChatPanelSource = readFileSync( + fileURLToPath(new globalThis.URL('./components/AIChatPanel.tsx', import.meta.url)), + 'utf8', +); +const dataGridSource = readFileSync( + fileURLToPath(new globalThis.URL('./components/DataGrid.tsx', import.meta.url)), + 'utf8', +); +const workbenchContentSource = readFileSync( + fileURLToPath(new globalThis.URL('./components/WorkbenchTabContent.tsx', import.meta.url)), + 'utf8', +); +const workbenchThemeSource = readFileSync( + fileURLToPath(new globalThis.URL('./styles/v2-theme-workbench.css', import.meta.url)), + 'utf8', +); +const aiThemeSource = readFileSync( + fileURLToPath(new globalThis.URL('./styles/v2-theme-ai.css', import.meta.url)), + 'utf8', +); describe('native detached window styles', () => { - it('loads the app and workbench styles from the detached entry', () => { + it('keeps feature styles out of the detached entry bootstrap', () => { expect(detachedEntrySource).toContain("import './App.css';"); expect(detachedEntrySource).toContain("import './v2-theme.css';"); - expect(detachedEntrySource).toContain("import './styles/v2-theme-workbench.css';"); + expect(detachedEntrySource).not.toContain("import './styles/v2-theme-workbench.css';"); + expect(detachedEntrySource).not.toContain("import './styles/v2-theme-ai.css';"); + }); + + it('loads workbench and AI assets only from their feature paths', () => { + expect(detachedAppSource).toContain( + "const WorkbenchTabContent = React.lazy(() => import('./WorkbenchTabContent'));", + ); + expect(detachedAppSource).not.toMatch(/import\s+WorkbenchTabContent\s+from/); + expect(aiChatPanelSource).toContain("import '../styles/v2-theme-ai.css';"); + expect(workbenchContentSource).toContain("import '../styles/v2-theme-workbench.css';"); + expect(dataGridSource).toContain("import '../styles/v2-theme-workbench.css';"); + expect(workbenchThemeSource).not.toContain('.gn-v2-ai-panel'); + expect(aiThemeSource).not.toContain('.gn-v2-data-grid-column-quick-find'); + }); + + it('keeps the v2 AI header actions inside the frameless window edge', () => { + expect(aiThemeSource).toMatch( + /body\[data-ui-version="v2"\] \.gn-v2-ai-header-top \{[^}]*box-sizing: border-box;/s, + ); }); }); diff --git a/frontend/src/nativeDetachedMain.tsx b/frontend/src/nativeDetachedMain.tsx index 48770ea4..2eabf2c0 100644 --- a/frontend/src/nativeDetachedMain.tsx +++ b/frontend/src/nativeDetachedMain.tsx @@ -3,7 +3,6 @@ import ReactDOM from 'react-dom/client'; import './App.css'; import './v2-theme.css'; -import './styles/v2-theme-workbench.css'; import NativeDetachedWindowApp from './components/NativeDetachedWindowApp'; import { setCurrentLanguage } from './i18n'; diff --git a/frontend/src/store.test.ts b/frontend/src/store.test.ts index 008fb9f1..b201b139 100644 --- a/frontend/src/store.test.ts +++ b/frontend/src/store.test.ts @@ -2193,9 +2193,21 @@ describe('store appearance persistence', () => { expect(useStore.getState().detachedAIChatWindow?.height).toBe(560); useStore.getState().setAIPanelVisible(false); - expect(useStore.getState().detachedAIChatWindow).toBeNull(); + expect(useStore.getState().detachedAIChatWindow).toEqual(expect.objectContaining({ + width: 500, + height: 560, + })); + expect(useStore.getState().isAIChatDetached()).toBe(true); expect(useStore.getState().aiPanelVisible).toBe(false); expect(useStore.getState().aiChatDetachedBoundsMemory?.width).toBe(500); + + useStore.getState().setAIChatOpenMode('detached'); + useStore.getState().setAIPanelVisible(true); + expect(useStore.getState().aiPanelVisible).toBe(true); + expect(useStore.getState().detachedAIChatWindow).toEqual(expect.objectContaining({ + width: 500, + height: 560, + })); }); it('opens AI chat according to the configured default open mode', async () => { diff --git a/frontend/src/store.ts b/frontend/src/store.ts index 877a4e5a..0d832153 100644 --- a/frontend/src/store.ts +++ b/frontend/src/store.ts @@ -5150,14 +5150,16 @@ export const useStore = create()( toggleAIPanel: () => set((state) => { const nextVisible = !state.aiPanelVisible; - // 关闭面板时一并收起独立窗,并记下尺寸 + // 关闭独立 AI 面板时保留窗口意图和尺寸。桌面端会把原生子窗 + // 隐藏保活,下一次打开可直接聚焦;浏览器浮窗也会被 + // aiPanelVisible 门禁隐藏,不会继续占用界面。 if (!nextVisible) { const memory = state.detachedAIChatWindow ? toAIChatDetachedBoundsMemory(state.detachedAIChatWindow) : state.aiChatDetachedBoundsMemory; return { aiPanelVisible: false, - detachedAIChatWindow: null, + detachedAIChatWindow: state.detachedAIChatWindow, aiChatDetachedBoundsMemory: memory, }; } @@ -5210,7 +5212,7 @@ export const useStore = create()( : state.aiChatDetachedBoundsMemory; return { aiPanelVisible: false, - detachedAIChatWindow: null, + detachedAIChatWindow: state.detachedAIChatWindow, aiChatDetachedBoundsMemory: memory, }; } diff --git a/frontend/src/styles/v2-theme-ai.css b/frontend/src/styles/v2-theme-ai.css new file mode 100644 index 00000000..db3899f6 --- /dev/null +++ b/frontend/src/styles/v2-theme-ai.css @@ -0,0 +1,902 @@ +/* ─── V2 AI side panel ─ */ +body[data-ui-version="v2"] .gn-v2-ai-panel { + width: 340px; + background: var(--gn-bg-panel) !important; + border-left: 0.5px solid var(--gn-br-1) !important; +} + +body[data-ui-version="v2"] .gn-v2-ai-panel .ai-chat-header { + min-height: 78px; + padding: 0 !important; + display: flex !important; + flex-direction: column !important; + align-items: stretch !important; + justify-content: flex-start !important; + gap: 0 !important; + background: var(--gn-bg-panel-2) !important; + border-bottom: 0.5px solid var(--gn-br-1) !important; + box-sizing: border-box; + overflow: hidden; +} + +body[data-ui-version="v2"] .gn-v2-ai-header-top, +body[data-ui-version="v2"] .gn-v2-ai-brand, +body[data-ui-version="v2"] .gn-v2-ai-header-actions, +body[data-ui-version="v2"] .gn-v2-ai-mode-tabs, +body[data-ui-version="v2"] .gn-v2-ai-session-row, +body[data-ui-version="v2"] .gn-v2-ai-suggestion-divider, +body[data-ui-version="v2"] .gn-v2-ai-suggestion-list button, +body[data-ui-version="v2"] .gn-v2-ai-context-row, +body[data-ui-version="v2"] .gn-v2-ai-context-toggle, +body[data-ui-version="v2"] .gn-v2-ai-context-add, +body[data-ui-version="v2"] .gn-v2-ai-input-actions, +body[data-ui-version="v2"] .gn-v2-ai-token-meter, +body[data-ui-version="v2"] .gn-v2-ai-token-bar { + display: flex; + align-items: center; +} + +body[data-ui-version="v2"] .gn-v2-ai-header-top { + justify-content: space-between; + gap: 8px; + min-width: 0; + width: 100%; + box-sizing: border-box; + flex: 0 0 auto; + height: 44px; + padding: 0 8px 0 12px; + border-bottom: 0.5px solid var(--gn-br-1); +} + +body[data-ui-version="v2"] .gn-v2-ai-brand { + min-width: 0; + flex: 1 1 auto; + overflow: hidden; +} + +body[data-ui-version="v2"] .gn-v2-ai-header-actions { + gap: 1px; + flex: 0 0 auto; +} + +body[data-ui-version="v2"] .gn-v2-ai-header-actions .ant-btn { + width: 24px !important; + height: 24px !important; + padding: 0 !important; + border-color: transparent !important; +} + +body[data-ui-version="v2"] .gn-v2-ai-panel .ai-logo { + background: linear-gradient(135deg, #0284c7 0%, #0369a1 100%) !important; + color: #fff !important; + box-shadow: inset 0 0.5px 0 rgba(255,255,255,0.3); +} + +body[data-ui-version="v2"] .gn-v2-ai-panel .ai-title { + color: var(--gn-fg-1) !important; + font-size: 13px !important; + font-weight: 750 !important; + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +body[data-ui-version="v2"] .gn-v2-ai-panel .ai-title-stack { + min-width: 0; + display: flex; + flex-direction: column; + gap: 1px; +} + +body[data-ui-version="v2"] .gn-v2-ai-panel .ai-title-stack small { + max-width: 150px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--gn-fg-5); + font-size: 10.5px; + font-family: var(--gn-font-mono); +} + +body[data-ui-version="v2"] .gn-v2-ai-provider-badge { + display: inline-flex; + align-items: center; + flex: 0 0 auto; + height: 17px; + padding: 0 5px; + border-radius: 3px; + background: var(--gn-accent-soft); + color: var(--gn-accent-2); + font-size: 9.5px; + font-weight: 800; + letter-spacing: 0; +} + +body[data-ui-version="v2"] .gn-v2-ai-mode-tabs { + gap: 2px; + width: 100%; + flex: 0 0 auto; + padding: 8px 10px 4px; +} + +body[data-ui-version="v2"] .gn-v2-ai-mode-tabs button { + flex: 1 1 0; + min-width: 0; + height: 26px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--gn-fg-4); + font-size: 11.5px; + font-weight: 600; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; + white-space: nowrap; + overflow: hidden; +} + +body[data-ui-version="v2"] .gn-v2-ai-mode-tabs button span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +body[data-ui-version="v2"] .gn-v2-ai-mode-tabs button.is-active { + background: var(--gn-bg-active); + color: var(--gn-fg-1); +} + +body[data-ui-version="v2"] .gn-v2-ai-panel .ai-chat-messages { + padding: 8px 14px 12px; + gap: 10px; + background: var(--gn-bg-panel); +} + +body[data-ui-version="v2"] .gn-v2-ai-panel .ai-chat-welcome { + flex: 0 0 auto !important; + align-items: stretch !important; + justify-content: flex-start; + padding: 16px 0 4px !important; + gap: 14px; +} + +body[data-ui-version="v2"] .gn-v2-ai-welcome-title { + display: flex; + align-items: center; + gap: 8px; +} + +body[data-ui-version="v2"] .gn-v2-ai-welcome-title > span { + width: 26px; + height: 26px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 7px; + background: var(--gn-info-soft); +} + +body[data-ui-version="v2"] .gn-v2-ai-context-inline { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 1px 5px; + border-radius: 4px; + background: var(--gn-info-soft); + color: var(--gn-info); + font-family: var(--gn-font-mono); + font-size: 11px; + font-weight: 650; +} + +body[data-ui-version="v2"] .gn-v2-ai-quick-icon { + width: 20px; + height: 20px; + flex: 0 0 20px; + display: inline-flex !important; + align-items: center; + justify-content: center; + border-radius: 5px; +} + +body[data-ui-version="v2"] .gn-v2-ai-quick-card.tone-info .gn-v2-ai-quick-icon { + background: var(--gn-info-soft); + color: var(--gn-info); +} + +body[data-ui-version="v2"] .gn-v2-ai-quick-card.tone-success .gn-v2-ai-quick-icon { + background: var(--gn-accent-soft); + color: var(--gn-accent-2); +} + +body[data-ui-version="v2"] .gn-v2-ai-quick-card.tone-warn .gn-v2-ai-quick-icon { + background: var(--gn-warn-soft); + color: var(--gn-warn); +} + +body[data-ui-version="v2"] .gn-v2-ai-quick-card.tone-purple .gn-v2-ai-quick-icon { + background: rgba(168,85,247,0.12); + color: #9333ea; +} + +body[data-ui-version="v2"] .gn-v2-ai-panel .quick-actions { + display: grid !important; + grid-template-columns: 1fr 1fr; + gap: 8px !important; + justify-content: stretch !important; +} + +body[data-ui-version="v2"] .gn-v2-ai-panel .quick-action-btn { + min-height: 66px; + padding: 10px 11px !important; + display: flex !important; + flex-direction: column; + align-items: flex-start; + justify-content: center; + gap: 4px; + border-radius: 8px !important; + background: var(--gn-bg-panel-2) !important; + color: var(--gn-fg-2) !important; + border-color: var(--gn-br-2) !important; + text-align: left !important; +} + +body[data-ui-version="v2"] .gn-v2-ai-panel .quick-action-btn:hover { + background: var(--gn-bg-hover) !important; + border-color: var(--gn-br-3) !important; +} + +body[data-ui-version="v2"] .gn-v2-ai-panel .quick-action-btn strong { + color: var(--gn-fg-1); + font-size: 12.5px; +} + +body[data-ui-version="v2"] .gn-v2-ai-panel .quick-action-btn span { + color: var(--gn-fg-4); + font-size: 11px; + line-height: 1.35; +} + +body[data-ui-version="v2"] .gn-v2-ai-suggestion-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +body[data-ui-version="v2"] .gn-v2-ai-suggestion-divider { + gap: 8px; + padding: 2px 0; +} + +body[data-ui-version="v2"] .gn-v2-ai-suggestion-divider span { + height: 1px; + flex: 1; + background: var(--gn-br-1); +} + +body[data-ui-version="v2"] .gn-v2-ai-suggestion-divider small { + color: var(--gn-fg-5); + font-size: 10px; + font-weight: 700; +} + +body[data-ui-version="v2"] .gn-v2-ai-suggestion-list button { + width: 100%; + gap: 8px; + min-height: 31px; + padding: 7px 10px; + border: 0.5px solid var(--gn-br-1); + border-radius: 7px; + background: transparent; + color: var(--gn-fg-2); + text-align: left; + font-size: 12px; +} + +body[data-ui-version="v2"] .gn-v2-ai-suggestion-list button .anticon { + color: var(--gn-info); + flex: 0 0 auto; +} + +body[data-ui-version="v2"] .gn-v2-ai-insights-list, +body[data-ui-version="v2"] .gn-v2-ai-history-list { + flex: 1 1 auto; + min-height: 0; + padding: 8px 2px 12px; + display: flex; + flex-direction: column; + gap: 8px; +} + +body[data-ui-version="v2"] .gn-v2-ai-insight-card { + display: flex; + align-items: flex-start; + gap: 9px; + padding: 10px; + border: 0.5px solid var(--gn-br-1); + border-radius: 8px; + background: var(--gn-bg-panel-2); +} + +body[data-ui-version="v2"] .gn-v2-ai-insight-card.tone-info .gn-v2-ai-insight-icon { + background: var(--gn-info-soft); + color: var(--gn-info); +} + +body[data-ui-version="v2"] .gn-v2-ai-insight-card.tone-accent .gn-v2-ai-insight-icon { + background: var(--gn-accent-soft); + color: var(--gn-accent-2); +} + +body[data-ui-version="v2"] .gn-v2-ai-insight-card.tone-warn .gn-v2-ai-insight-icon { + background: var(--gn-warn-soft); + color: var(--gn-warn); +} + +body[data-ui-version="v2"] .gn-v2-ai-insight-icon { + width: 22px; + height: 22px; + flex: 0 0 22px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 6px; +} + +body[data-ui-version="v2"] .gn-v2-ai-insight-card strong { + display: block; + color: var(--gn-fg-1); + font-size: 12.5px; + font-weight: 750; +} + +body[data-ui-version="v2"] .gn-v2-ai-insight-card p { + margin: 4px 0 0; + color: var(--gn-fg-4); + font-size: 11.5px; + line-height: 1.45; + word-break: break-word; +} + +body[data-ui-version="v2"] .gn-v2-ai-history-card { + width: 100%; + padding: 9px 10px; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 5px; + border: 0.5px solid var(--gn-br-1); + border-radius: 8px; + background: transparent; + color: var(--gn-fg-2); + text-align: left; +} + +body[data-ui-version="v2"] .gn-v2-ai-history-card.is-active { + background: var(--gn-bg-selected); + border-color: color-mix(in srgb, var(--gn-accent) 36%, var(--gn-br-1)); +} + +body[data-ui-version="v2"] .gn-v2-ai-history-card span { + display: flex; + align-items: center; + min-width: 0; + gap: 7px; +} + +body[data-ui-version="v2"] .gn-v2-ai-history-card span .anticon { + color: var(--gn-info); +} + +body[data-ui-version="v2"] .gn-v2-ai-history-card strong { + overflow: hidden; + color: var(--gn-fg-1); + font-size: 12.5px; + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +} + +body[data-ui-version="v2"] .gn-v2-ai-history-card small { + padding-left: 21px; + color: var(--gn-fg-5); + font-family: var(--gn-font-mono); + font-size: 10.5px; +} + +body[data-ui-version="v2"] .gn-v2-ai-empty-note { + padding: 32px 0; + color: var(--gn-fg-5); + font-size: 12px; + text-align: center; +} + +body[data-ui-version="v2"] .gn-v2-ai-panel .ai-chat-input-area { + padding: 10px 12px 12px !important; + border-top: 0.5px solid var(--gn-br-1) !important; + background: var(--gn-bg-panel-2); +} + +body[data-ui-version="v2"] .gn-v2-ai-panel .ai-chat-input-wrapper { + border-radius: 10px !important; + border: none !important; + background: transparent !important; + box-shadow: none !important; + gap: 4px !important; +} + +body[data-ui-version="v2"] .gn-v2-ai-context-row { + justify-content: space-between; + gap: 6px; + min-height: 22px; +} + +body[data-ui-version="v2"] .gn-v2-ai-context-toggle, +body[data-ui-version="v2"] .gn-v2-ai-context-add { + height: 22px; + border-radius: 6px; + font-size: 11.5px; + font-weight: 700; +} + +body[data-ui-version="v2"] .gn-v2-ai-context-toggle { + gap: 5px; + padding: 0 8px; + border: 0.5px solid rgba(56,189,248,0.3); + background: var(--gn-info-soft); + color: var(--gn-info); +} + +body[data-ui-version="v2"] .gn-v2-ai-context-toggle strong { + min-width: 15px; + height: 15px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 4px; + border-radius: 4px; + background: var(--gn-info); + color: var(--gn-on-info, #fff); + font-family: var(--gn-font-mono); + font-size: 10px; +} + +body[data-ui-version="v2"] .gn-v2-ai-context-toggle .anticon-down { + transition: transform 0.12s ease; +} + +body[data-ui-version="v2"] .gn-v2-ai-context-toggle.is-expanded .anticon-down { + transform: rotate(180deg); +} + +body[data-ui-version="v2"] .gn-v2-ai-context-add { + gap: 4px; + padding: 0 8px; + border: 0.5px dashed var(--gn-br-3); + background: transparent; + color: var(--gn-fg-4); +} + +body[data-ui-version="v2"] .gn-v2-ai-context-detail { + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px; + border: 0.5px solid var(--gn-br-1); + border-radius: 8px; + background: var(--gn-bg-panel-2); +} + +body[data-ui-version="v2"] .gn-v2-ai-context-detail-title { + padding: 0 4px; + color: var(--gn-fg-4); + font-size: 10px; + font-weight: 700; +} + +body[data-ui-version="v2"] .gn-v2-ai-context-table-chip { + display: inline-flex !important; + align-items: center; + gap: 6px; + min-height: 24px; + border: 0.5px solid var(--gn-br-1) !important; + border-radius: 6px !important; + background: var(--gn-bg-panel) !important; + color: var(--gn-fg-1) !important; + font-family: var(--gn-font-mono); + font-size: 11.5px !important; +} + +body[data-ui-version="v2"] .gn-v2-ai-attachment-row { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +body[data-ui-version="v2"] .gn-v2-ai-attachment-thumb { + position: relative; + width: 54px; + height: 54px; + overflow: hidden; + border: 0.5px solid var(--gn-br-2); + border-radius: 7px; +} + +body[data-ui-version="v2"] .gn-v2-ai-attachment-thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +body[data-ui-version="v2"] .gn-v2-ai-attachment-thumb button { + position: absolute; + top: 3px; + right: 3px; + width: 16px; + height: 16px; + padding: 0; + border: none; + border-radius: 50%; + background: rgba(0,0,0,0.55); + color: #fff; + font-size: 10px; +} + +body[data-ui-version="v2"] .gn-v2-ai-attachment-file { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: min(100%, 280px); + min-height: 28px; + padding: 4px 8px; + border: 0.5px solid var(--gn-br-2); + border-radius: 8px; + background: var(--gn-bg-panel); + color: var(--gn-fg-1); + font-size: 11.5px; +} + +body[data-ui-version="v2"] .gn-v2-ai-attachment-file.has-warning { + border-color: rgba(217, 119, 6, 0.45); + background: rgba(217, 119, 6, 0.08); +} + +body[data-ui-version="v2"] .gn-v2-ai-attachment-file-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +body[data-ui-version="v2"] .gn-v2-ai-attachment-file-meta { + color: var(--gn-fg-3); + flex-shrink: 0; +} + +body[data-ui-version="v2"] .gn-v2-ai-attachment-file button { + width: 16px; + height: 16px; + padding: 0; + border: none; + border-radius: 50%; + background: var(--gn-bg-subtle); + color: var(--gn-fg-3); + line-height: 1; +} + +body[data-ui-version="v2"] .gn-v2-ai-input-box { + min-height: 104px; + padding: 0; +} + +body[data-ui-version="v2"] .gn-v2-ai-input-surface { + min-height: 104px; + padding: 10px 8px 8px 12px; + border: 0.5px solid var(--gn-br-2) !important; + border-radius: 10px !important; + background: var(--gn-bg-input) !important; + box-shadow: none !important; +} + +body[data-ui-version="v2"] .gn-v2-ai-input-box textarea { + border: 0 !important; + border-radius: 0 !important; + background: transparent !important; + box-shadow: none !important; + font-size: 12.5px !important; + line-height: 1.55 !important; + padding: 0 !important; +} + +body[data-ui-version="v2"] .gn-v2-ai-input-box textarea.ant-input:focus, +body[data-ui-version="v2"] .gn-v2-ai-input-box textarea.ant-input:focus-visible { + border-color: transparent !important; + outline: none !important; + box-shadow: none !important; +} + +body[data-ui-version="v2"] .gn-v2-ai-slash-menu { + position: absolute; + bottom: 100%; + left: 0; + right: 0; + z-index: 100; + max-height: 220px; + padding: 4px; + margin-bottom: 4px; + overflow-y: auto; + border-radius: 8px; + box-shadow: var(--gn-shadow-lg); +} + +body[data-ui-version="v2"] .gn-v2-ai-input-actions { + justify-content: flex-end; + gap: 4px; + margin-top: 6px; +} + +body[data-ui-version="v2"] .gn-v2-ai-input-actions .ant-btn { + width: 24px !important; + height: 24px !important; + padding: 0 !important; +} + +/* + * 单行紧凑工具条: + * [连接] [模型] [思考] ········· [token] + * 全部固定/内容宽度,模型下拉绝不拉满整行。 + */ +body[data-ui-version="v2"] .gn-v2-ai-model-bar { + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + gap: 6px; + width: 100%; + min-width: 0; + padding-top: 2px; + overflow: visible; +} + +body[data-ui-version="v2"] .gn-v2-ai-context-chip, +body[data-ui-version="v2"] .gn-v2-ai-token-meter { + height: 22px; + display: inline-flex; + align-items: center; + gap: 4px; + padding: 0 7px; + border-radius: 6px !important; + border: 0.5px solid var(--gn-br-1) !important; + background: var(--gn-bg-active) !important; + color: var(--gn-fg-4) !important; + font-family: var(--gn-font-mono); + font-size: 10.5px; + box-sizing: border-box; +} + +/* 连接芯片:内容宽度 + 上限 */ +body[data-ui-version="v2"] .gn-v2-ai-context-chip { + flex: 0 1 auto; + width: auto; + max-width: 112px; + min-width: 0; + overflow: hidden; +} + +/* Tooltip 外包层:不破坏 flex 子项收缩 */ +body[data-ui-version="v2"] .gn-v2-ai-model-bar > span { + display: inline-flex !important; + min-width: 0; + max-width: 100%; + vertical-align: middle; +} + +body[data-ui-version="v2"] .gn-v2-ai-model-bar > span:has(.gn-v2-ai-context-chip) { + max-width: 112px; + flex: 0 1 auto; +} + +body[data-ui-version="v2"] .gn-v2-ai-model-bar > span:has(.gn-v2-ai-token-meter) { + flex: 0 0 auto; + max-width: none; + margin-left: auto; +} + +body[data-ui-version="v2"] .gn-v2-ai-token-meter { + flex: 0 0 auto; + white-space: nowrap; +} + +/* 无 Tooltip 包裹时 token 也靠右 */ +body[data-ui-version="v2"] .gn-v2-ai-model-bar > .gn-v2-ai-token-meter { + margin-left: auto; +} + +body[data-ui-version="v2"] .gn-v2-ai-context-chip > .anticon { + flex: 0 0 auto; +} + +body[data-ui-version="v2"] .gn-v2-ai-context-chip-text { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +body[data-ui-version="v2"] .gn-v2-ai-context-live-dot { + width: 5px; + height: 5px; + flex: 0 0 5px; + border-radius: 50%; + background: var(--gn-accent); +} + +/* 模型 / 思考:固定宽度,禁止被 flex 拉长 */ +body[data-ui-version="v2"] .gn-v2-ai-model-select { + width: 128px !important; + min-width: 128px !important; + max-width: 128px !important; + height: 26px !important; + flex: 0 0 128px !important; +} + +body[data-ui-version="v2"] .gn-v2-ai-thinking-select { + width: 52px !important; + min-width: 52px !important; + max-width: 52px !important; + height: 26px !important; + flex: 0 0 52px !important; +} + +/* 输入区允许 IME 候选层正常定位,避免被裁切/残留 */ +body[data-ui-version="v2"] .gn-v2-ai-composer, +body[data-ui-version="v2"] .gn-v2-ai-input-box, +body[data-ui-version="v2"] .gn-v2-ai-input-surface { + overflow: visible; +} + +body[data-ui-version="v2"] .gn-v2-ai-model-select .ant-select-selector, +body[data-ui-version="v2"] .gn-v2-ai-thinking-select .ant-select-selector { + display: flex !important; + align-items: center !important; + height: 26px !important; + min-height: 26px !important; + padding: 0 22px 0 8px !important; + border: 0.5px solid var(--gn-br-2) !important; + border-radius: 7px !important; + background: var(--gn-bg-panel) !important; + box-shadow: none !important; + font-family: var(--gn-font-mono); + font-size: 12px !important; +} + +body[data-ui-version="v2"] .gn-v2-ai-model-select.ant-select-focused .ant-select-selector, +body[data-ui-version="v2"] .gn-v2-ai-model-select:hover .ant-select-selector, +body[data-ui-version="v2"] .gn-v2-ai-thinking-select.ant-select-focused .ant-select-selector, +body[data-ui-version="v2"] .gn-v2-ai-thinking-select:hover .ant-select-selector { + border-color: var(--gn-info) !important; + box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.12) !important; +} + +body[data-ui-version="v2"] .gn-v2-ai-model-select .ant-select-selection-search, +body[data-ui-version="v2"] .gn-v2-ai-thinking-select .ant-select-selection-search { + left: 8px !important; + right: 22px !important; + inset-inline-start: 8px !important; + inset-inline-end: 22px !important; + height: 24px !important; + background: transparent !important; + border: 0 !important; + box-shadow: none !important; +} + +body[data-ui-version="v2"] .gn-v2-ai-model-select .ant-select-selection-search-input, +body[data-ui-version="v2"] .gn-v2-ai-thinking-select .ant-select-selection-search-input { + height: 24px !important; + padding: 0 !important; + border: 0 !important; + border-radius: 0 !important; + background: transparent !important; + box-shadow: none !important; + color: var(--gn-fg-2) !important; + font-family: var(--gn-font-mono) !important; + font-size: 12px !important; + line-height: 24px !important; +} + +body[data-ui-version="v2"] .gn-v2-ai-model-select .ant-select-selection-item, +body[data-ui-version="v2"] .gn-v2-ai-model-select .ant-select-selection-placeholder, +body[data-ui-version="v2"] .gn-v2-ai-thinking-select .ant-select-selection-item, +body[data-ui-version="v2"] .gn-v2-ai-thinking-select .ant-select-selection-placeholder { + display: flex !important; + align-items: center !important; + height: 24px !important; + line-height: 24px !important; + padding-inline-end: 0 !important; + background: transparent !important; + color: var(--gn-fg-2) !important; + overflow: hidden !important; + text-overflow: ellipsis !important; +} + +body[data-ui-version="v2"] .gn-v2-ai-model-select .ant-select-arrow, +body[data-ui-version="v2"] .gn-v2-ai-thinking-select .ant-select-arrow { + inset-inline-end: 7px !important; + color: var(--gn-fg-4) !important; + font-size: 11px; +} + +body[data-ui-version="v2"] .gn-v2-ai-token-meter { + flex: 0 0 auto; + white-space: nowrap; + gap: 5px !important; +} + +body[data-ui-version="v2"] .gn-v2-ai-token-meter-text { + flex: 0 0 auto; + white-space: nowrap; +} + +body[data-ui-version="v2"] .gn-v2-ai-token-meter.is-warn { + border-color: color-mix(in srgb, var(--gn-warn) 35%, transparent) !important; + color: var(--gn-warn) !important; +} + +body[data-ui-version="v2"] .gn-v2-ai-token-bar { + width: 32px; + height: 4px; + overflow: hidden; + border-radius: 2px; + background: var(--gn-bg-active); +} + +body[data-ui-version="v2"] .gn-v2-ai-token-bar span { + display: block; + height: 100%; + border-radius: inherit; + background: var(--gn-info); +} + +body[data-ui-version="v2"] .gn-v2-ai-panel .ai-chat-send-btn { + width: 28px !important; + height: 28px !important; + border-radius: 7px !important; + display: inline-flex !important; + align-items: center; + justify-content: center; + flex: 0 0 auto; + padding: 0 !important; + border: 0.5px solid var(--gn-info) !important; + background: var(--gn-info) !important; + background-color: var(--gn-info) !important; + color: var(--gn-on-info, #fff) !important; + opacity: 1 !important; + cursor: pointer; +} + +body[data-ui-version="v2"] .gn-v2-ai-panel .ai-chat-send-btn .anticon, +body[data-ui-version="v2"] .gn-v2-ai-panel .ai-chat-send-btn svg { + color: currentColor !important; + fill: currentColor !important; + font-size: 13px; +} + +body[data-ui-version="v2"] .gn-v2-ai-panel .ai-chat-send-btn:disabled { + border-color: var(--gn-br-1) !important; + background: var(--gn-bg-active) !important; + background-color: var(--gn-bg-active) !important; + color: var(--gn-fg-5) !important; + opacity: 1 !important; + cursor: not-allowed; +} + +body[data-ui-version="v2"] .gn-v2-ai-panel .ai-chat-stop-btn { + border-color: rgba(220,38,38,0.28) !important; + background: rgba(220,38,38,0.12) !important; + background-color: rgba(220,38,38,0.12) !important; + color: var(--gn-danger) !important; +} diff --git a/frontend/src/styles/v2-theme-workbench.css b/frontend/src/styles/v2-theme-workbench.css index 5863025b..2a5569cb 100644 --- a/frontend/src/styles/v2-theme-workbench.css +++ b/frontend/src/styles/v2-theme-workbench.css @@ -911,908 +911,6 @@ body[data-ui-version="v2"] .gn-v2-table-row:last-child { border-bottom: 0 !important; } -/* ─── V2 AI side panel ─ */ -body[data-ui-version="v2"] .gn-v2-ai-panel { - width: 340px; - background: var(--gn-bg-panel) !important; - border-left: 0.5px solid var(--gn-br-1) !important; -} - -body[data-ui-version="v2"] .gn-v2-ai-panel .ai-chat-header { - min-height: 78px; - padding: 0 !important; - display: flex !important; - flex-direction: column !important; - align-items: stretch !important; - justify-content: flex-start !important; - gap: 0 !important; - background: var(--gn-bg-panel-2) !important; - border-bottom: 0.5px solid var(--gn-br-1) !important; - box-sizing: border-box; - overflow: hidden; -} - -body[data-ui-version="v2"] .gn-v2-ai-header-top, -body[data-ui-version="v2"] .gn-v2-ai-brand, -body[data-ui-version="v2"] .gn-v2-ai-header-actions, -body[data-ui-version="v2"] .gn-v2-ai-mode-tabs, -body[data-ui-version="v2"] .gn-v2-ai-session-row, -body[data-ui-version="v2"] .gn-v2-ai-suggestion-divider, -body[data-ui-version="v2"] .gn-v2-ai-suggestion-list button, -body[data-ui-version="v2"] .gn-v2-ai-context-row, -body[data-ui-version="v2"] .gn-v2-ai-context-toggle, -body[data-ui-version="v2"] .gn-v2-ai-context-add, -body[data-ui-version="v2"] .gn-v2-ai-input-actions, -body[data-ui-version="v2"] .gn-v2-ai-token-meter, -body[data-ui-version="v2"] .gn-v2-ai-token-bar { - display: flex; - align-items: center; -} - -body[data-ui-version="v2"] .gn-v2-ai-header-top { - justify-content: space-between; - gap: 8px; - min-width: 0; - width: 100%; - flex: 0 0 auto; - height: 44px; - padding: 0 8px 0 12px; - border-bottom: 0.5px solid var(--gn-br-1); -} - -body[data-ui-version="v2"] .gn-v2-ai-brand { - min-width: 0; - flex: 1 1 auto; - overflow: hidden; -} - -body[data-ui-version="v2"] .gn-v2-ai-header-actions { - gap: 1px; - flex: 0 0 auto; -} - -body[data-ui-version="v2"] .gn-v2-ai-header-actions .ant-btn { - width: 24px !important; - height: 24px !important; - padding: 0 !important; - border-color: transparent !important; -} - -body[data-ui-version="v2"] .gn-v2-ai-panel .ai-logo { - background: linear-gradient(135deg, #0284c7 0%, #0369a1 100%) !important; - color: #fff !important; - box-shadow: inset 0 0.5px 0 rgba(255,255,255,0.3); -} - -body[data-ui-version="v2"] .gn-v2-ai-panel .ai-title { - color: var(--gn-fg-1) !important; - font-size: 13px !important; - font-weight: 750 !important; - display: block; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -body[data-ui-version="v2"] .gn-v2-ai-panel .ai-title-stack { - min-width: 0; - display: flex; - flex-direction: column; - gap: 1px; -} - -body[data-ui-version="v2"] .gn-v2-ai-panel .ai-title-stack small { - max-width: 150px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - color: var(--gn-fg-5); - font-size: 10.5px; - font-family: var(--gn-font-mono); -} - -body[data-ui-version="v2"] .gn-v2-ai-provider-badge { - display: inline-flex; - align-items: center; - flex: 0 0 auto; - height: 17px; - padding: 0 5px; - border-radius: 3px; - background: var(--gn-accent-soft); - color: var(--gn-accent-2); - font-size: 9.5px; - font-weight: 800; - letter-spacing: 0; -} - -body[data-ui-version="v2"] .gn-v2-ai-mode-tabs { - gap: 2px; - width: 100%; - flex: 0 0 auto; - padding: 8px 10px 4px; -} - -body[data-ui-version="v2"] .gn-v2-ai-mode-tabs button { - flex: 1 1 0; - min-width: 0; - height: 26px; - border: none; - border-radius: 6px; - background: transparent; - color: var(--gn-fg-4); - font-size: 11.5px; - font-weight: 600; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 5px; - white-space: nowrap; - overflow: hidden; -} - -body[data-ui-version="v2"] .gn-v2-ai-mode-tabs button span { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -body[data-ui-version="v2"] .gn-v2-ai-mode-tabs button.is-active { - background: var(--gn-bg-active); - color: var(--gn-fg-1); -} - -body[data-ui-version="v2"] .gn-v2-ai-panel .ai-chat-messages { - padding: 8px 14px 12px; - gap: 10px; - background: var(--gn-bg-panel); -} - -body[data-ui-version="v2"] .gn-v2-ai-panel .ai-chat-welcome { - flex: 0 0 auto !important; - align-items: stretch !important; - justify-content: flex-start; - padding: 16px 0 4px !important; - gap: 14px; -} - -body[data-ui-version="v2"] .gn-v2-ai-welcome-title { - display: flex; - align-items: center; - gap: 8px; -} - -body[data-ui-version="v2"] .gn-v2-ai-welcome-title > span { - width: 26px; - height: 26px; - display: inline-flex; - align-items: center; - justify-content: center; - border-radius: 7px; - background: var(--gn-info-soft); -} - -body[data-ui-version="v2"] .gn-v2-ai-context-inline { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 1px 5px; - border-radius: 4px; - background: var(--gn-info-soft); - color: var(--gn-info); - font-family: var(--gn-font-mono); - font-size: 11px; - font-weight: 650; -} - -body[data-ui-version="v2"] .gn-v2-ai-quick-icon { - width: 20px; - height: 20px; - flex: 0 0 20px; - display: inline-flex !important; - align-items: center; - justify-content: center; - border-radius: 5px; -} - -body[data-ui-version="v2"] .gn-v2-ai-quick-card.tone-info .gn-v2-ai-quick-icon { - background: var(--gn-info-soft); - color: var(--gn-info); -} - -body[data-ui-version="v2"] .gn-v2-ai-quick-card.tone-success .gn-v2-ai-quick-icon { - background: var(--gn-accent-soft); - color: var(--gn-accent-2); -} - -body[data-ui-version="v2"] .gn-v2-ai-quick-card.tone-warn .gn-v2-ai-quick-icon { - background: var(--gn-warn-soft); - color: var(--gn-warn); -} - -body[data-ui-version="v2"] .gn-v2-ai-quick-card.tone-purple .gn-v2-ai-quick-icon { - background: rgba(168,85,247,0.12); - color: #9333ea; -} - -body[data-ui-version="v2"] .gn-v2-ai-panel .quick-actions { - display: grid !important; - grid-template-columns: 1fr 1fr; - gap: 8px !important; - justify-content: stretch !important; -} - -body[data-ui-version="v2"] .gn-v2-ai-panel .quick-action-btn { - min-height: 66px; - padding: 10px 11px !important; - display: flex !important; - flex-direction: column; - align-items: flex-start; - justify-content: center; - gap: 4px; - border-radius: 8px !important; - background: var(--gn-bg-panel-2) !important; - color: var(--gn-fg-2) !important; - border-color: var(--gn-br-2) !important; - text-align: left !important; -} - -body[data-ui-version="v2"] .gn-v2-ai-panel .quick-action-btn:hover { - background: var(--gn-bg-hover) !important; - border-color: var(--gn-br-3) !important; -} - -body[data-ui-version="v2"] .gn-v2-ai-panel .quick-action-btn strong { - color: var(--gn-fg-1); - font-size: 12.5px; -} - -body[data-ui-version="v2"] .gn-v2-ai-panel .quick-action-btn span { - color: var(--gn-fg-4); - font-size: 11px; - line-height: 1.35; -} - -body[data-ui-version="v2"] .gn-v2-ai-suggestion-list { - display: flex; - flex-direction: column; - gap: 6px; -} - -body[data-ui-version="v2"] .gn-v2-ai-suggestion-divider { - gap: 8px; - padding: 2px 0; -} - -body[data-ui-version="v2"] .gn-v2-ai-suggestion-divider span { - height: 1px; - flex: 1; - background: var(--gn-br-1); -} - -body[data-ui-version="v2"] .gn-v2-ai-suggestion-divider small { - color: var(--gn-fg-5); - font-size: 10px; - font-weight: 700; -} - -body[data-ui-version="v2"] .gn-v2-ai-suggestion-list button { - width: 100%; - gap: 8px; - min-height: 31px; - padding: 7px 10px; - border: 0.5px solid var(--gn-br-1); - border-radius: 7px; - background: transparent; - color: var(--gn-fg-2); - text-align: left; - font-size: 12px; -} - -body[data-ui-version="v2"] .gn-v2-ai-suggestion-list button .anticon { - color: var(--gn-info); - flex: 0 0 auto; -} - -body[data-ui-version="v2"] .gn-v2-ai-insights-list, -body[data-ui-version="v2"] .gn-v2-ai-history-list { - flex: 1 1 auto; - min-height: 0; - padding: 8px 2px 12px; - display: flex; - flex-direction: column; - gap: 8px; -} - -body[data-ui-version="v2"] .gn-v2-ai-insight-card { - display: flex; - align-items: flex-start; - gap: 9px; - padding: 10px; - border: 0.5px solid var(--gn-br-1); - border-radius: 8px; - background: var(--gn-bg-panel-2); -} - -body[data-ui-version="v2"] .gn-v2-ai-insight-card.tone-info .gn-v2-ai-insight-icon { - background: var(--gn-info-soft); - color: var(--gn-info); -} - -body[data-ui-version="v2"] .gn-v2-ai-insight-card.tone-accent .gn-v2-ai-insight-icon { - background: var(--gn-accent-soft); - color: var(--gn-accent-2); -} - -body[data-ui-version="v2"] .gn-v2-ai-insight-card.tone-warn .gn-v2-ai-insight-icon { - background: var(--gn-warn-soft); - color: var(--gn-warn); -} - -body[data-ui-version="v2"] .gn-v2-ai-insight-icon { - width: 22px; - height: 22px; - flex: 0 0 22px; - display: inline-flex; - align-items: center; - justify-content: center; - border-radius: 6px; -} - -body[data-ui-version="v2"] .gn-v2-ai-insight-card strong { - display: block; - color: var(--gn-fg-1); - font-size: 12.5px; - font-weight: 750; -} - -body[data-ui-version="v2"] .gn-v2-ai-insight-card p { - margin: 4px 0 0; - color: var(--gn-fg-4); - font-size: 11.5px; - line-height: 1.45; - word-break: break-word; -} - -body[data-ui-version="v2"] .gn-v2-ai-history-card { - width: 100%; - padding: 9px 10px; - display: flex; - flex-direction: column; - align-items: stretch; - gap: 5px; - border: 0.5px solid var(--gn-br-1); - border-radius: 8px; - background: transparent; - color: var(--gn-fg-2); - text-align: left; -} - -body[data-ui-version="v2"] .gn-v2-ai-history-card.is-active { - background: var(--gn-bg-selected); - border-color: color-mix(in srgb, var(--gn-accent) 36%, var(--gn-br-1)); -} - -body[data-ui-version="v2"] .gn-v2-ai-history-card span { - display: flex; - align-items: center; - min-width: 0; - gap: 7px; -} - -body[data-ui-version="v2"] .gn-v2-ai-history-card span .anticon { - color: var(--gn-info); -} - -body[data-ui-version="v2"] .gn-v2-ai-history-card strong { - overflow: hidden; - color: var(--gn-fg-1); - font-size: 12.5px; - font-weight: 650; - text-overflow: ellipsis; - white-space: nowrap; -} - -body[data-ui-version="v2"] .gn-v2-ai-history-card small { - padding-left: 21px; - color: var(--gn-fg-5); - font-family: var(--gn-font-mono); - font-size: 10.5px; -} - -body[data-ui-version="v2"] .gn-v2-ai-empty-note { - padding: 32px 0; - color: var(--gn-fg-5); - font-size: 12px; - text-align: center; -} - -body[data-ui-version="v2"] .gn-v2-ai-panel .ai-chat-input-area { - padding: 10px 12px 12px !important; - border-top: 0.5px solid var(--gn-br-1) !important; - background: var(--gn-bg-panel-2); -} - -body[data-ui-version="v2"] .gn-v2-ai-panel .ai-chat-input-wrapper { - border-radius: 10px !important; - border: none !important; - background: transparent !important; - box-shadow: none !important; - gap: 4px !important; -} - -body[data-ui-version="v2"] .gn-v2-ai-context-row { - justify-content: space-between; - gap: 6px; - min-height: 22px; -} - -body[data-ui-version="v2"] .gn-v2-ai-context-toggle, -body[data-ui-version="v2"] .gn-v2-ai-context-add { - height: 22px; - border-radius: 6px; - font-size: 11.5px; - font-weight: 700; -} - -body[data-ui-version="v2"] .gn-v2-ai-context-toggle { - gap: 5px; - padding: 0 8px; - border: 0.5px solid rgba(56,189,248,0.3); - background: var(--gn-info-soft); - color: var(--gn-info); -} - -body[data-ui-version="v2"] .gn-v2-ai-context-toggle strong { - min-width: 15px; - height: 15px; - display: inline-flex; - align-items: center; - justify-content: center; - padding: 0 4px; - border-radius: 4px; - background: var(--gn-info); - color: var(--gn-on-info, #fff); - font-family: var(--gn-font-mono); - font-size: 10px; -} - -body[data-ui-version="v2"] .gn-v2-ai-context-toggle .anticon-down { - transition: transform 0.12s ease; -} - -body[data-ui-version="v2"] .gn-v2-ai-context-toggle.is-expanded .anticon-down { - transform: rotate(180deg); -} - -body[data-ui-version="v2"] .gn-v2-ai-context-add { - gap: 4px; - padding: 0 8px; - border: 0.5px dashed var(--gn-br-3); - background: transparent; - color: var(--gn-fg-4); -} - -body[data-ui-version="v2"] .gn-v2-ai-context-detail { - display: flex; - flex-direction: column; - gap: 6px; - padding: 8px; - border: 0.5px solid var(--gn-br-1); - border-radius: 8px; - background: var(--gn-bg-panel-2); -} - -body[data-ui-version="v2"] .gn-v2-ai-context-detail-title { - padding: 0 4px; - color: var(--gn-fg-4); - font-size: 10px; - font-weight: 700; -} - -body[data-ui-version="v2"] .gn-v2-ai-context-table-chip { - display: inline-flex !important; - align-items: center; - gap: 6px; - min-height: 24px; - border: 0.5px solid var(--gn-br-1) !important; - border-radius: 6px !important; - background: var(--gn-bg-panel) !important; - color: var(--gn-fg-1) !important; - font-family: var(--gn-font-mono); - font-size: 11.5px !important; -} - -body[data-ui-version="v2"] .gn-v2-ai-attachment-row { - display: flex; - flex-wrap: wrap; - gap: 6px; -} - -body[data-ui-version="v2"] .gn-v2-ai-attachment-thumb { - position: relative; - width: 54px; - height: 54px; - overflow: hidden; - border: 0.5px solid var(--gn-br-2); - border-radius: 7px; -} - -body[data-ui-version="v2"] .gn-v2-ai-attachment-thumb img { - width: 100%; - height: 100%; - object-fit: cover; -} - -body[data-ui-version="v2"] .gn-v2-ai-attachment-thumb button { - position: absolute; - top: 3px; - right: 3px; - width: 16px; - height: 16px; - padding: 0; - border: none; - border-radius: 50%; - background: rgba(0,0,0,0.55); - color: #fff; - font-size: 10px; -} - -body[data-ui-version="v2"] .gn-v2-ai-attachment-file { - display: inline-flex; - align-items: center; - gap: 6px; - max-width: min(100%, 280px); - min-height: 28px; - padding: 4px 8px; - border: 0.5px solid var(--gn-br-2); - border-radius: 8px; - background: var(--gn-bg-panel); - color: var(--gn-fg-1); - font-size: 11.5px; -} - -body[data-ui-version="v2"] .gn-v2-ai-attachment-file.has-warning { - border-color: rgba(217, 119, 6, 0.45); - background: rgba(217, 119, 6, 0.08); -} - -body[data-ui-version="v2"] .gn-v2-ai-attachment-file-name { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -body[data-ui-version="v2"] .gn-v2-ai-attachment-file-meta { - color: var(--gn-fg-3); - flex-shrink: 0; -} - -body[data-ui-version="v2"] .gn-v2-ai-attachment-file button { - width: 16px; - height: 16px; - padding: 0; - border: none; - border-radius: 50%; - background: var(--gn-bg-subtle); - color: var(--gn-fg-3); - line-height: 1; -} - -body[data-ui-version="v2"] .gn-v2-ai-input-box { - min-height: 104px; - padding: 0; -} - -body[data-ui-version="v2"] .gn-v2-ai-input-surface { - min-height: 104px; - padding: 10px 8px 8px 12px; - border: 0.5px solid var(--gn-br-2) !important; - border-radius: 10px !important; - background: var(--gn-bg-input) !important; - box-shadow: none !important; -} - -body[data-ui-version="v2"] .gn-v2-ai-input-box textarea { - border: 0 !important; - border-radius: 0 !important; - background: transparent !important; - box-shadow: none !important; - font-size: 12.5px !important; - line-height: 1.55 !important; - padding: 0 !important; -} - -body[data-ui-version="v2"] .gn-v2-ai-input-box textarea.ant-input:focus, -body[data-ui-version="v2"] .gn-v2-ai-input-box textarea.ant-input:focus-visible { - border-color: transparent !important; - outline: none !important; - box-shadow: none !important; -} - -body[data-ui-version="v2"] .gn-v2-ai-slash-menu { - position: absolute; - bottom: 100%; - left: 0; - right: 0; - z-index: 100; - max-height: 220px; - padding: 4px; - margin-bottom: 4px; - overflow-y: auto; - border-radius: 8px; - box-shadow: var(--gn-shadow-lg); -} - -body[data-ui-version="v2"] .gn-v2-ai-input-actions { - justify-content: flex-end; - gap: 4px; - margin-top: 6px; -} - -body[data-ui-version="v2"] .gn-v2-ai-input-actions .ant-btn { - width: 24px !important; - height: 24px !important; - padding: 0 !important; -} - -/* - * 单行紧凑工具条: - * [连接] [模型] [思考] ········· [token] - * 全部固定/内容宽度,模型下拉绝不拉满整行。 - */ -body[data-ui-version="v2"] .gn-v2-ai-model-bar { - display: flex; - flex-direction: row; - flex-wrap: wrap; - align-items: center; - gap: 6px; - width: 100%; - min-width: 0; - padding-top: 2px; - overflow: visible; -} - -body[data-ui-version="v2"] .gn-v2-ai-context-chip, -body[data-ui-version="v2"] .gn-v2-ai-token-meter { - height: 22px; - display: inline-flex; - align-items: center; - gap: 4px; - padding: 0 7px; - border-radius: 6px !important; - border: 0.5px solid var(--gn-br-1) !important; - background: var(--gn-bg-active) !important; - color: var(--gn-fg-4) !important; - font-family: var(--gn-font-mono); - font-size: 10.5px; - box-sizing: border-box; -} - -/* 连接芯片:内容宽度 + 上限 */ -body[data-ui-version="v2"] .gn-v2-ai-context-chip { - flex: 0 1 auto; - width: auto; - max-width: 112px; - min-width: 0; - overflow: hidden; -} - -/* Tooltip 外包层:不破坏 flex 子项收缩 */ -body[data-ui-version="v2"] .gn-v2-ai-model-bar > span { - display: inline-flex !important; - min-width: 0; - max-width: 100%; - vertical-align: middle; -} - -body[data-ui-version="v2"] .gn-v2-ai-model-bar > span:has(.gn-v2-ai-context-chip) { - max-width: 112px; - flex: 0 1 auto; -} - -body[data-ui-version="v2"] .gn-v2-ai-model-bar > span:has(.gn-v2-ai-token-meter) { - flex: 0 0 auto; - max-width: none; - margin-left: auto; -} - -body[data-ui-version="v2"] .gn-v2-ai-token-meter { - flex: 0 0 auto; - white-space: nowrap; -} - -/* 无 Tooltip 包裹时 token 也靠右 */ -body[data-ui-version="v2"] .gn-v2-ai-model-bar > .gn-v2-ai-token-meter { - margin-left: auto; -} - -body[data-ui-version="v2"] .gn-v2-ai-context-chip > .anticon { - flex: 0 0 auto; -} - -body[data-ui-version="v2"] .gn-v2-ai-context-chip-text { - flex: 1 1 auto; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -body[data-ui-version="v2"] .gn-v2-ai-context-live-dot { - width: 5px; - height: 5px; - flex: 0 0 5px; - border-radius: 50%; - background: var(--gn-accent); -} - -/* 模型 / 思考:固定宽度,禁止被 flex 拉长 */ -body[data-ui-version="v2"] .gn-v2-ai-model-select { - width: 128px !important; - min-width: 128px !important; - max-width: 128px !important; - height: 26px !important; - flex: 0 0 128px !important; -} - -body[data-ui-version="v2"] .gn-v2-ai-thinking-select { - width: 52px !important; - min-width: 52px !important; - max-width: 52px !important; - height: 26px !important; - flex: 0 0 52px !important; -} - -/* 输入区允许 IME 候选层正常定位,避免被裁切/残留 */ -body[data-ui-version="v2"] .gn-v2-ai-composer, -body[data-ui-version="v2"] .gn-v2-ai-input-box, -body[data-ui-version="v2"] .gn-v2-ai-input-surface { - overflow: visible; -} - -body[data-ui-version="v2"] .gn-v2-ai-model-select .ant-select-selector, -body[data-ui-version="v2"] .gn-v2-ai-thinking-select .ant-select-selector { - display: flex !important; - align-items: center !important; - height: 26px !important; - min-height: 26px !important; - padding: 0 22px 0 8px !important; - border: 0.5px solid var(--gn-br-2) !important; - border-radius: 7px !important; - background: var(--gn-bg-panel) !important; - box-shadow: none !important; - font-family: var(--gn-font-mono); - font-size: 12px !important; -} - -body[data-ui-version="v2"] .gn-v2-ai-model-select.ant-select-focused .ant-select-selector, -body[data-ui-version="v2"] .gn-v2-ai-model-select:hover .ant-select-selector, -body[data-ui-version="v2"] .gn-v2-ai-thinking-select.ant-select-focused .ant-select-selector, -body[data-ui-version="v2"] .gn-v2-ai-thinking-select:hover .ant-select-selector { - border-color: var(--gn-info) !important; - box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.12) !important; -} - -body[data-ui-version="v2"] .gn-v2-ai-model-select .ant-select-selection-search, -body[data-ui-version="v2"] .gn-v2-ai-thinking-select .ant-select-selection-search { - left: 8px !important; - right: 22px !important; - inset-inline-start: 8px !important; - inset-inline-end: 22px !important; - height: 24px !important; - background: transparent !important; - border: 0 !important; - box-shadow: none !important; -} - -body[data-ui-version="v2"] .gn-v2-ai-model-select .ant-select-selection-search-input, -body[data-ui-version="v2"] .gn-v2-ai-thinking-select .ant-select-selection-search-input { - height: 24px !important; - padding: 0 !important; - border: 0 !important; - border-radius: 0 !important; - background: transparent !important; - box-shadow: none !important; - color: var(--gn-fg-2) !important; - font-family: var(--gn-font-mono) !important; - font-size: 12px !important; - line-height: 24px !important; -} - -body[data-ui-version="v2"] .gn-v2-ai-model-select .ant-select-selection-item, -body[data-ui-version="v2"] .gn-v2-ai-model-select .ant-select-selection-placeholder, -body[data-ui-version="v2"] .gn-v2-ai-thinking-select .ant-select-selection-item, -body[data-ui-version="v2"] .gn-v2-ai-thinking-select .ant-select-selection-placeholder { - display: flex !important; - align-items: center !important; - height: 24px !important; - line-height: 24px !important; - padding-inline-end: 0 !important; - background: transparent !important; - color: var(--gn-fg-2) !important; - overflow: hidden !important; - text-overflow: ellipsis !important; -} - -body[data-ui-version="v2"] .gn-v2-ai-model-select .ant-select-arrow, -body[data-ui-version="v2"] .gn-v2-ai-thinking-select .ant-select-arrow { - inset-inline-end: 7px !important; - color: var(--gn-fg-4) !important; - font-size: 11px; -} - -body[data-ui-version="v2"] .gn-v2-ai-token-meter { - flex: 0 0 auto; - white-space: nowrap; - gap: 5px !important; -} - -body[data-ui-version="v2"] .gn-v2-ai-token-meter-text { - flex: 0 0 auto; - white-space: nowrap; -} - -body[data-ui-version="v2"] .gn-v2-ai-token-meter.is-warn { - border-color: color-mix(in srgb, var(--gn-warn) 35%, transparent) !important; - color: var(--gn-warn) !important; -} - -body[data-ui-version="v2"] .gn-v2-ai-token-bar { - width: 32px; - height: 4px; - overflow: hidden; - border-radius: 2px; - background: var(--gn-bg-active); -} - -body[data-ui-version="v2"] .gn-v2-ai-token-bar span { - display: block; - height: 100%; - border-radius: inherit; - background: var(--gn-info); -} - -body[data-ui-version="v2"] .gn-v2-ai-panel .ai-chat-send-btn { - width: 28px !important; - height: 28px !important; - border-radius: 7px !important; - display: inline-flex !important; - align-items: center; - justify-content: center; - flex: 0 0 auto; - padding: 0 !important; - border: 0.5px solid var(--gn-info) !important; - background: var(--gn-info) !important; - background-color: var(--gn-info) !important; - color: var(--gn-on-info, #fff) !important; - opacity: 1 !important; - cursor: pointer; -} - -body[data-ui-version="v2"] .gn-v2-ai-panel .ai-chat-send-btn .anticon, -body[data-ui-version="v2"] .gn-v2-ai-panel .ai-chat-send-btn svg { - color: currentColor !important; - fill: currentColor !important; - font-size: 13px; -} - -body[data-ui-version="v2"] .gn-v2-ai-panel .ai-chat-send-btn:disabled { - border-color: var(--gn-br-1) !important; - background: var(--gn-bg-active) !important; - background-color: var(--gn-bg-active) !important; - color: var(--gn-fg-5) !important; - opacity: 1 !important; - cursor: not-allowed; -} - -body[data-ui-version="v2"] .gn-v2-ai-panel .ai-chat-stop-btn { - border-color: rgba(220,38,38,0.28) !important; - background: rgba(220,38,38,0.12) !important; - background-color: rgba(220,38,38,0.12) !important; - color: var(--gn-danger) !important; -} - body[data-ui-version="v2"] .gn-v2-data-grid-column-quick-find { display: inline-flex !important; align-items: center !important; diff --git a/frontend/src/test/readV2ThemeCss.ts b/frontend/src/test/readV2ThemeCss.ts index 76690d86..67813645 100644 --- a/frontend/src/test/readV2ThemeCss.ts +++ b/frontend/src/test/readV2ThemeCss.ts @@ -3,4 +3,5 @@ import { readFileSync } from 'node:fs'; export const readV2ThemeCss = (): string => [ readFileSync(new URL('../v2-theme.css', import.meta.url), 'utf8'), readFileSync(new URL('../styles/v2-theme-workbench.css', import.meta.url), 'utf8'), + readFileSync(new URL('../styles/v2-theme-ai.css', import.meta.url), 'utf8'), ].join('\n'); diff --git a/frontend/src/utils/nativeDetachedWindowClient.test.ts b/frontend/src/utils/nativeDetachedWindowClient.test.ts index 344dacae..e1b57f2a 100644 --- a/frontend/src/utils/nativeDetachedWindowClient.test.ts +++ b/frontend/src/utils/nativeDetachedWindowClient.test.ts @@ -16,6 +16,8 @@ import { buildNativeDetachedWorkbenchMutableStoreSnapshot, buildNativeDetachedWorkbenchPayload, fetchNativeDetachedWindowBootstrap, + hideCurrentNativeDetachedWindow, + hideNativeDetachedWindow, hydrateNativeDetachedStore, isNativeDetachedWindow, mergeNativeDetachedAIContextsDelta, @@ -590,6 +592,49 @@ describe('nativeDetachedWindowClient', () => { } }); + it('returns the parent visibility revision when an AI child is hidden', async () => { + const action = vi.fn(async () => ({ + success: true, + id: 'ai-chat', + visibilityRevision: 7, + })); + const previousWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window'); + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { __GONAVI_DETACHED__: { action } }, + }); + try { + await expect(hideNativeDetachedWindow({ id: 'ai-chat', kind: 'ai-chat' })) + .resolves.toBe(7); + expect(action).toHaveBeenCalledWith('hide', { id: 'ai-chat', kind: 'ai-chat' }); + } finally { + if (previousWindowDescriptor) { + Object.defineProperty(globalThis, 'window', previousWindowDescriptor); + } else { + Reflect.deleteProperty(globalThis, 'window'); + } + } + }); + + it('passes the visibility revision to the native hide control', async () => { + const hide = vi.fn(async () => ({ success: true })); + const previousWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window'); + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { go: { nativewindow: { Control: { Hide: hide } } } }, + }); + try { + await hideCurrentNativeDetachedWindow(11); + expect(hide).toHaveBeenCalledWith(11); + } 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); diff --git a/frontend/src/utils/nativeDetachedWindowClient.ts b/frontend/src/utils/nativeDetachedWindowClient.ts index 98041049..3e547683 100644 --- a/frontend/src/utils/nativeDetachedWindowClient.ts +++ b/frontend/src/utils/nativeDetachedWindowClient.ts @@ -37,6 +37,7 @@ export type NativeDetachedWindowAction = | 'ready' | 'sync' | 'attach' + | 'hide' | 'close' | 'cancel-close' | 'open-ai-settings' @@ -107,6 +108,13 @@ export interface NativeDetachedWindowActionRequest { payload: NativeDetachedWindowActionPayload; } +export interface NativeDetachedWindowActionResult { + success: boolean; + message?: string; + id?: string; + visibilityRevision?: number; +} + export interface NativeDetachedHostStateCommand { id: string; action: 'sync-host-state' | string; @@ -584,6 +592,7 @@ export const buildNativeDetachedAIHostStoreSnapshot = ( activeTab, activeConnection, aiContexts: source.aiContexts, + shortcutOptions: source.shortcutOptions, ...(hostEvents.length > 0 ? { [NATIVE_DETACHED_HOST_EVENTS_KEY]: hostEvents } : {}), }); }; @@ -871,7 +880,7 @@ export const postNativeDetachedWindowAction = async ( action: NativeDetachedWindowAction, payload: NativeDetachedWindowActionPayload, fetchImpl?: FetchLike, -): Promise => { +): Promise => { const nativeAction = !fetchImpl && typeof window !== 'undefined' ? (window as any).__GONAVI_DETACHED__?.action : undefined; @@ -880,10 +889,17 @@ export const postNativeDetachedWindowAction = async ( if (result?.success === false) { throw new Error(String(result.message || `Native detached ${action} failed`)); } - return; + return { + success: result?.success !== false, + ...(result?.message ? { message: String(result.message) } : {}), + ...(result?.id ? { id: String(result.id) } : {}), + ...(Number.isFinite(Number(result?.visibilityRevision)) + ? { visibilityRevision: Number(result.visibilityRevision) } + : {}), + }; } const request = fetchImpl ?? fetch; - await requireSuccessfulResponse(await request(NATIVE_DETACHED_ACTION_URL, { + const response = await requireSuccessfulResponse(await request(NATIVE_DETACHED_ACTION_URL, { method: 'POST', credentials: 'same-origin', headers: { @@ -892,42 +908,61 @@ export const postNativeDetachedWindowAction = async ( }, body: JSON.stringify({ action, payload } satisfies NativeDetachedWindowActionRequest), })); + const body = await response.text(); + if (!body.trim()) return { success: true }; + const result = JSON.parse(body) as NativeDetachedWindowActionResult; + if (result?.success === false) { + throw new Error(String(result.message || `Native detached ${action} failed`)); + } + return result; }; export const syncNativeDetachedWindow = ( payload: NativeDetachedWindowActionPayload, fetchImpl?: FetchLike, -): Promise => postNativeDetachedWindowAction('sync', payload, fetchImpl); +): Promise => postNativeDetachedWindowAction('sync', payload, fetchImpl).then(() => undefined); export const readyNativeDetachedWindow = ( payload: NativeDetachedWindowActionPayload, fetchImpl?: FetchLike, -): Promise => postNativeDetachedWindowAction('ready', payload, fetchImpl); +): Promise => postNativeDetachedWindowAction('ready', payload, fetchImpl).then(() => undefined); export const attachNativeDetachedWindow = ( payload: NativeDetachedWindowActionPayload, fetchImpl?: FetchLike, -): Promise => postNativeDetachedWindowAction('attach', payload, fetchImpl); +): Promise => postNativeDetachedWindowAction('attach', payload, fetchImpl).then(() => undefined); + +export const hideNativeDetachedWindow = async ( + payload: NativeDetachedWindowActionPayload, + fetchImpl?: FetchLike, +): Promise => { + const result = await postNativeDetachedWindowAction('hide', payload, fetchImpl); + const revision = Math.trunc(Number(result.visibilityRevision)); + if (!Number.isFinite(revision) || revision <= 0) { + throw new Error('Native detached hide did not return a visibility revision'); + } + return revision; +}; export const closeNativeDetachedWindow = ( payload: NativeDetachedWindowActionPayload, fetchImpl?: FetchLike, -): Promise => postNativeDetachedWindowAction('close', payload, fetchImpl); +): Promise => postNativeDetachedWindowAction('close', payload, fetchImpl).then(() => undefined); export const cancelNativeDetachedWindowClose = ( payload: NativeDetachedWindowActionPayload, fetchImpl?: FetchLike, -): Promise => postNativeDetachedWindowAction('cancel-close', payload, fetchImpl); +): Promise => postNativeDetachedWindowAction('cancel-close', payload, fetchImpl).then(() => undefined); export const openNativeDetachedAISettings = ( payload: NativeDetachedWindowActionPayload, fetchImpl?: FetchLike, -): Promise => postNativeDetachedWindowAction('open-ai-settings', payload, fetchImpl); +): Promise => postNativeDetachedWindowAction('open-ai-settings', payload, fetchImpl).then(() => undefined); export const sendNativeDetachedHostEvent = ( payload: NativeDetachedWindowActionPayload, fetchImpl?: FetchLike, -): Promise => postNativeDetachedWindowAction('host-event', payload, fetchImpl); +): Promise => postNativeDetachedWindowAction('host-event', payload, fetchImpl).then(() => undefined); export const presentCurrentNativeDetachedWindow = async (): Promise => { const present = typeof window !== 'undefined' @@ -953,6 +988,21 @@ export const closeCurrentNativeDetachedWindow = async (): Promise => { } }; +export const hideCurrentNativeDetachedWindow = async ( + visibilityRevision: number, +): Promise => { + const nativeHide = typeof window !== 'undefined' + ? (window as any).go?.nativewindow?.Control?.Hide + : undefined; + if (typeof nativeHide !== 'function') { + throw new Error('Native detached hide control is unavailable'); + } + const result = await nativeHide(Math.trunc(visibilityRevision)); + if (result?.success === false) { + throw new Error(String(result.message || 'Failed to hide native detached window')); + } +}; + export const cancelCurrentNativeDetachedWindowClose = async (): Promise => { const cancelClose = typeof window !== 'undefined' ? (window as any).go?.nativewindow?.Control?.CancelClose diff --git a/frontend/src/utils/nativeDetachedWindowHost.test.ts b/frontend/src/utils/nativeDetachedWindowHost.test.ts index 9f60b20c..5447a90f 100644 --- a/frontend/src/utils/nativeDetachedWindowHost.test.ts +++ b/frontend/src/utils/nativeDetachedWindowHost.test.ts @@ -8,6 +8,7 @@ import { openNativeQueryResultWindow, openNativeWorkbenchTabWindow, forwardNativeDetachedHostEvent, + shouldApplyNativeDetachedHideRevision, syncNativeAIChatHostState, syncNativeDetachedShortcutOptions, type NativeDetachedWindowManager, @@ -30,6 +31,7 @@ describe('nativeDetachedWindowHost', () => { manager = { Open: vi.fn().mockResolvedValue({ success: true }), Focus: vi.fn().mockResolvedValue({ success: true }), + Hide: vi.fn().mockResolvedValue({ success: true, visibilityRevision: 1 }), Close: vi.fn().mockResolvedValue({ success: true }), CloseAll: vi.fn().mockResolvedValue({ success: true }), SyncHostState: vi.fn().mockResolvedValue({ success: true }), @@ -206,6 +208,77 @@ describe('nativeDetachedWindowHost', () => { })); }); + it('builds the AI bootstrap from an explicit feature whitelist', async () => { + const originalTableColumnOrders = useStore.getState().tableColumnOrders; + useStore.setState({ + aiPanelVisible: true, + tableColumnOrders: { + 'unrelated-large-table-state': ['x'.repeat(256 * 1024)], + }, + }); + + try { + await expect(openNativeAIChatWindow(undefined, manager)).resolves.toBe(true); + + const request = vi.mocked(manager.Open).mock.calls[0]?.[0]; + expect(request?.payload.storeState).toEqual(expect.objectContaining({ + languagePreference: useStore.getState().languagePreference, + theme: useStore.getState().theme, + appearance: useStore.getState().appearance, + fontSize: useStore.getState().fontSize, + uiScale: useStore.getState().uiScale, + shortcutOptions: useStore.getState().shortcutOptions, + tabs: useStore.getState().tabs, + connections: useStore.getState().connections, + aiChatHistory: useStore.getState().aiChatHistory, + aiChatSessions: useStore.getState().aiChatSessions, + aiContexts: useStore.getState().aiContexts, + savedQueries: useStore.getState().savedQueries, + sqlSnippets: useStore.getState().sqlSnippets, + externalSQLDirectories: useStore.getState().externalSQLDirectories, + sqlEditorTransactionOptions: useStore.getState().sqlEditorTransactionOptions, + })); + expect(request?.payload.storeState).not.toHaveProperty('tableColumnOrders'); + expect(request?.payload.storeState).not.toHaveProperty('tableExportHistories'); + expect(request?.payload.storeState).not.toHaveProperty('recentSQLFiles'); + expect(request?.payload.storeState).not.toHaveProperty('windowBounds'); + } finally { + useStore.setState({ tableColumnOrders: originalTableColumnOrders }); + } + }); + + it('reuses a parked AI child without building another native window', async () => { + const parkedBounds = { x: -1180, y: 70, width: 480, height: 700 }; + useStore.setState({ + aiPanelVisible: true, + detachedAIChatWindow: { ...parkedBounds, zIndex: 1201, coordinateSpace: 'screen' }, + aiChatHistory: { + 'session-warm': [{ id: 'message-warm', role: 'assistant', content: 'kept', timestamp: 1 }], + }, + aiChatSessions: [{ id: 'session-warm', title: 'Warm', updatedAt: 1 }], + aiActiveSessionId: 'session-warm', + }); + vi.mocked(manager.Focus).mockResolvedValueOnce({ + success: true, + bounds: parkedBounds, + visibilityRevision: 4, + }); + + await expect(openNativeAIChatWindow(undefined, manager)).resolves.toBe(true); + + expect(manager.Focus).toHaveBeenCalledOnce(); + expect(manager.Focus).toHaveBeenCalledWith('ai-chat'); + expect(manager.Open).not.toHaveBeenCalled(); + expect(manager.SyncHostState).toHaveBeenCalledWith(expect.objectContaining({ + id: 'ai-chat', + storeState: expect.objectContaining({ + shortcutOptions: useStore.getState().shortcutOptions, + }), + })); + expect(useStore.getState().aiChatHistory['session-warm'][0]?.content).toBe('kept'); + expect(shouldApplyNativeDetachedHideRevision('ai-chat', 3)).toBe(false); + }); + it('resends the latest AI shortcut after native open completes', async () => { const initialShortcutOptions = useStore.getState().shortcutOptions; const latestShortcutOptions = { @@ -234,7 +307,7 @@ describe('nativeDetachedWindowHost', () => { )); expect(shortcutSync).toEqual(expect.objectContaining({ id: 'ai-chat', - storeState: { shortcutOptions: latestShortcutOptions }, + storeState: expect.objectContaining({ shortcutOptions: latestShortcutOptions }), })); }); @@ -350,6 +423,10 @@ describe('nativeDetachedWindowHost', () => { vi.mocked(manager.Open).mockReturnValueOnce(new Promise((resolve) => { resolveOpen = resolve; })); + vi.mocked(manager.Focus).mockResolvedValueOnce({ + success: false, + message: 'native window was not found', + }); useStore.getState().detachAIChatPanel({ x: 120, y: 80, width: 440, height: 720 }); const opening = openNativeAIChatWindow(undefined, manager); @@ -361,7 +438,8 @@ describe('nativeDetachedWindowHost', () => { expect(useStore.getState().detachedAIChatWindow).toBeNull(); expect(manager.Close).toHaveBeenCalledOnce(); expect(manager.Close).toHaveBeenCalledWith('ai-chat'); - expect(manager.Focus).not.toHaveBeenCalled(); + expect(manager.Focus).toHaveBeenCalledOnce(); + expect(manager.Focus).toHaveBeenCalledWith('ai-chat'); expect(manager.SyncHostState).not.toHaveBeenCalled(); }); @@ -370,6 +448,10 @@ describe('nativeDetachedWindowHost', () => { vi.mocked(manager.Open).mockReturnValueOnce(new Promise((resolve) => { resolveOpen = resolve; })); + vi.mocked(manager.Focus).mockResolvedValueOnce({ + success: false, + message: 'native window was not found', + }); useStore.getState().detachAIChatPanel({ x: 120, y: 80, width: 440, height: 720 }); const opening = openNativeAIChatWindow(undefined, manager); @@ -378,10 +460,11 @@ describe('nativeDetachedWindowHost', () => { await expect(opening).resolves.toBe(false); expect(useStore.getState().aiPanelVisible).toBe(false); - expect(useStore.getState().detachedAIChatWindow).toBeNull(); + expect(useStore.getState().detachedAIChatWindow).not.toBeNull(); expect(manager.Close).toHaveBeenCalledOnce(); expect(manager.Close).toHaveBeenCalledWith('ai-chat'); - expect(manager.Focus).not.toHaveBeenCalled(); + expect(manager.Focus).toHaveBeenCalledOnce(); + expect(manager.Focus).toHaveBeenCalledWith('ai-chat'); expect(manager.SyncHostState).not.toHaveBeenCalled(); }); @@ -427,10 +510,10 @@ describe('nativeDetachedWindowHost', () => { 'ai-chat', ]); for (const [request] of vi.mocked(manager.SyncHostState!).mock.calls) { - expect(request).toEqual(expect.objectContaining({ - revision: expect.any(Number), - storeState: { shortcutOptions }, - })); + expect(request).toEqual(expect.objectContaining({ revision: expect.any(Number) })); + expect(request.storeState).toEqual(request.id === 'ai-chat' + ? expect.objectContaining({ shortcutOptions }) + : { shortcutOptions }); } }); diff --git a/frontend/src/utils/nativeDetachedWindowHost.ts b/frontend/src/utils/nativeDetachedWindowHost.ts index 6a8fd6fd..f2e0bf24 100644 --- a/frontend/src/utils/nativeDetachedWindowHost.ts +++ b/frontend/src/utils/nativeDetachedWindowHost.ts @@ -14,7 +14,6 @@ import { } from './detachedWindow'; import { buildNativeDetachedQueryResultPayload, - buildNativeDetachedAIChatPayload, buildNativeDetachedAIHostStoreSnapshot, buildNativeDetachedStoreSnapshot, buildNativeDetachedWorkbenchPayload, @@ -30,6 +29,7 @@ export type NativeDetachedWindowOperationResult = { success: boolean; message?: string; id?: string; + visibilityRevision?: number; bounds?: Pick; }; @@ -47,6 +47,7 @@ export type NativeDetachedWindowOpenRequest = { export type NativeDetachedWindowManager = { Open: (request: NativeDetachedWindowOpenRequest) => Promise; Focus: (id: string) => Promise; + Hide?: (id: string) => Promise; Close: (id: string) => Promise; CloseAll: () => Promise; SyncHostState?: (request: NativeDetachedHostStateRequest) => Promise; @@ -67,9 +68,89 @@ const openingWindows = new Map>(); const nativeHostStateRevisions = new Map(); const nativeHostStateQueues = new Map>(); const retainedNativeHostEvents = new Map(); +const nativeVisibilityRevisions = new Map(); let nativeHostEventSequence = 0; const NATIVE_HOST_EVENT_RETENTION_LIMIT = 64; +// Keep cold AI startup bounded: detached AI only needs presentation settings, +// live chat/session state, workspace context and the data exposed by its local +// inspection tools. Provider/model/prompt configuration is loaded directly +// from aiservice and refreshed through the retained config/provider events. +const NATIVE_AI_CHAT_BOOTSTRAP_KEYS = [ + 'languagePreference', + 'theme', + 'appearance', + 'fontSize', + 'uiScale', + 'shortcutOptions', + 'activeContext', + 'activeTabId', + 'connections', + 'tabs', + 'sqlLogs', + 'aiChatHistory', + 'aiChatSessions', + 'aiActiveSessionId', + 'aiContexts', + 'savedQueries', + 'sqlSnippets', + 'externalSQLDirectories', + 'sqlEditorTransactionOptions', +] as const; + +const buildNativeDetachedAIChatBootstrapPayload = ( + state: object, +): NativeDetachedWindowPayload => { + const source = state as Record; + const selected: Record = {}; + for (const key of NATIVE_AI_CHAT_BOOTSTRAP_KEYS) { + if (Object.prototype.hasOwnProperty.call(source, key)) selected[key] = source[key]; + } + return { + storeState: { + ...buildNativeDetachedStoreSnapshot(selected), + detachedWorkbenchWindows: [], + detachedQueryResultWindows: [], + detachedAIChatWindow: null, + sqlEditorPendingTransactions: {}, + aiPanelVisible: true, + aiChatOpenMode: 'detached', + }, + }; +}; + +const normalizeNativeVisibilityRevision = (value: unknown): number => { + const revision = Math.trunc(Number(value)); + return Number.isFinite(revision) && revision > 0 ? revision : 0; +}; + +export const recordNativeDetachedVisibilityRevision = ( + windowId: string, + revisionValue: unknown, +): number => { + const id = String(windowId || '').trim(); + const revision = normalizeNativeVisibilityRevision(revisionValue); + if (!id || revision <= 0) return nativeVisibilityRevisions.get(id) || 0; + const current = nativeVisibilityRevisions.get(id) || 0; + if (revision > current) nativeVisibilityRevisions.set(id, revision); + return Math.max(current, revision); +}; + +export const shouldApplyNativeDetachedHideRevision = ( + windowId: string, + revisionValue: unknown, +): boolean => { + const id = String(windowId || '').trim(); + const revision = normalizeNativeVisibilityRevision(revisionValue); + // Keep backward compatibility with older native runtimes that do not return + // visibility revisions. + if (!id || revision <= 0) return true; + const current = nativeVisibilityRevisions.get(id) || 0; + if (revision < current) return false; + nativeVisibilityRevisions.set(id, revision); + return true; +}; + const nextNativeHostStateRevision = (id: string): number => { const previous = nativeHostStateRevisions.get(id) || Date.now(); const next = Math.max(Date.now(), previous + 1); @@ -104,6 +185,7 @@ export const clearNativeDetachedHostEvents = (windowId: string): void => { const id = String(windowId || '').trim(); if (!id) return; retainedNativeHostEvents.delete(id); + nativeVisibilityRevisions.delete(id); }; const syncNativeDetachedHostState = ( @@ -382,6 +464,33 @@ export const openNativeAIChatWindow = async ( const windowId = 'ai-chat'; const hadDetachedIntent = Boolean(state.detachedAIChatWindow); + if (hadDetachedIntent) { + const focused = await manager.Focus(windowId); + if (focused?.success) { + recordNativeDetachedVisibilityRevision(windowId, focused.visibilityRevision); + const latest = useStore.getState(); + if (!latest.aiPanelVisible) { + await hideNativeDetachedWindowById(windowId, manager); + return false; + } + const focusedBounds = focused.bounds; + if ( + focusedBounds + && [focusedBounds.x, focusedBounds.y, focusedBounds.width, focusedBounds.height] + .every(Number.isFinite) + && focusedBounds.width > 0 + && focusedBounds.height > 0 + ) { + latest.updateDetachedAIChatBounds({ ...focusedBounds, coordinateSpace: 'screen' }); + } + try { + await refreshNativeAIChatWindow(manager); + } catch (error) { + console.warn('[Native Detached Window] Failed to refresh reused AI window', error); + } + return true; + } + } const remembered = state.aiChatDetachedBoundsMemory; const rememberedBounds = { ...(remembered?.coordinateSpace === 'screen' @@ -397,7 +506,7 @@ export const openNativeAIChatWindow = async ( kind: 'ai-chat', title: 'GoNavi AI', ...bounds, - payload: buildNativeDetachedAIChatPayload(state), + payload: buildNativeDetachedAIChatBootstrapPayload(state), }, (openedBounds) => { const latest = useStore.getState(); if (!latest.aiPanelVisible || (hadDetachedIntent && !latest.detachedAIChatWindow)) { @@ -417,16 +526,7 @@ export const openNativeAIChatWindow = async ( }); if (opened && typeof manager.SyncHostState === 'function') { try { - await syncNativeDetachedShortcutOptions( - [windowId], - useStore.getState().shortcutOptions, - manager, - ); - } catch (error) { - console.warn('[Native Detached Window] Failed to send current shortcuts to AI window', error); - } - try { - await syncNativeAIChatHostState(manager); + await refreshNativeAIChatWindow(manager); } catch (error) { console.warn('[Native Detached Window] Failed to send initial AI host context', error); } @@ -434,6 +534,25 @@ export const openNativeAIChatWindow = async ( return opened; }; +const refreshNativeAIChatWindow = async ( + manager: NativeDetachedWindowManager, +): Promise => { + if (typeof manager.SyncHostState !== 'function') return false; + retainNativeHostEvent('ai-chat', createNativeHostEvent( + 'main', + 'gonavi:ai:config-changed', + )); + const events = retainNativeHostEvent('ai-chat', createNativeHostEvent( + 'main', + 'gonavi:ai:provider-changed', + )); + return syncNativeDetachedHostState( + 'ai-chat', + buildNativeDetachedAIHostStoreSnapshot(useStore.getState(), events), + manager, + ); +}; + export const syncNativeAIChatHostState = async ( managerOverride?: NativeDetachedWindowManager, ): Promise => { @@ -459,11 +578,36 @@ export const syncNativeDetachedShortcutOptions = async ( const ids = Array.from(new Set( Array.from(targetWindowIds, (id) => String(id || '').trim()).filter(Boolean), )); - const storeState = buildNativeDetachedStoreSnapshot({ shortcutOptions }); - await Promise.all(ids.map((id) => syncNativeDetachedHostState(id, storeState, manager))); + const shortcutStoreState = buildNativeDetachedStoreSnapshot({ shortcutOptions }); + await Promise.all(ids.map((id) => syncNativeDetachedHostState( + id, + id === 'ai-chat' + ? buildNativeDetachedAIHostStoreSnapshot( + { ...useStore.getState(), shortcutOptions }, + retainedNativeHostEvents.get('ai-chat') || [], + ) + : shortcutStoreState, + manager, + ))); return true; }; +export const hideNativeDetachedWindowById = async ( + id: string, + managerOverride?: NativeDetachedWindowManager, +): Promise => { + const manager = managerOverride ?? resolveNativeDetachedWindowManager(); + if (!manager) return; + const targetID = String(id || '').trim(); + const result = typeof manager.Hide === 'function' + ? await manager.Hide(targetID) + : await manager.Close(targetID); + recordNativeDetachedVisibilityRevision(targetID, result?.visibilityRevision); + if (!result?.success && result?.message) { + throw new Error(result.message); + } +}; + export const closeNativeDetachedWindowById = async (id: string): Promise => { const manager = resolveNativeDetachedWindowManager(); if (!manager) return; diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 02083056..997d63d7 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1694,6 +1694,7 @@ export namespace nativewindow { message?: string; id?: string; bounds?: WindowBounds; + visibilityRevision?: number; static createFrom(source: any = {}) { return new OperationResult(source); @@ -1705,6 +1706,7 @@ export namespace nativewindow { this.message = source["message"]; this.id = source["id"]; this.bounds = this.convertValues(source["bounds"], WindowBounds); + this.visibilityRevision = source["visibilityRevision"]; } convertValues(a: any, classs: any, asMap: boolean = false): any { @@ -1738,6 +1740,7 @@ export namespace nativewindow { openedAt: number; ready: boolean; closeSent: boolean; + hidden?: boolean; static createFrom(source: any = {}) { return new WindowInfo(source); @@ -1756,6 +1759,7 @@ export namespace nativewindow { this.openedAt = source["openedAt"]; this.ready = source["ready"]; this.closeSent = source["closeSent"]; + this.hidden = source["hidden"]; } } diff --git a/frontend/wailsjs/go/nativewindow/Manager.d.ts b/frontend/wailsjs/go/nativewindow/Manager.d.ts index fb289624..c03450e7 100755 --- a/frontend/wailsjs/go/nativewindow/Manager.d.ts +++ b/frontend/wailsjs/go/nativewindow/Manager.d.ts @@ -10,6 +10,8 @@ export function CloseAll():Promise; export function Focus(arg1:string):Promise; +export function Hide(arg1:string):Promise; + export function List():Promise>; export function Open(arg1:nativewindow.OpenRequest):Promise; diff --git a/frontend/wailsjs/go/nativewindow/Manager.js b/frontend/wailsjs/go/nativewindow/Manager.js index 0f34b41d..fe23d818 100755 --- a/frontend/wailsjs/go/nativewindow/Manager.js +++ b/frontend/wailsjs/go/nativewindow/Manager.js @@ -18,6 +18,10 @@ export function Focus(arg1) { return window['go']['nativewindow']['Manager']['Focus'](arg1); } +export function Hide(arg1) { + return window['go']['nativewindow']['Manager']['Hide'](arg1); +} + export function List() { return window['go']['nativewindow']['Manager']['List'](); } diff --git a/internal/nativewindow/bridge.go b/internal/nativewindow/bridge.go index 2851adb4..bedb761b 100644 --- a/internal/nativewindow/bridge.go +++ b/internal/nativewindow/bridge.go @@ -43,14 +43,15 @@ type Bridge struct { kind string client *http.Client - mu sync.Mutex - ctx context.Context - cancel context.CancelFunc - ready bool - onReady func() OperationResult - terminal string - closeOnce sync.Once - emitToWails func(context.Context, string, ...any) + mu sync.Mutex + ctx context.Context + cancel context.CancelFunc + ready bool + onReady func() OperationResult + terminal string + closeOnce sync.Once + emitToWails func(context.Context, string, ...any) + allowParentForeground func() error } func newBridge(options ChildOptions) *Bridge { @@ -63,11 +64,12 @@ func newBridge(options ChildOptions) *Bridge { ForceAttemptHTTP2: false, } return &Bridge{ - parentURL: strings.TrimRight(options.ParentURL, "/"), - token: options.Token, - windowID: options.ID, - kind: options.Kind, - client: &http.Client{Transport: transport}, + parentURL: strings.TrimRight(options.ParentURL, "/"), + token: options.Token, + windowID: options.ID, + kind: options.Kind, + client: &http.Client{Transport: transport}, + allowParentForeground: grantParentForegroundAccess, emitToWails: func(ctx context.Context, name string, args ...any) { wailsRuntime.EventsEmit(ctx, name, args...) }, @@ -145,6 +147,10 @@ func (b *Bridge) FocusWindow(id string) OperationResult { return b.control(controlRequest{Action: "focus", ID: id}) } +func (b *Bridge) HideWindow(id string) OperationResult { + return b.control(controlRequest{Action: "hide", ID: id}) +} + func (b *Bridge) CloseWindow(id string) OperationResult { return b.control(controlRequest{Action: "close", ID: id}) } @@ -171,8 +177,8 @@ func (b *Bridge) control(request controlRequest) OperationResult { return result } -// Action acknowledges child readiness or forwards sync, attach, or close state -// to the main window. +// Action acknowledges child readiness or forwards sync, hide, attach, or close +// state to the main window. func (b *Bridge) Action(action string, payload any) OperationResult { normalizedAction := strings.ToLower(strings.TrimSpace(action)) if normalizedAction == "ready" { @@ -180,6 +186,12 @@ func (b *Bridge) Action(action string, payload any) OperationResult { return result } } + if normalizedAction == "open-ai-settings" && b.allowParentForeground != nil { + // The detached child owns the current user interaction on Windows. Grant + // the parent permission immediately before it attempts to take focus. This + // is best-effort so an OS rejection never blocks the settings action itself. + _ = b.allowParentForeground() + } var result OperationResult status, err := b.doJSON(context.Background(), http.MethodPost, ActionPath, actionRequest{Action: action, Payload: payload}, &result) @@ -410,13 +422,36 @@ func (b *Bridge) replayPendingCommand(ctx context.Context) error { if status != http.StatusOK { return fmt.Errorf("detached command-state replay failed with status %d", status) } - if strings.TrimSpace(command.ID) != b.windowID || command.Action != "close" { + if strings.TrimSpace(command.ID) != b.windowID || + (command.Action != "close" && command.Action != "hide" && command.Action != "focus") { return fmt.Errorf("detached command-state replay is invalid") } + visibilityRevision := positiveVisibilityRevision(command.Payload) + if command.Action == "focus" && visibilityRevision == 0 { + return fmt.Errorf("detached command-state focus revision is invalid") + } b.emitRuntimeEvent(CommandEventName, command) return nil } +func (b *Bridge) acknowledgeFocus(ctx context.Context, visibilityRevision uint64) error { + if visibilityRevision == 0 { + return fmt.Errorf("detached focus acknowledgement revision is invalid") + } + var result OperationResult + status, err := b.doJSON(ctx, http.MethodPost, CommandStatePath, commandStateRequest{ + Action: "ack-focus", + VisibilityRevision: visibilityRevision, + }, &result) + if err != nil { + return err + } + if status != http.StatusOK || !result.Success { + return fmt.Errorf("detached focus acknowledgement failed with status %d", status) + } + return nil +} + func (b *Bridge) replayHostState(ctx context.Context) error { var snapshot HostStateRequest status, err := b.doJSON(ctx, http.MethodGet, HostStatePath, nil, &snapshot) @@ -492,12 +527,15 @@ type Control struct { closeFallbackGeneration uint64 closeFallbackDelay time.Duration closeCommitted bool + visibilityRevision uint64 domReady bool frontendReady bool focusPending bool + focusPendingRevision uint64 visible bool emitCommand func(context.Context, childCommand) showWindow func(context.Context) + hideWindow func(context.Context) focusWindow func(context.Context) quit func(context.Context) } @@ -512,6 +550,9 @@ func newControl(bridge *Bridge) *Control { showWindow: func(ctx context.Context) { wailsRuntime.WindowShow(ctx) }, + hideWindow: func(ctx context.Context) { + wailsRuntime.WindowHide(ctx) + }, focusWindow: func(ctx context.Context) { wailsRuntime.WindowUnminimise(ctx) wailsRuntime.Show(ctx) @@ -586,7 +627,10 @@ func (c *Control) Present() OperationResult { presentation := childWindowPresentation{ctx: c.ctx, show: c.showWindow} if c.focusPending && c.focusWindow != nil { c.focusPending = false + presentation.visibilityRevision = c.focusPendingRevision + c.focusPendingRevision = 0 presentation.focus = c.focusWindow + presentation.bridge = c.bridge } c.mu.Unlock() presentation.run() @@ -594,9 +638,11 @@ func (c *Control) Present() OperationResult { } type childWindowPresentation struct { - ctx context.Context - show func(context.Context) - focus func(context.Context) + ctx context.Context + show func(context.Context) + focus func(context.Context) + bridge *Bridge + visibilityRevision uint64 } func (p childWindowPresentation) run() { @@ -605,6 +651,12 @@ func (p childWindowPresentation) run() { } if p.focus != nil { p.focus(p.ctx) + if p.bridge != nil && p.visibilityRevision > 0 { + // The parent must retain its pending focus until the native focus + // callback has actually run. A failed acknowledgement deliberately + // leaves that pending command available for the next SSE reconnect. + _ = p.bridge.acknowledgeFocus(p.ctx, p.visibilityRevision) + } } } @@ -616,7 +668,10 @@ func (c *Control) takeInitialPresentationLocked() childWindowPresentation { presentation := childWindowPresentation{ctx: c.ctx, show: c.showWindow} if c.focusPending && c.focusWindow != nil { c.focusPending = false + presentation.visibilityRevision = c.focusPendingRevision + c.focusPendingRevision = 0 presentation.focus = c.focusWindow + presentation.bridge = c.bridge } return presentation } @@ -643,6 +698,44 @@ func (c *Control) Close() OperationResult { return OperationResult{Success: true} } +// Hide parks this child window while keeping its process, WebView, and React +// tree alive. Visibility revisions make the transition monotonic: a delayed +// hide from an older close request cannot win over a newer host focus. +func (c *Control) Hide(visibilityRevision uint64) OperationResult { + if c == nil { + return operationFailure("native window control is unavailable") + } + c.mu.Lock() + if visibilityRevision < c.visibilityRevision { + currentRevision := c.visibilityRevision + c.mu.Unlock() + return OperationResult{ + Success: true, + Message: "stale native window hide ignored", + VisibilityRevision: currentRevision, + } + } + ctx := c.ctx + hide := c.hideWindow + if ctx == nil || hide == nil { + c.mu.Unlock() + return operationFailure("native window is not ready") + } + if c.closeCommitted { + c.mu.Unlock() + return operationFailure("native window close is already committed") + } + c.visibilityRevision = visibilityRevision + c.visible = false + c.focusPending = false + c.focusPendingRevision = 0 + c.invalidateCloseFallbackLocked() + c.mu.Unlock() + c.closeGate.cancel() + hide(ctx) + return OperationResult{Success: true, VisibilityRevision: visibilityRevision} +} + // CancelClose keeps the child alive after a failed final frontend flush. It // also invalidates any native-close fallback so a retry starts a fresh grace // period instead of inheriting the old timeout. @@ -743,10 +836,32 @@ func (c *Control) invalidateCloseFallbackLocked() { } func (c *Control) Focus() OperationResult { + if c == nil { + return operationFailure("native window control is unavailable") + } + c.mu.RLock() + visibilityRevision := c.visibilityRevision + c.mu.RUnlock() + return c.FocusRevision(visibilityRevision) +} + +// FocusRevision raises the window only when the request is at least as new as +// the last visibility transition observed by this child. +func (c *Control) FocusRevision(visibilityRevision uint64) OperationResult { if c == nil { return operationFailure("native window control is unavailable") } c.mu.Lock() + if visibilityRevision < c.visibilityRevision { + currentRevision := c.visibilityRevision + c.mu.Unlock() + return OperationResult{ + Success: true, + Message: "stale native window focus ignored", + VisibilityRevision: currentRevision, + } + } + c.visibilityRevision = visibilityRevision ctx := c.ctx focus := c.focusWindow if ctx == nil || focus == nil { @@ -755,12 +870,21 @@ func (c *Control) Focus() OperationResult { } if !c.visible { c.focusPending = true + c.focusPendingRevision = visibilityRevision presentation := c.takeInitialPresentationLocked() c.mu.Unlock() presentation.run() - return OperationResult{Success: true} + return OperationResult{Success: true, VisibilityRevision: visibilityRevision} + } + c.focusPending = false + c.focusPendingRevision = 0 + presentation := childWindowPresentation{ + ctx: ctx, + focus: focus, + bridge: c.bridge, + visibilityRevision: visibilityRevision, } c.mu.Unlock() - focus(ctx) - return OperationResult{Success: true} + presentation.run() + return OperationResult{Success: true, VisibilityRevision: visibilityRevision} } diff --git a/internal/nativewindow/bridge_sse_test.go b/internal/nativewindow/bridge_sse_test.go index 283ca34f..c698aa92 100644 --- a/internal/nativewindow/bridge_sse_test.go +++ b/internal/nativewindow/bridge_sse_test.go @@ -125,3 +125,112 @@ func TestBridgeReplaysPendingCloseWhenEventStreamReconnects(t *testing.T) { t.Fatalf("replayed command = %#v", received) } } + +func TestBridgeReplaysPendingFocusWithoutAcknowledgingBeforeNativeFocus(t *testing.T) { + bridge := newBridge(ChildOptions{ + ParentURL: "http://127.0.0.1:43119", + Token: "test-token", + ID: "ai-chat", + Kind: "ai-chat", + }) + commandPayload, err := json.Marshal(childCommand{ + ID: "ai-chat", + Action: "focus", + Payload: visibilityCommandPayload{VisibilityRevision: 7}, + }) + if err != nil { + t.Fatalf("marshal focus command: %v", err) + } + acknowledgements := 0 + bridge.client.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + status := http.StatusNoContent + body := "" + switch { + case request.URL.Path == EventsPath: + status = http.StatusOK + body = ": connected\n\n" + case request.URL.Path == CommandStatePath && request.Method == http.MethodGet: + status = http.StatusOK + body = string(commandPayload) + case request.URL.Path == CommandStatePath && request.Method == http.MethodPost: + acknowledgements++ + status = http.StatusOK + body = `{"success":true,"id":"ai-chat","visibilityRevision":7}` + } + return &http.Response{ + StatusCode: status, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + }, nil + }) + + var received childCommand + bridge.mu.Lock() + bridge.ctx = context.Background() + bridge.emitToWails = func(_ context.Context, name string, args ...any) { + if name == CommandEventName && len(args) == 1 { + received, _ = args[0].(childCommand) + } + } + bridge.mu.Unlock() + + if err := bridge.consumeEventStream(context.Background()); err != nil { + t.Fatalf("consume reconnected stream: %v", err) + } + if received.Action != "focus" || positiveVisibilityRevision(received.Payload) != 7 { + t.Fatalf("replayed focus command = %#v", received) + } + if acknowledgements != 0 { + t.Fatalf("focus acknowledgements before native focus = %d, want 0", acknowledgements) + } +} + +func TestBridgeDeliversLiveFocusWithoutAcknowledgingBeforeNativeFocus(t *testing.T) { + bridge := newBridge(ChildOptions{ + ParentURL: "http://127.0.0.1:43119", + Token: "test-token", + ID: "ai-chat", + Kind: "ai-chat", + }) + eventPayload, err := json.Marshal(bridgeEvent{ + Name: CommandEventName, + Args: []any{childCommand{ + ID: "ai-chat", + Action: "focus", + Payload: visibilityCommandPayload{VisibilityRevision: 9}, + }}, + }) + if err != nil { + t.Fatalf("marshal focus event: %v", err) + } + acknowledgements := 0 + bridge.client.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + status := http.StatusNoContent + body := "" + switch { + case request.URL.Path == EventsPath: + status = http.StatusOK + body = "data: " + string(eventPayload) + "\n\n" + case request.URL.Path == CommandStatePath && request.Method == http.MethodPost: + acknowledgements++ + status = http.StatusOK + body = `{"success":true,"id":"ai-chat","visibilityRevision":9}` + } + return &http.Response{ + StatusCode: status, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + }, nil + }) + bridge.mu.Lock() + bridge.ctx = context.Background() + bridge.emitToWails = func(context.Context, string, ...any) {} + bridge.mu.Unlock() + + if err := bridge.consumeEventStream(context.Background()); err != nil { + t.Fatalf("consume live focus stream: %v", err) + } + if acknowledgements != 0 { + t.Fatalf("focus acknowledgements before native focus = %d, want 0", acknowledgements) + } +} diff --git a/internal/nativewindow/close_gate_test.go b/internal/nativewindow/close_gate_test.go index 4bc56feb..866f5c72 100644 --- a/internal/nativewindow/close_gate_test.go +++ b/internal/nativewindow/close_gate_test.go @@ -16,6 +16,35 @@ func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) return f(request) } +func TestBridgeHideActionDoesNotCommitTerminalState(t *testing.T) { + bridge := newBridge(ChildOptions{ + ParentURL: "http://127.0.0.1:43119", + Token: "test-token", + ID: "ai-chat", + Kind: "ai-chat", + }) + bridge.client.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader( + `{"success":true,"id":"ai-chat","visibilityRevision":1}`, + )), + Header: make(http.Header), + }, nil + }) + + result := bridge.Action("hide", map[string]any{"id": "ai-chat", "kind": "ai-chat"}) + if !result.Success || result.VisibilityRevision != 1 { + t.Fatalf("hide Action result = %#v", result) + } + bridge.mu.Lock() + terminal := bridge.terminal + bridge.mu.Unlock() + if terminal != "" { + t.Fatalf("hide action committed terminal state %q", terminal) + } +} + func TestCloseGateVetoesAndRequestsFrontendUntilExitIsAllowed(t *testing.T) { var gate closeGate diff --git a/internal/nativewindow/dock_menu.go b/internal/nativewindow/dock_menu.go index 1097000b..24f84065 100644 --- a/internal/nativewindow/dock_menu.go +++ b/internal/nativewindow/dock_menu.go @@ -92,7 +92,7 @@ func buildDockMenuSnapshot(windows []WindowInfo) []dockMenuWindow { current := make([]WindowInfo, 0, len(windows)) for _, window := range windows { window.ID = strings.TrimSpace(window.ID) - if window.ID == "" || !window.Ready || window.CloseSent { + if window.ID == "" || !window.Ready || window.CloseSent || window.Hidden { continue } current = append(current, window) diff --git a/internal/nativewindow/dock_menu_test.go b/internal/nativewindow/dock_menu_test.go index 7fe85fd1..ec39cc14 100644 --- a/internal/nativewindow/dock_menu_test.go +++ b/internal/nativewindow/dock_menu_test.go @@ -8,6 +8,7 @@ import ( func TestBuildDockMenuSnapshotIncludesOnlyCurrentReadyWindows(t *testing.T) { windows := []WindowInfo{ {ID: "closing", Title: "Closing", PID: 42, OpenedAt: 1, Ready: true, CloseSent: true}, + {ID: "hidden", Title: "Hidden", PID: 48, OpenedAt: 3, Ready: true, Hidden: true}, {ID: "not-ready", Title: "Starting", PID: 43, OpenedAt: 2}, {ID: " result-2 ", Title: " Result 2 ", PID: 44, OpenedAt: 40, Ready: true}, {ID: "workbench-1", Title: "Workbench", PID: 45, OpenedAt: 20, Ready: true}, diff --git a/internal/nativewindow/foreground_permission_other.go b/internal/nativewindow/foreground_permission_other.go new file mode 100644 index 00000000..84c74bc6 --- /dev/null +++ b/internal/nativewindow/foreground_permission_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package nativewindow + +func grantParentForegroundAccess() error { + return nil +} diff --git a/internal/nativewindow/foreground_permission_test.go b/internal/nativewindow/foreground_permission_test.go new file mode 100644 index 00000000..ae17ea41 --- /dev/null +++ b/internal/nativewindow/foreground_permission_test.go @@ -0,0 +1,82 @@ +package nativewindow + +import ( + "errors" + "io" + "net/http" + "strings" + "testing" +) + +func TestBridgeAllowsParentForegroundImmediatelyBeforeOpeningAISettings(t *testing.T) { + bridge := newBridge(ChildOptions{ + ParentURL: "http://127.0.0.1:43119", + Token: "test-token", + ID: "ai-chat", + Kind: "ai-chat", + }) + steps := make([]string, 0, 2) + bridge.allowParentForeground = func() error { + steps = append(steps, "allow-parent-foreground") + return nil + } + bridge.client.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) { + steps = append(steps, "post-action") + return successfulForegroundActionResponse(), nil + }) + + if result := bridge.Action("open-ai-settings", map[string]any{"id": "ai-chat"}); !result.Success { + t.Fatalf("open-ai-settings result = %#v", result) + } + if got := strings.Join(steps, ","); got != "allow-parent-foreground,post-action" { + t.Fatalf("open-ai-settings sequence = %q", got) + } +} + +func TestBridgeStillOpensAISettingsWhenForegroundPermissionFails(t *testing.T) { + bridge := newBridge(ChildOptions{ParentURL: "http://127.0.0.1:43119", ID: "ai-chat", Kind: "ai-chat"}) + bridge.allowParentForeground = func() error { + return errors.New("permission denied") + } + posts := 0 + bridge.client.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) { + posts++ + return successfulForegroundActionResponse(), nil + }) + + if result := bridge.Action("open-ai-settings", nil); !result.Success { + t.Fatalf("open-ai-settings result = %#v", result) + } + if posts != 1 { + t.Fatalf("parent action posts = %d, want 1", posts) + } +} + +func TestBridgeDoesNotGrantForegroundForOtherActions(t *testing.T) { + bridge := newBridge(ChildOptions{ParentURL: "http://127.0.0.1:43119", ID: "ai-chat", Kind: "ai-chat"}) + grants := 0 + bridge.allowParentForeground = func() error { + grants++ + return nil + } + bridge.client.Transport = roundTripFunc(func(*http.Request) (*http.Response, error) { + return successfulForegroundActionResponse(), nil + }) + + for _, action := range []string{"sync", "attach", "close", "host-event"} { + if result := bridge.Action(action, nil); !result.Success { + t.Fatalf("%s result = %#v", action, result) + } + } + if grants != 0 { + t.Fatalf("foreground grants = %d, want 0", grants) + } +} + +func successfulForegroundActionResponse() *http.Response { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"success":true,"id":"ai-chat"}`)), + Header: make(http.Header), + } +} diff --git a/internal/nativewindow/foreground_permission_windows.go b/internal/nativewindow/foreground_permission_windows.go new file mode 100644 index 00000000..518f2a1a --- /dev/null +++ b/internal/nativewindow/foreground_permission_windows.go @@ -0,0 +1,28 @@ +//go:build windows + +package nativewindow + +import ( + "fmt" + "os" + + "golang.org/x/sys/windows" +) + +var allowSetForegroundWindowProc = windows.NewLazySystemDLL("user32.dll").NewProc("AllowSetForegroundWindow") + +var allowSetForegroundWindow = func(processID uint32) bool { + result, _, _ := allowSetForegroundWindowProc.Call(uintptr(processID)) + return result != 0 +} + +func grantParentForegroundAccess() error { + parentProcessID := os.Getppid() + if parentProcessID <= 0 { + return fmt.Errorf("invalid parent process ID %d", parentProcessID) + } + if !allowSetForegroundWindow(uint32(parentProcessID)) { + return fmt.Errorf("AllowSetForegroundWindow rejected parent process %d", parentProcessID) + } + return nil +} diff --git a/internal/nativewindow/foreground_permission_windows_test.go b/internal/nativewindow/foreground_permission_windows_test.go new file mode 100644 index 00000000..7c98a9d6 --- /dev/null +++ b/internal/nativewindow/foreground_permission_windows_test.go @@ -0,0 +1,40 @@ +//go:build windows + +package nativewindow + +import ( + "os" + "testing" +) + +func TestGrantParentForegroundAccessTargetsDirectParent(t *testing.T) { + original := allowSetForegroundWindow + t.Cleanup(func() { + allowSetForegroundWindow = original + }) + + var processID uint32 + allowSetForegroundWindow = func(candidate uint32) bool { + processID = candidate + return true + } + + if err := grantParentForegroundAccess(); err != nil { + t.Fatalf("grantParentForegroundAccess error = %v", err) + } + if processID != uint32(os.Getppid()) { + t.Fatalf("foreground process ID = %d, want parent %d", processID, os.Getppid()) + } +} + +func TestGrantParentForegroundAccessReportsWindowsRejection(t *testing.T) { + original := allowSetForegroundWindow + t.Cleanup(func() { + allowSetForegroundWindow = original + }) + allowSetForegroundWindow = func(uint32) bool { return false } + + if err := grantParentForegroundAccess(); err == nil { + t.Fatal("grantParentForegroundAccess error = nil, want Windows rejection") + } +} diff --git a/internal/nativewindow/manager.go b/internal/nativewindow/manager.go index 6f9eddfd..4b2b635d 100644 --- a/internal/nativewindow/manager.go +++ b/internal/nativewindow/manager.go @@ -48,19 +48,21 @@ type processExit struct { } type windowEntry struct { - info WindowInfo - payload any - hostState HostStateRequest - ownerID string - process childProcess - exitReason string - closeGeneration uint64 - actionRevision int64 - ready chan struct{} - done chan processExit - readyOnce sync.Once - doneOnce sync.Once - acknowledged bool + info WindowInfo + payload any + hostState HostStateRequest + ownerID string + process childProcess + exitReason string + closeGeneration uint64 + visibilityRevision uint64 + pendingFocusRevision uint64 + actionRevision int64 + ready chan struct{} + done chan processExit + readyOnce sync.Once + doneOnce sync.Once + acknowledged bool } // Manager owns the loopback bridge and the registry of detached Wails child @@ -292,7 +294,20 @@ func (m *Manager) open(request OpenRequest, ownerID string) OperationResult { m.mu.Unlock() return operationFailure("native window manager is not running") } - if _, exists := m.windows[request.ID]; exists { + if existing, exists := m.windows[request.ID]; exists { + if existing.info.CloseSent { + m.mu.Unlock() + return closingWindowRetryFailure(request.ID) + } + // A parked child keeps its WebView and React tree alive. Refresh the + // bootstrap snapshot and remembered geometry before raising it so a + // subsequent frontend resume can hydrate from the newest host state. + existing.payload = request.Payload + existing.info.Title = request.Title + existing.info.X = request.X + existing.info.Y = request.Y + existing.info.Width = request.Width + existing.info.Height = request.Height m.mu.Unlock() result := m.Focus(request.ID) result.ID = request.ID @@ -401,25 +416,93 @@ func (m *Manager) Focus(id string) OperationResult { return operationFailure("native window manager is unavailable") } id = strings.TrimSpace(id) - m.mu.RLock() + m.mu.Lock() entry, exists := m.windows[id] - var bounds *WindowBounds - if exists { - bounds = windowBoundsFromInfo(entry.info) - } - emitToChild := m.emitToChild - shared := m.shared - m.mu.RUnlock() if !exists { + m.mu.Unlock() return operationFailure("native window was not found") } - command := childCommand{ID: id, Action: "focus"} + if entry.info.CloseSent { + m.mu.Unlock() + return closingWindowRetryFailure(id) + } + wasHidden := entry.info.Hidden + // Every explicit focus is a separately acknowledged visibility intent. + // Advancing even while already visible prevents an acknowledgement for an + // older focus from clearing a newer request that arrived during an SSE gap. + entry.visibilityRevision++ + entry.info.Hidden = false + entry.pendingFocusRevision = entry.visibilityRevision + visibilityRevision := entry.visibilityRevision + bounds := windowBoundsFromInfo(entry.info) + emitToChild := m.emitToChild + shared := m.shared + m.mu.Unlock() + command := childCommand{ + ID: id, + Action: "focus", + Payload: visibilityCommandPayload{VisibilityRevision: visibilityRevision}, + } if emitToChild != nil { emitToChild(id, CommandEventName, command) } else if shared != nil { shared.EmitTo(id, CommandEventName, command) } - return OperationResult{Success: true, ID: id, Bounds: bounds} + if wasHidden { + publishDetachedDockMenuSnapshot(m) + } + return OperationResult{ + Success: true, + ID: id, + Bounds: bounds, + VisibilityRevision: visibilityRevision, + } +} + +// Hide parks a detached child without terminating its process. Repeated hides +// reuse the same visibility revision, while the next Focus advances it so a +// delayed child-side hide cannot conceal a newly focused window. +func (m *Manager) Hide(id string) OperationResult { + if m == nil { + return operationFailure("native window manager is unavailable") + } + id = strings.TrimSpace(id) + m.mu.Lock() + entry, exists := m.windows[id] + if !exists { + m.mu.Unlock() + return operationFailure("native window was not found") + } + if entry.info.CloseSent { + m.mu.Unlock() + return operationFailure("native window is closing") + } + if !entry.info.Hidden { + entry.visibilityRevision++ + entry.info.Hidden = true + } + entry.pendingFocusRevision = 0 + visibilityRevision := entry.visibilityRevision + emitToChild := m.emitToChild + shared := m.shared + m.mu.Unlock() + + command := childCommand{ + ID: id, + Action: "hide", + Payload: visibilityCommandPayload{VisibilityRevision: visibilityRevision}, + } + if emitToChild != nil { + emitToChild(id, CommandEventName, command) + } else if shared != nil { + shared.EmitTo(id, CommandEventName, command) + } + publishDetachedDockMenuSnapshot(m) + return OperationResult{ + Success: true, + ID: id, + VisibilityRevision: visibilityRevision, + } } func windowBoundsFromRequest(request OpenRequest) *WindowBounds { @@ -455,6 +538,7 @@ func (m *Manager) requestClose(id string, reason string) OperationResult { entry.exitReason = reason } entry.info.CloseSent = true + entry.pendingFocusRevision = 0 entry.closeGeneration++ closeGeneration := entry.closeGeneration process := entry.process @@ -605,6 +689,10 @@ type hostStateInvalidationPayload struct { Revision int64 `json:"revision"` } +type visibilityCommandPayload struct { + VisibilityRevision uint64 `json:"visibilityRevision"` +} + func cloneHostStoreState(storeState map[string]any) (map[string]any, error) { if storeState == nil { return nil, fmt.Errorf("native host-state storeState is required") @@ -743,6 +831,14 @@ func operationFailure(message string) OperationResult { return OperationResult{Success: false, Message: message} } +func closingWindowRetryFailure(id string) OperationResult { + return OperationResult{ + Success: false, + ID: strings.TrimSpace(id), + Message: "native window is closing; retry after it exits", + } +} + func validateOpenRequest(request OpenRequest) error { if len(request.ID) > 256 || strings.ContainsAny(request.ID, "\r\n\x00") { return fmt.Errorf("native window id is invalid") @@ -769,6 +865,7 @@ func (m *Manager) shutdown() { ids = append(ids, id) entry.exitReason = ExitReasonParentShutdown entry.info.CloseSent = true + entry.pendingFocusRevision = 0 entry.closeGeneration++ } httpServer := m.httpServer @@ -927,6 +1024,10 @@ func (m *Manager) handleHostState(w http.ResponseWriter, r *http.Request) { } func (m *Manager) handleCommandState(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + m.handleCommandStateAck(w, r) + return + } if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return @@ -940,12 +1041,33 @@ func (m *Manager) handleCommandState(w http.ResponseWriter, r *http.Request) { return } closeSent := entry.info.CloseSent + hidden := entry.info.Hidden + visibilityRevision := entry.visibilityRevision + pendingFocusRevision := entry.pendingFocusRevision reason := entry.exitReason m.mu.RUnlock() - if !closeSent { + if !closeSent && !hidden && pendingFocusRevision == 0 { w.WriteHeader(http.StatusNoContent) return } + if hidden && !closeSent { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(childCommand{ + ID: id, + Action: "hide", + Payload: visibilityCommandPayload{VisibilityRevision: visibilityRevision}, + }) + return + } + if !closeSent { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(childCommand{ + ID: id, + Action: "focus", + Payload: visibilityCommandPayload{VisibilityRevision: pendingFocusRevision}, + }) + return + } if strings.TrimSpace(reason) == "" { reason = ExitReasonRequested } @@ -957,6 +1079,51 @@ func (m *Manager) handleCommandState(w http.ResponseWriter, r *http.Request) { }) } +type commandStateRequest struct { + Action string `json:"action"` + VisibilityRevision uint64 `json:"visibilityRevision"` +} + +func (m *Manager) handleCommandStateAck(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + var request commandStateRequest + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)) + if err := decoder.Decode(&request); err != nil { + http.Error(w, "invalid detached command acknowledgement", http.StatusBadRequest) + return + } + request.Action = strings.ToLower(strings.TrimSpace(request.Action)) + if request.Action != "ack-focus" || request.VisibilityRevision == 0 { + http.Error(w, "invalid detached command acknowledgement", http.StatusBadRequest) + return + } + + id := strings.TrimSpace(r.Header.Get(HeaderWindowID)) + m.mu.Lock() + entry, exists := m.windows[id] + if !exists { + m.mu.Unlock() + http.Error(w, "unknown detached window", http.StatusNotFound) + return + } + message := "" + if entry.pendingFocusRevision == request.VisibilityRevision { + entry.pendingFocusRevision = 0 + } else { + message = "stale focus acknowledgement ignored" + } + visibilityRevision := entry.visibilityRevision + m.mu.Unlock() + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(OperationResult{ + Success: true, + ID: id, + Message: message, + VisibilityRevision: visibilityRevision, + }) +} + func (m *Manager) handleAction(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) @@ -971,7 +1138,7 @@ func (m *Manager) handleAction(w http.ResponseWriter, r *http.Request) { } request.Action = strings.ToLower(strings.TrimSpace(request.Action)) switch request.Action { - case "ready", "sync", "attach", "close", "cancel-close", "host-event", "open-ai-settings": + case "ready", "sync", "attach", "close", "hide", "cancel-close", "host-event", "open-ai-settings": default: http.Error(w, "unsupported detached action", http.StatusBadRequest) return @@ -988,17 +1155,21 @@ func (m *Manager) handleAction(w http.ResponseWriter, r *http.Request) { } if actionUsesRevision(request.Action) && revision > 0 { if revision <= entry.actionRevision { + visibilityRevision := entry.visibilityRevision m.mu.Unlock() w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(OperationResult{ - Success: true, - ID: id, - Message: "stale detached action ignored", + Success: true, + ID: id, + Message: "stale detached action ignored", + VisibilityRevision: visibilityRevision, }) return } entry.actionRevision = revision } + eventAction := request.Action + visibilityRevision := uint64(0) if request.Action == "ready" { entry.info.Ready = true entry.readyOnce.Do(func() { @@ -1008,15 +1179,39 @@ func (m *Manager) handleAction(w http.ResponseWriter, r *http.Request) { }) } else if request.Action == "attach" { entry.exitReason = ExitReasonAttached + entry.pendingFocusRevision = 0 } else if request.Action == "close" && entry.exitReason == "" { entry.exitReason = ExitReasonWindowClosed + entry.pendingFocusRevision = 0 + } else if request.Action == "hide" { + requestedVisibilityRevision := positiveVisibilityRevision(request.Payload) + switch { + case requestedVisibilityRevision == 0: + if !entry.info.Hidden { + entry.visibilityRevision++ + entry.info.Hidden = true + } + entry.pendingFocusRevision = 0 + visibilityRevision = entry.visibilityRevision + case requestedVisibilityRevision < entry.visibilityRevision: + // Preserve the final child snapshot, but do not let an old hide + // transition close a window that the host has already focused again. + visibilityRevision = requestedVisibilityRevision + eventAction = "sync" + default: + entry.visibilityRevision = requestedVisibilityRevision + entry.info.Hidden = true + entry.pendingFocusRevision = 0 + visibilityRevision = requestedVisibilityRevision + } + request.Payload = withVisibilityRevision(request.Payload, visibilityRevision) } else if request.Action == "cancel-close" { m.cancelCloseLocked(entry) } info := entry.info ownerID := entry.ownerID m.mu.Unlock() - if request.Action == "ready" || request.Action == "cancel-close" { + if request.Action == "ready" || request.Action == "hide" || request.Action == "cancel-close" { publishDetachedDockMenuSnapshot(m) } @@ -1024,16 +1219,76 @@ func (m *Manager) handleAction(w http.ResponseWriter, r *http.Request) { m.emitDetached(Event{ ID: info.ID, Kind: info.Kind, - Action: request.Action, + Action: eventAction, Payload: withOwnerWindowID(request.Payload, ownerID), }) } w.Header().Set("Content-Type", "application/json; charset=utf-8") - _ = json.NewEncoder(w).Encode(OperationResult{Success: true, ID: id}) + _ = json.NewEncoder(w).Encode(OperationResult{ + Success: true, + ID: id, + VisibilityRevision: visibilityRevision, + }) } func actionUsesRevision(action string) bool { - return action == "sync" || action == "attach" || action == "close" + return action == "sync" || action == "attach" || action == "close" || action == "hide" +} + +func positiveVisibilityRevision(payload any) uint64 { + switch typed := payload.(type) { + case visibilityCommandPayload: + return typed.VisibilityRevision + case *visibilityCommandPayload: + if typed != nil { + return typed.VisibilityRevision + } + } + record, ok := payload.(map[string]any) + if !ok { + return 0 + } + return positiveUintRevision(record["visibilityRevision"]) +} + +func positiveUintRevision(value any) uint64 { + switch typed := value.(type) { + case float64: + if typed > 0 && typed <= 9_007_199_254_740_991 && math.Trunc(typed) == typed { + return uint64(typed) + } + case json.Number: + revision, err := typed.Int64() + if err == nil && revision > 0 { + return uint64(revision) + } + case uint64: + return typed + case uint: + return uint64(typed) + case int64: + if typed > 0 { + return uint64(typed) + } + case int: + if typed > 0 { + return uint64(typed) + } + } + return 0 +} + +func withVisibilityRevision(payload any, visibilityRevision uint64) any { + result := make(map[string]any) + if source, ok := payload.(map[string]any); ok { + for key, value := range source { + result[key] = value + } + } else if payload != nil { + result["value"] = payload + } + result["visibilityRevision"] = visibilityRevision + return result } func positiveActionRevision(payload any) int64 { @@ -1105,6 +1360,12 @@ func (m *Manager) handleControl(w http.ResponseWriter, r *http.Request) { break } result = m.Focus(request.ID) + case "hide": + if !m.ownsWindow(request.ID, ownerID) { + result = operationFailure("native window is not owned by this window") + break + } + result = m.Hide(request.ID) case "close": if !m.ownsWindow(request.ID, ownerID) { result = operationFailure("native window is not owned by this window") diff --git a/internal/nativewindow/manager_test.go b/internal/nativewindow/manager_test.go index 262c9b33..d0378f7c 100644 --- a/internal/nativewindow/manager_test.go +++ b/internal/nativewindow/manager_test.go @@ -473,6 +473,318 @@ func TestManagerRoutesCommandsAndAIStreamsToOnlyTheirTargetWindow(t *testing.T) } } +func TestManagerHideIsIdempotentAndFocusAdvancesVisibilityRevision(t *testing.T) { + manager := newHTTPTestManager(t) + manager.windows["ai-chat"] = &windowEntry{ + info: WindowInfo{ + ID: "ai-chat", + Kind: "ai-chat", + X: 10, + Y: 20, + Width: 440, + Height: 720, + }, + } + commands := make(chan childCommand, 3) + manager.emitToChild = func(targetID string, name string, args ...any) { + if targetID != "ai-chat" || name != CommandEventName { + t.Fatalf("unexpected target event %q %q", targetID, name) + } + commands <- args[0].(childCommand) + } + + firstHide := manager.Hide("ai-chat") + if !firstHide.Success || firstHide.VisibilityRevision != 1 { + t.Fatalf("first Hide result = %#v", firstHide) + } + firstHideCommand := <-commands + if firstHideCommand.Action != "hide" || + firstHideCommand.Payload.(visibilityCommandPayload).VisibilityRevision != 1 { + t.Fatalf("first hide command = %#v", firstHideCommand) + } + + secondHide := manager.Hide("ai-chat") + if !secondHide.Success || secondHide.VisibilityRevision != 1 { + t.Fatalf("second Hide result = %#v", secondHide) + } + secondHideCommand := <-commands + if secondHideCommand.Action != "hide" || + secondHideCommand.Payload.(visibilityCommandPayload).VisibilityRevision != 1 { + t.Fatalf("second hide command = %#v", secondHideCommand) + } + + focus := manager.Focus("ai-chat") + if !focus.Success || focus.VisibilityRevision != 2 { + t.Fatalf("Focus result = %#v", focus) + } + focusCommand := <-commands + if focusCommand.Action != "focus" || + focusCommand.Payload.(visibilityCommandPayload).VisibilityRevision != 2 { + t.Fatalf("focus command = %#v", focusCommand) + } + manager.mu.RLock() + hidden := manager.windows["ai-chat"].info.Hidden + manager.mu.RUnlock() + if hidden { + t.Fatal("focused window remained hidden in manager state") + } +} + +func TestManagerReplaysLatestFocusUntilChildAcknowledgesIt(t *testing.T) { + manager := newHTTPTestManager(t) + manager.windows["ai-chat"] = &windowEntry{ + info: WindowInfo{ + ID: "ai-chat", + Kind: "ai-chat", + Hidden: true, + }, + visibilityRevision: 1, + } + // Simulate a disconnected child: reliable SSE has no subscriber and the + // immediate delivery therefore disappears. + manager.emitToChild = func(string, string, ...any) {} + + first := manager.Focus("ai-chat") + second := manager.Focus("ai-chat") + if !first.Success || first.VisibilityRevision != 2 || + !second.Success || second.VisibilityRevision != 3 { + t.Fatalf("Focus results = %#v %#v", first, second) + } + + replay := func() (*httptest.ResponseRecorder, childCommand) { + t.Helper() + request := authenticatedRequest(manager, http.MethodGet, CommandStatePath, "ai-chat", nil) + recorder := httptest.NewRecorder() + manager.authenticatedHandler().ServeHTTP(recorder, request) + var command childCommand + if recorder.Code == http.StatusOK { + if err := json.NewDecoder(recorder.Body).Decode(&command); err != nil { + t.Fatalf("decode command state: %v", err) + } + } + return recorder, command + } + + recorder, command := replay() + if recorder.Code != http.StatusOK || command.Action != "focus" || + positiveVisibilityRevision(command.Payload) != 3 { + t.Fatalf("pending focus replay = status %d command %#v", recorder.Code, command) + } + + staleAck := strings.NewReader(`{"action":"ack-focus","visibilityRevision":2}`) + staleRequest := authenticatedRequest(manager, http.MethodPost, CommandStatePath, "ai-chat", staleAck) + staleRecorder := httptest.NewRecorder() + manager.authenticatedHandler().ServeHTTP(staleRecorder, staleRequest) + if staleRecorder.Code != http.StatusOK { + t.Fatalf("stale focus ack status = %d body=%s", staleRecorder.Code, staleRecorder.Body.String()) + } + if recorder, command = replay(); recorder.Code != http.StatusOK || + command.Action != "focus" || positiveVisibilityRevision(command.Payload) != 3 { + t.Fatalf("focus after stale ack = status %d command %#v", recorder.Code, command) + } + + latestAck := strings.NewReader(`{"action":"ack-focus","visibilityRevision":3}`) + latestRequest := authenticatedRequest(manager, http.MethodPost, CommandStatePath, "ai-chat", latestAck) + latestRecorder := httptest.NewRecorder() + manager.authenticatedHandler().ServeHTTP(latestRecorder, latestRequest) + if latestRecorder.Code != http.StatusOK { + t.Fatalf("latest focus ack status = %d body=%s", latestRecorder.Code, latestRecorder.Body.String()) + } + if recorder, _ = replay(); recorder.Code != http.StatusNoContent { + t.Fatalf("command state after focus ack = %d body=%s, want 204", recorder.Code, recorder.Body.String()) + } +} + +func TestManagerCommandStatePrioritizesHideAndCloseOverPendingFocus(t *testing.T) { + manager := newHTTPTestManager(t) + manager.windows["ai-chat"] = &windowEntry{ + info: WindowInfo{ID: "ai-chat", Kind: "ai-chat", Hidden: true}, + visibilityRevision: 1, + } + manager.emitToChild = func(string, string, ...any) {} + + if result := manager.Focus("ai-chat"); !result.Success { + t.Fatalf("Focus result = %#v", result) + } + if result := manager.Hide("ai-chat"); !result.Success { + t.Fatalf("Hide result = %#v", result) + } + request := authenticatedRequest(manager, http.MethodGet, CommandStatePath, "ai-chat", nil) + recorder := httptest.NewRecorder() + manager.authenticatedHandler().ServeHTTP(recorder, request) + var command childCommand + if err := json.NewDecoder(recorder.Body).Decode(&command); err != nil { + t.Fatalf("decode hidden command state: %v", err) + } + if recorder.Code != http.StatusOK || command.Action != "hide" { + t.Fatalf("hidden command state = status %d command %#v", recorder.Code, command) + } + + if result := manager.Close("ai-chat"); !result.Success { + t.Fatalf("Close result = %#v", result) + } + request = authenticatedRequest(manager, http.MethodGet, CommandStatePath, "ai-chat", nil) + recorder = httptest.NewRecorder() + manager.authenticatedHandler().ServeHTTP(recorder, request) + if err := json.NewDecoder(recorder.Body).Decode(&command); err != nil { + t.Fatalf("decode closing command state: %v", err) + } + if recorder.Code != http.StatusOK || command.Action != "close" { + t.Fatalf("closing command state = status %d command %#v", recorder.Code, command) + } +} + +func TestManagerFocusAndOpenRejectClosingWindowForRetry(t *testing.T) { + manager := newHTTPTestManager(t) + starter := &fakeProcessStarter{} + manager.started = true + manager.endpoint = "http://127.0.0.1:43119" + manager.starter = starter + manager.windows["ai-chat"] = &windowEntry{ + info: WindowInfo{ + ID: "ai-chat", + Kind: "ai-chat", + Title: "Existing", + X: 10, + Y: 20, + Width: 440, + Height: 720, + Hidden: true, + CloseSent: true, + }, + payload: map[string]any{"snapshot": "existing"}, + visibilityRevision: 4, + } + + for name, result := range map[string]OperationResult{ + "focus": manager.Focus("ai-chat"), + "open": manager.Open(OpenRequest{ + ID: "ai-chat", + Kind: "ai-chat", + Title: "Replacement", + Payload: map[string]any{"snapshot": "replacement"}, + X: 30, + Y: 40, + Width: 500, + Height: 800, + }), + } { + if result.Success || !strings.Contains(result.Message, "closing") || + !strings.Contains(result.Message, "retry") { + t.Fatalf("%s result = %#v, want explicit retry failure", name, result) + } + } + + manager.mu.RLock() + entry := manager.windows["ai-chat"] + payload := entry.payload.(map[string]any)["snapshot"] + info := entry.info + revision := entry.visibilityRevision + manager.mu.RUnlock() + if payload != "existing" || info.Title != "Existing" || info.X != 10 || info.Y != 20 || + !info.Hidden || revision != 4 { + t.Fatalf("closing entry was mutated: info=%#v payload=%#v revision=%d", info, payload, revision) + } + starter.mu.Lock() + starts := len(starter.specs) + starter.mu.Unlock() + if starts != 0 { + t.Fatalf("closing entry spawned %d replacement processes, want 0", starts) + } +} + +func TestHideActionParksWithoutCommittingTerminalState(t *testing.T) { + manager := newHTTPTestManager(t) + manager.windows["ai-chat"] = &windowEntry{ + info: WindowInfo{ID: "ai-chat", Kind: "ai-chat", Ready: true}, + } + events := make(chan Event, 2) + manager.runtimeCtx = context.Background() + manager.emitToWails = func(_ context.Context, name string, args ...any) { + if name == MainEventName && len(args) == 1 { + events <- args[0].(Event) + } + } + + body := strings.NewReader(`{"action":"hide","payload":{"id":"ai-chat","kind":"ai-chat","revision":1}}`) + request := authenticatedRequest(manager, http.MethodPost, ActionPath, "ai-chat", body) + recorder := httptest.NewRecorder() + manager.authenticatedHandler().ServeHTTP(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("hide status = %d body=%s", recorder.Code, recorder.Body.String()) + } + var result OperationResult + if err := json.NewDecoder(recorder.Body).Decode(&result); err != nil { + t.Fatalf("decode hide result: %v", err) + } + if !result.Success || result.VisibilityRevision != 1 { + t.Fatalf("hide result = %#v", result) + } + event := receiveEvent(t, events) + if event.Action != "hide" || event.ID != "ai-chat" { + t.Fatalf("hide event = %#v", event) + } + payload := event.Payload.(map[string]any) + if payload["visibilityRevision"] != uint64(1) { + t.Fatalf("hide payload = %#v", payload) + } + manager.mu.RLock() + entry := manager.windows["ai-chat"] + hidden := entry.info.Hidden + closeSent := entry.info.CloseSent + exitReason := entry.exitReason + manager.mu.RUnlock() + if !hidden || closeSent || exitReason != "" { + t.Fatalf( + "parked state = hidden %v closeSent %v reason %q", + hidden, + closeSent, + exitReason, + ) + } +} + +func TestStaleHideActionCannotOverrideNewerFocus(t *testing.T) { + manager := newHTTPTestManager(t) + manager.windows["ai-chat"] = &windowEntry{ + info: WindowInfo{ID: "ai-chat", Kind: "ai-chat", Ready: true, Hidden: true}, + visibilityRevision: 1, + actionRevision: 1, + } + events := make(chan Event, 1) + manager.runtimeCtx = context.Background() + manager.emitToWails = func(_ context.Context, name string, args ...any) { + if name == MainEventName && len(args) == 1 { + events <- args[0].(Event) + } + } + + if result := manager.Focus("ai-chat"); !result.Success || result.VisibilityRevision != 2 { + t.Fatalf("Focus result = %#v", result) + } + body := strings.NewReader( + `{"action":"hide","payload":{"id":"ai-chat","kind":"ai-chat","revision":2,"visibilityRevision":1}}`, + ) + request := authenticatedRequest(manager, http.MethodPost, ActionPath, "ai-chat", body) + recorder := httptest.NewRecorder() + manager.authenticatedHandler().ServeHTTP(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("stale hide status = %d body=%s", recorder.Code, recorder.Body.String()) + } + event := receiveEvent(t, events) + if event.Action != "sync" { + t.Fatalf("stale hide event = %#v, want sync", event) + } + manager.mu.RLock() + entry := manager.windows["ai-chat"] + hidden := entry.info.Hidden + revision := entry.visibilityRevision + manager.mu.RUnlock() + if hidden || revision != 2 { + t.Fatalf("state after stale hide = hidden %v revision %d", hidden, revision) + } +} + func TestAuthenticatedHostStateEndpointReturnsRetainedSnapshot(t *testing.T) { manager := newHTTPTestManager(t) manager.windows["ai-chat"] = &windowEntry{ diff --git a/internal/nativewindow/runtime_script.go b/internal/nativewindow/runtime_script.go index 7e887b54..cf558c7b 100644 --- a/internal/nativewindow/runtime_script.go +++ b/internal/nativewindow/runtime_script.go @@ -14,6 +14,7 @@ func detachedRuntimeBridgeScript() string { || typeof bridge.WindowID !== 'function' || typeof bridge.OpenWindow !== 'function' || typeof bridge.FocusWindow !== 'function' + || typeof bridge.HideWindow !== 'function' || typeof bridge.CloseWindow !== 'function' || typeof bridge.CloseOwnedWindows !== 'function' ) { @@ -41,6 +42,9 @@ func detachedRuntimeBridgeScript() string { Focus: function (id) { return bridge.FocusWindow(String(id || '')); }, + Hide: function (id) { + return bridge.HideWindow(String(id || '')); + }, Close: function (id) { return bridge.CloseWindow(String(id || '')); }, @@ -103,6 +107,15 @@ func detachedRuntimeBridgeScript() string { detail: { reason: String(reason || '') } })); }; + var visibilityRevisionOf = function (command) { + var value = Number(command && command.payload && command.payload.visibilityRevision); + return Number.isFinite(value) && value >= 0 ? Math.trunc(value) : 0; + }; + var requestGracefulHide = function (command) { + window.dispatchEvent(new CustomEvent('` + GracefulHideRequestEventName + `', { + detail: { visibilityRevision: visibilityRevisionOf(command) } + })); + }; var runtime = window.runtime || {}; if (typeof runtime.EventsOnMultiple === 'function') { @@ -111,8 +124,14 @@ func detachedRuntimeBridgeScript() string { if (!command || String(command.id || '') !== windowID) return; if (command.action === 'close') { requestGracefulClose(command.reason); - } else if (command.action === 'focus' && control && typeof control.Focus === 'function') { - control.Focus(); + } else if (command.action === 'hide') { + requestGracefulHide(command); + } else if (command.action === 'focus' && control) { + if (typeof control.FocusRevision === 'function') { + control.FocusRevision(visibilityRevisionOf(command)); + } else if (typeof control.Focus === 'function') { + control.Focus(); + } } }); }, -1); diff --git a/internal/nativewindow/runtime_script_test.go b/internal/nativewindow/runtime_script_test.go index ba45fed1..f2a320c6 100644 --- a/internal/nativewindow/runtime_script_test.go +++ b/internal/nativewindow/runtime_script_test.go @@ -28,6 +28,7 @@ func TestRuntimeExposesParentWindowManagerInsideDetachedChildren(t *testing.T) { "Manager: parentWindowManager", "bridge.OpenWindow(request || {})", "bridge.FocusWindow", + "bridge.HideWindow", "bridge.CloseWindow", } { if !strings.Contains(script, expected) { @@ -36,6 +37,23 @@ func TestRuntimeExposesParentWindowManagerInsideDetachedChildren(t *testing.T) { } } +func TestRuntimeRoutesRevisionedHideAndFocusCommands(t *testing.T) { + script := detachedRuntimeBridgeScript() + for _, expected := range []string{ + GracefulHideRequestEventName, + "requestGracefulHide(command)", + "visibilityRevisionOf(command)", + "control.FocusRevision(visibilityRevisionOf(command))", + } { + if !strings.Contains(script, expected) { + t.Fatalf("runtime bridge is missing revisioned visibility marker %q", expected) + } + } + if strings.Contains(script, "control.Hide(") { + t.Fatal("runtime hide command bypasses the frontend state flush") + } +} + func TestRuntimeRoutesParentCloseThroughGracefulFrontendEvent(t *testing.T) { script := detachedRuntimeBridgeScript() for _, expected := range []string{ diff --git a/internal/nativewindow/types.go b/internal/nativewindow/types.go index 05d71d06..d888c6cf 100644 --- a/internal/nativewindow/types.go +++ b/internal/nativewindow/types.go @@ -25,6 +25,11 @@ const ( // GracefulCloseRequestEventName is dispatched inside a detached WebView so // React can flush state before the native child process exits. GracefulCloseRequestEventName = "gonavi:native-detached-request-close" + // GracefulHideRequestEventName asks an already-mounted detached frontend to + // flush its state before parking the native window without exiting its + // process. The visibility revision prevents a late hide from winning over a + // newer focus request. + GracefulHideRequestEventName = "gonavi:native-detached-request-hide" ExitReasonRequested = "requested" ExitReasonWindowClosed = "window-closed" @@ -78,6 +83,7 @@ type WindowInfo struct { OpenedAt int64 `json:"openedAt"` Ready bool `json:"ready"` CloseSent bool `json:"closeSent"` + Hidden bool `json:"hidden,omitempty"` } // Bootstrap is fetched by the child after Wails has installed its native @@ -91,10 +97,11 @@ type Bootstrap struct { // OperationResult is returned by the Wails-bound Manager commands. type OperationResult struct { - Success bool `json:"success"` - Message string `json:"message,omitempty"` - ID string `json:"id,omitempty"` - Bounds *WindowBounds `json:"bounds,omitempty"` + Success bool `json:"success"` + Message string `json:"message,omitempty"` + ID string `json:"id,omitempty"` + Bounds *WindowBounds `json:"bounds,omitempty"` + VisibilityRevision uint64 `json:"visibilityRevision,omitempty"` } // HostStateRequest carries main-window state that an active detached child diff --git a/internal/nativewindow/visibility_gate_test.go b/internal/nativewindow/visibility_gate_test.go index a00c81ce..f9859647 100644 --- a/internal/nativewindow/visibility_gate_test.go +++ b/internal/nativewindow/visibility_gate_test.go @@ -2,8 +2,11 @@ package nativewindow import ( "context" + "encoding/json" + "errors" "io" "net/http" + "net/http/httptest" "strings" "testing" ) @@ -74,6 +77,152 @@ func TestDetachedChildShowsAfterReadyAndFocusesWithoutShowingAgain(t *testing.T) } } +func TestDetachedChildAcknowledgesFocusOnlyAfterDelayedPresentation(t *testing.T) { + bridge, control := newVisibilityTestChild() + ctx := context.Background() + InitializeControl(control, ctx) + + steps := make([]string, 0, 4) + control.showWindow = func(context.Context) { steps = append(steps, "show") } + control.focusWindow = func(context.Context) { steps = append(steps, "focus") } + bridge.client.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + switch request.URL.Path { + case CommandStatePath: + var acknowledgement commandStateRequest + if err := json.NewDecoder(request.Body).Decode(&acknowledgement); err != nil { + return nil, err + } + steps = append(steps, "ack-focus") + if acknowledgement.Action != "ack-focus" || acknowledgement.VisibilityRevision != 7 { + t.Fatalf("focus acknowledgement = %#v", acknowledgement) + } + case ActionPath: + steps = append(steps, "post-ready") + } + return successfulVisibilityResponse(), nil + }) + + if result := control.FocusRevision(7); !result.Success { + t.Fatalf("pre-ready FocusRevision result = %#v", result) + } + if len(steps) != 0 { + t.Fatalf("pre-ready steps = %#v, want none", steps) + } + control.markDOMReady(ctx) + if len(steps) != 0 { + t.Fatalf("DOM-ready steps = %#v, want none before frontend presentation", steps) + } + if result := bridge.Action("ready", map[string]any{"id": "window-1"}); !result.Success { + t.Fatalf("ready Action result = %#v", result) + } + if got := strings.Join(steps, ","); got != "show,focus,ack-focus,post-ready" { + t.Fatalf("delayed focus sequence = %q", got) + } +} + +func TestDetachedChildFailedFocusAcknowledgementLeavesParentPendingForRetry(t *testing.T) { + manager := newHTTPTestManager(t) + manager.windows["ai-chat"] = &windowEntry{ + info: WindowInfo{ID: "ai-chat", Kind: "ai-chat", Ready: true}, + visibilityRevision: 7, + pendingFocusRevision: 7, + } + bridge := newBridge(ChildOptions{ + ParentURL: "http://127.0.0.1:43119", + Token: manager.token, + ID: "ai-chat", + Kind: "ai-chat", + }) + control := newControl(bridge) + ctx := context.Background() + InitializeControl(control, ctx) + control.showWindow = func(context.Context) {} + focuses := 0 + control.focusWindow = func(context.Context) { focuses++ } + control.markDOMReady(ctx) + if result := control.markFrontendReady(); !result.Success { + t.Fatalf("markFrontendReady result = %#v", result) + } + + failAcknowledgement := true + bridge.client.Transport = roundTripFunc(func(request *http.Request) (*http.Response, error) { + if failAcknowledgement { + return nil, errors.New("temporary parent connection failure") + } + request.RemoteAddr = "127.0.0.1:51003" + recorder := httptest.NewRecorder() + manager.authenticatedHandler().ServeHTTP(recorder, request) + return recorder.Result(), nil + }) + + if result := control.FocusRevision(7); !result.Success { + t.Fatalf("FocusRevision with failed acknowledgement = %#v", result) + } + manager.mu.RLock() + pendingAfterFailure := manager.windows["ai-chat"].pendingFocusRevision + manager.mu.RUnlock() + if pendingAfterFailure != 7 { + t.Fatalf("pending focus after failed acknowledgement = %d, want 7", pendingAfterFailure) + } + + failAcknowledgement = false + if result := control.FocusRevision(7); !result.Success { + t.Fatalf("FocusRevision retry result = %#v", result) + } + manager.mu.RLock() + pendingAfterRetry := manager.windows["ai-chat"].pendingFocusRevision + manager.mu.RUnlock() + if pendingAfterRetry != 0 || focuses != 2 { + t.Fatalf("retry state = pending %d focuses %d, want 0/2", pendingAfterRetry, focuses) + } +} + +func TestDetachedChildIgnoresLateHideAfterNewerFocus(t *testing.T) { + bridge, control := newVisibilityTestChild() + ctx := context.Background() + InitializeControl(control, ctx) + + shows := 0 + hides := 0 + focuses := 0 + control.showWindow = func(context.Context) { shows++ } + control.hideWindow = func(context.Context) { hides++ } + control.focusWindow = func(context.Context) { focuses++ } + control.markDOMReady(ctx) + if result := bridge.Action("ready", map[string]any{"id": "window-1"}); !result.Success { + t.Fatalf("ready Action result = %#v", result) + } + + if result := control.Hide(1); !result.Success || result.VisibilityRevision != 1 { + t.Fatalf("Hide result = %#v", result) + } + if hides != 1 { + t.Fatalf("hides after revision 1 = %d, want 1", hides) + } + if result := control.FocusRevision(2); !result.Success || result.VisibilityRevision != 2 { + t.Fatalf("FocusRevision result = %#v", result) + } + if shows != 2 || focuses != 1 { + t.Fatalf("presentation after focus = show %d focus %d, want 2/1", shows, focuses) + } + + lateHide := control.Hide(1) + if !lateHide.Success || lateHide.VisibilityRevision != 2 || + !strings.Contains(lateHide.Message, "stale") { + t.Fatalf("late Hide result = %#v", lateHide) + } + if hides != 1 { + t.Fatalf("late hide reached native window: hides = %d, want 1", hides) + } + control.mu.RLock() + visible := control.visible + revision := control.visibilityRevision + control.mu.RUnlock() + if !visible || revision != 2 { + t.Fatalf("final visibility state = visible %v revision %d", visible, revision) + } +} + func TestDetachedChildPresentsBeforePaintReadyWithoutShowingTwice(t *testing.T) { bridge, control := newVisibilityTestChild() ctx := context.Background()