️ perf(native-window): 优化 AI 独立窗口启动与交互

- 关闭时隐藏保活,重开复用现有进程并通过可见性版本消除竞态
- 拆分 AI 与工作台资源,使用启动状态白名单减少序列化开销
- 修复设置中心层级、Windows 前台切换及独立窗口关闭交互
- 补齐状态同步、断线重放与窗口生命周期回归测试
This commit is contained in:
Syngnat
2026-07-21 23:40:17 +08:00
parent 62a045b39d
commit fda0733f11
38 changed files with 3147 additions and 1051 deletions

View File

@@ -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(/<SecurityUpdateSettingsModal/g)?.length).toBe(1);
expect(appSource).toContain('<SecurityUpdateSettingsModal\n embedded');
expect(appSource).toMatch(/<SecurityUpdateSettingsModal\r?\n\s+embedded/);
expect(appSource).not.toContain('isSecurityUpdateSettingsOpen');
expect(appSource).not.toContain('setIsSecurityUpdateSettingsOpen');
});
@@ -181,6 +181,19 @@ describe('settings center layout', () => {
expect(appSource).not.toContain('<AISettingsModal');
});
it('keeps the settings center above the in-webview detached AI fallback', () => {
const settingsModalStart = appSource.indexOf(
"title={renderUtilityModalTitle(<SettingOutlined />, 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");

View File

@@ -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<boolean>;
/** 设置页 Slider 底部预设刻度 */
const UI_SCALE_SLIDER_MARKS: Record<number, string> = {
@@ -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 },

View File

@@ -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';

View File

@@ -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';

View File

@@ -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(<NativeDetachedWindowApp client={client} />);
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<void>((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<number>((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(<NativeDetachedWindowApp client={client} />);
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(<NativeDetachedWindowApp client={client} />);
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<boolean>((resolve) => {

View File

@@ -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<void>;
sync: (payload: NativeDetachedWindowActionPayload) => Promise<void>;
attach: (payload: NativeDetachedWindowActionPayload) => Promise<void>;
hide?: (payload: NativeDetachedWindowActionPayload) => Promise<number>;
close: (payload: NativeDetachedWindowActionPayload) => Promise<void>;
cancelCloseRequest?: (payload: NativeDetachedWindowActionPayload) => Promise<void>;
openAISettings: (payload: NativeDetachedWindowActionPayload) => Promise<void>;
hostEvent?: (payload: NativeDetachedWindowActionPayload) => Promise<void>;
closeCurrentWindow: () => Promise<void>;
hideCurrentWindow?: (visibilityRevision: number) => Promise<void>;
cancelClose?: () => Promise<void>;
};
@@ -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<NativeDetachedWindowAppProps> = ({
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<boolean>) | null>(null);
const resultSessionRef = useRef<QueryEditorResultSessionSnapshot | null>(null);
const queryResultWindowRef = useRef<DetachedQueryResultWindow | null>(null);
@@ -680,9 +688,27 @@ const NativeDetachedWindowApp: React.FC<NativeDetachedWindowAppProps> = ({
};
}, [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<NativeDetachedWindowAppProps> = ({
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<NativeDetachedWindowAppProps> = ({
? 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<NativeDetachedWindowAppProps> = ({
}
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<NativeDetachedWindowAppProps> = ({
bootstrap,
readCurrentTab(),
currentSession,
terminalAction === 'attach',
actionToRun === 'attach',
readUnsyncedSqlLogs(),
nextActionRevision(),
terminalWorkbench.workbenchState,
@@ -786,47 +845,113 @@ const NativeDetachedWindowApp: React.FC<NativeDetachedWindowAppProps> = ({
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<NativeDetachedWindowAppProps> = ({
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<NativeDetachedWindowAppProps> = ({
icon={<CloseOutlined />}
aria-label={chromeLabels.close}
disabled={!bootstrap || Boolean(terminalAction)}
onClick={() => requestTerminalAction('close')}
onClick={requestWindowClose}
/>
</Tooltip>
</div>
@@ -1036,7 +1165,7 @@ const NativeDetachedWindowApp: React.FC<NativeDetachedWindowAppProps> = ({
bootstrap={bootstrap}
onContentReady={markContentReady}
onAttach={() => requestTerminalAction('attach')}
onClose={() => requestTerminalAction('close')}
onClose={requestWindowClose}
onOpenSettings={requestOpenAISettings}
onRegisterAITerminalGuard={(guard) => {
aiTerminalGuardRef.current = guard;

View File

@@ -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',

View File

@@ -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<typeof setTimeout> | 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<string>();
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;

View File

@@ -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'));

View File

@@ -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,
);
});
});

View File

@@ -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';

View File

@@ -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 () => {

View File

@@ -5150,14 +5150,16 @@ export const useStore = create<AppState>()(
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<AppState>()(
: state.aiChatDetachedBoundsMemory;
return {
aiPanelVisible: false,
detachedAIChatWindow: null,
detachedAIChatWindow: state.detachedAIChatWindow,
aiChatDetachedBoundsMemory: memory,
};
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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');

View File

@@ -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);

View File

@@ -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<void> => {
): Promise<NativeDetachedWindowActionResult> => {
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<void> => postNativeDetachedWindowAction('sync', payload, fetchImpl);
): Promise<void> => postNativeDetachedWindowAction('sync', payload, fetchImpl).then(() => undefined);
export const readyNativeDetachedWindow = (
payload: NativeDetachedWindowActionPayload,
fetchImpl?: FetchLike,
): Promise<void> => postNativeDetachedWindowAction('ready', payload, fetchImpl);
): Promise<void> => postNativeDetachedWindowAction('ready', payload, fetchImpl).then(() => undefined);
export const attachNativeDetachedWindow = (
payload: NativeDetachedWindowActionPayload,
fetchImpl?: FetchLike,
): Promise<void> => postNativeDetachedWindowAction('attach', payload, fetchImpl);
): Promise<void> => postNativeDetachedWindowAction('attach', payload, fetchImpl).then(() => undefined);
export const hideNativeDetachedWindow = async (
payload: NativeDetachedWindowActionPayload,
fetchImpl?: FetchLike,
): Promise<number> => {
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<void> => postNativeDetachedWindowAction('close', payload, fetchImpl);
): Promise<void> => postNativeDetachedWindowAction('close', payload, fetchImpl).then(() => undefined);
export const cancelNativeDetachedWindowClose = (
payload: NativeDetachedWindowActionPayload,
fetchImpl?: FetchLike,
): Promise<void> => postNativeDetachedWindowAction('cancel-close', payload, fetchImpl);
): Promise<void> => postNativeDetachedWindowAction('cancel-close', payload, fetchImpl).then(() => undefined);
export const openNativeDetachedAISettings = (
payload: NativeDetachedWindowActionPayload,
fetchImpl?: FetchLike,
): Promise<void> => postNativeDetachedWindowAction('open-ai-settings', payload, fetchImpl);
): Promise<void> => postNativeDetachedWindowAction('open-ai-settings', payload, fetchImpl).then(() => undefined);
export const sendNativeDetachedHostEvent = (
payload: NativeDetachedWindowActionPayload,
fetchImpl?: FetchLike,
): Promise<void> => postNativeDetachedWindowAction('host-event', payload, fetchImpl);
): Promise<void> => postNativeDetachedWindowAction('host-event', payload, fetchImpl).then(() => undefined);
export const presentCurrentNativeDetachedWindow = async (): Promise<void> => {
const present = typeof window !== 'undefined'
@@ -953,6 +988,21 @@ export const closeCurrentNativeDetachedWindow = async (): Promise<void> => {
}
};
export const hideCurrentNativeDetachedWindow = async (
visibilityRevision: number,
): Promise<void> => {
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<void> => {
const cancelClose = typeof window !== 'undefined'
? (window as any).go?.nativewindow?.Control?.CancelClose

View File

@@ -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 });
}
});

View File

@@ -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<DetachedWindowBounds, 'x' | 'y' | 'width' | 'height'>;
};
@@ -47,6 +47,7 @@ export type NativeDetachedWindowOpenRequest = {
export type NativeDetachedWindowManager = {
Open: (request: NativeDetachedWindowOpenRequest) => Promise<NativeDetachedWindowOperationResult>;
Focus: (id: string) => Promise<NativeDetachedWindowOperationResult>;
Hide?: (id: string) => Promise<NativeDetachedWindowOperationResult>;
Close: (id: string) => Promise<NativeDetachedWindowOperationResult>;
CloseAll: () => Promise<NativeDetachedWindowOperationResult>;
SyncHostState?: (request: NativeDetachedHostStateRequest) => Promise<NativeDetachedWindowOperationResult>;
@@ -67,9 +68,89 @@ const openingWindows = new Map<string, Promise<boolean>>();
const nativeHostStateRevisions = new Map<string, number>();
const nativeHostStateQueues = new Map<string, Promise<boolean>>();
const retainedNativeHostEvents = new Map<string, NativeDetachedHostEvent[]>();
const nativeVisibilityRevisions = new Map<string, number>();
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<string, unknown>;
const selected: Record<string, unknown> = {};
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<boolean> => {
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<boolean> => {
@@ -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<void> => {
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<void> => {
const manager = resolveNativeDetachedWindowManager();
if (!manager) return;

View File

@@ -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"];
}
}

View File

@@ -10,6 +10,8 @@ export function CloseAll():Promise<nativewindow.OperationResult>;
export function Focus(arg1:string):Promise<nativewindow.OperationResult>;
export function Hide(arg1:string):Promise<nativewindow.OperationResult>;
export function List():Promise<Array<nativewindow.WindowInfo>>;
export function Open(arg1:nativewindow.OpenRequest):Promise<nativewindow.OperationResult>;

View File

@@ -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']();
}