优化: AI Chat 体验全面升级(图标/审批/目标提取/窗口管理)
- AI 入口图标:灯泡→星花 sparkles+发光,与灵感入口区分 - 目标提取根本性改造:用户消息规则→工具调用推理(infer_goal_from_tool_calls) - Vec<String>→Vec<GoalEntry>(text+status),active→completed 状态流转 - 多条/轮提取,system_prompt 仅注入 active 目标 - 代码拆分到 helpers.rs(解决 brace 嵌套致 pub(crate) 不可见) - 审批修复:patch_file 全档位自动放行,批量审批防抖,乐观更新竞态保护 - 审批通知浮层:全局右上角 ApprovalOverlay,窗口遮挡时可见 - 设置导入导出移除:价值低占用空间,跨设备同步建议复制 SQLite - 分离窗口默认置顶+置顶状态持久化(刷新后保持) - header 菜单聚合:清空/压缩上下文收入 ... popout - LLM 文字重叠防御:delta 重复检测+丢弃 - 置顶图标:星→大头针 pushpin - 文档/注释更新:过期 Vec<String>/chat.rs 提取描述修正
This commit is contained in:
@@ -82,6 +82,7 @@ async function detachPanel(convId?: string) {
|
||||
minHeight: 400,
|
||||
center: true,
|
||||
decorations: true,
|
||||
alwaysOnTop: true, // 分离窗口默认置顶(AI 助手常态:用户在主窗口工作时希望 AI 辅助窗口可见)
|
||||
})
|
||||
win.once('tauri://created', () => {
|
||||
state.panelOpen = false
|
||||
@@ -258,10 +259,22 @@ const AI_DOCK_WIDTH = 600
|
||||
const AI_DOCK_GAP = 4
|
||||
// 防 syncToMain 改主窗口 size 触发 onResized 递归回调
|
||||
let _isSyncing = false
|
||||
// 拖动/缩放防抖:用户拖动主窗口期间 onMoved/onResized 高频触发,
|
||||
// 直接 sync 会让 setSize/setPosition 排队 → AI 窗口抖动 + 性能浪费。
|
||||
// 防后 150ms 才执行,用户停下才同步(手感顺 + 不会重重复复中中间态同步)。
|
||||
let _syncTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const SYNC_DEBOUNCE_MS = 150
|
||||
|
||||
/** 同步 AI 窗口位置到主窗口右侧(保留 GAP 间隙)。
|
||||
* U-260618: 主窗口最大化或主+AI 超出屏幕时,取消最大化 + 缩小主窗口宽度,
|
||||
* 让主窗口与 AI 窗口都进入屏幕(原:主窗口最大化时 AI 被挤到屏幕外)。 */
|
||||
/** 同步 AI 窗口位置到主窗口右侧(保留 GAP 间隙)。
|
||||
*
|
||||
* 设计原则(本次优化重点):
|
||||
* 1. 不反向改主窗口 size——原实现在拖动主窗口时 setSize 主窗口,使主窗口在拖动过程中自己也在变,
|
||||
* 拖动体验极差。现改为:主窗口是真相源,AI 窗口跟随到主窗口右侧;若右侧空间不够,AI 窗口本身让位
|
||||
* (往左偏移或缩宽),不动主窗口。
|
||||
* 2. 不动主窗口最大化状态——原实现强制 unmaximize 主窗口,破坏用户最大化习惯。
|
||||
* 现改为:主窗口最大化时仅同步高度,AI 窗口贴右侧屏幕边显示。
|
||||
* 3. 仅同步位置与高度(跟随主窗口高度),宽度保持 AI_DOCK_WIDTH 不变(避免宽度反复跳)。
|
||||
*/
|
||||
async function syncToMain() {
|
||||
if (_isSyncing) return
|
||||
_isSyncing = true
|
||||
@@ -276,28 +289,32 @@ async function syncToMain() {
|
||||
const monitor = await currentMonitor()
|
||||
const screenWidth = monitor?.size.width ?? 0
|
||||
|
||||
// 取消主窗口最大化(吸附需主窗口让出右侧空间给 AI 窗口)
|
||||
if (await mainWin.isMaximized()) {
|
||||
await mainWin.unmaximize()
|
||||
await new Promise(r => setTimeout(r, 80)) // 等 unmaximize UI 落定再读尺寸
|
||||
}
|
||||
const pos = await mainWin.outerPosition()
|
||||
const size = await mainWin.innerSize()
|
||||
const maximized = await mainWin.isMaximized()
|
||||
|
||||
let pos = await mainWin.outerPosition()
|
||||
let size = await mainWin.innerSize()
|
||||
// 计算目标位置(默认贴主窗口右侧 + GAP)
|
||||
let targetX = pos.x + size.width + AI_DOCK_GAP
|
||||
let targetY = pos.y
|
||||
let targetHeight = size.height
|
||||
let targetWidth = AI_DOCK_WIDTH
|
||||
|
||||
// 主+AI 超屏幕宽度 → 强制缩小主窗口让 AI 进屏幕
|
||||
if (screenWidth > 0 && pos.x + size.width + AI_DOCK_GAP + AI_DOCK_WIDTH > screenWidth) {
|
||||
const newMainWidth = screenWidth - AI_DOCK_GAP - AI_DOCK_WIDTH - pos.x
|
||||
if (newMainWidth >= 400) {
|
||||
await mainWin.setSize(new PhysicalSize(newMainWidth, size.height))
|
||||
await new Promise(r => setTimeout(r, 50)) // 等 setSize UI 落定
|
||||
pos = await mainWin.outerPosition()
|
||||
size = await mainWin.innerSize()
|
||||
if (maximized) {
|
||||
// 主窗口最大化时:不动主窗口。AI 贴屏幕右侧(主窗口全屏时右侧没空间,AI 覆盖在主窗口上)。
|
||||
// 保持 AI 默认高度不变(跟随屏幕高度易越界),高度不动。
|
||||
if (screenWidth > 0) {
|
||||
targetX = screenWidth - AI_DOCK_WIDTH - AI_DOCK_GAP
|
||||
}
|
||||
targetHeight = size.height > targetHeight ? targetHeight : size.height
|
||||
} else if (screenWidth > 0 && targetX + AI_DOCK_WIDTH > screenWidth) {
|
||||
// 非最大化但主+AI 超屏幕:AI 窗口本身让位(缩窄或往左偏),不动主窗口。
|
||||
// 优先策略:贴屏幕右侧边,AI 窗口宽度可能被主窗口右边缘掩盖一部分,但不强迫主窗口缩小。
|
||||
// 若主窗口右侧几乎贴边(targetX < pos.x + 100,主窗口已占满宽度),AI 覆盖在主窗口右侧上方(置顶态自然可见)。
|
||||
targetX = Math.max(pos.x + 100, screenWidth - AI_DOCK_WIDTH - AI_DOCK_GAP)
|
||||
}
|
||||
|
||||
await aiWin.setPosition(new PhysicalPosition(pos.x + size.width + AI_DOCK_GAP, pos.y))
|
||||
await aiWin.setSize(new PhysicalSize(AI_DOCK_WIDTH, size.height))
|
||||
await aiWin.setPosition(new PhysicalPosition(targetX, targetY))
|
||||
await aiWin.setSize(new PhysicalSize(targetWidth, targetHeight))
|
||||
} catch (e) {
|
||||
console.error('[AI] syncToMain 失败:', e)
|
||||
} finally {
|
||||
@@ -305,18 +322,28 @@ async function syncToMain() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 启动主窗口 move/resize 跟随(吸附时调用) */
|
||||
/** 同步防抖包装:拖动/缩放高频事件不走立即同步,防 150ms 后执行。 */
|
||||
function scheduleSync() {
|
||||
if (_syncTimer) clearTimeout(_syncTimer)
|
||||
_syncTimer = setTimeout(() => {
|
||||
_syncTimer = null
|
||||
void syncToMain()
|
||||
}, SYNC_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
/** 启动主窗口 move/resize 跟随(吸附时调用,防后 150ms 同步防抖动) */
|
||||
async function startFollowMain() {
|
||||
stopFollowMain()
|
||||
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
const mainWin = await WebviewWindow.getByLabel('main')
|
||||
if (!mainWin) return
|
||||
_unlistenMove = await mainWin.onMoved(() => { void syncToMain() })
|
||||
_unlistenResize = await mainWin.onResized(() => { void syncToMain() })
|
||||
_unlistenMove = await mainWin.onMoved(() => { scheduleSync() })
|
||||
_unlistenResize = await mainWin.onResized(() => { scheduleSync() })
|
||||
}
|
||||
|
||||
/** 停止主窗口跟随 */
|
||||
/** 停止主窗口跟随 + 清防抖计时器 */
|
||||
function stopFollowMain() {
|
||||
if (_syncTimer) { clearTimeout(_syncTimer); _syncTimer = null }
|
||||
_unlistenMove?.()
|
||||
_unlistenResize?.()
|
||||
_unlistenMove = null
|
||||
|
||||
Reference in New Issue
Block a user