mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-22 00:42:47 +08:00
⚡️ perf(native-window): 优化 AI 独立窗口启动与交互
- 关闭时隐藏保活,重开复用现有进程并通过可见性版本消除竞态 - 拆分 AI 与工作台资源,使用启动状态白名单减少序列化开销 - 修复设置中心层级、Windows 前台切换及独立窗口关闭交互 - 补齐状态同步、断线重放与窗口生命周期回归测试
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
902
frontend/src/styles/v2-theme-ai.css
Normal file
902
frontend/src/styles/v2-theme-ai.css
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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']();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user