fix(quickdock): 进页与切换对话时快捷窗口静态呈现,修复跨工作区输入栏跳动
- 设置与内容状态改为 localStorage 同步缓存(主题缓存同模式): 自动展开设置、全局内容状态、按对话 ID 内容状态(LRU 上限 50) - 三态乐观掩码:assumedActive 生效时 hasContent 完全由缓存假定值 决定(含假定无内容),dock 与对话区同刻切换收起/展开 - 无过渡窗口重写为可重入机制:初始加载与切换对话(flush:sync) 两个时机开启,按 filesSyncSeq/todoSyncSeq 序号推进精确关窗 - 跨工作区点选对话时保持当前对话视图冻结,不再经过空对话 中间态,消除输入栏先上后下
This commit is contained in:
parent
0aa8af8618
commit
a1e05032a2
@ -57,7 +57,11 @@ export const hostWorkspaceMethods = {
|
||||
this.runningWorkspaceTasks = [];
|
||||
}
|
||||
},
|
||||
async handleHostWorkspaceSwitch(workspaceId) {
|
||||
async handleHostWorkspaceSwitch(workspaceId, options = {}) {
|
||||
// preserveConversationView:跨工作区点选具体对话时使用——保持当前对话视图冻结,
|
||||
// 不做 /new 跳转与状态清空(否则空对话态会让居中输入栏「先往上再往下」),
|
||||
// 目标对话由调用方随后立即加载
|
||||
const preserveConversationView = Boolean(options && options.preserveConversationView);
|
||||
const targetId = String(workspaceId || '').trim();
|
||||
if (!targetId || this.hostWorkspaceSwitching) {
|
||||
return;
|
||||
@ -102,31 +106,39 @@ export const hostWorkspaceMethods = {
|
||||
if (data.default_workspace_id) {
|
||||
this.defaultHostWorkspaceId = String(data.default_workspace_id);
|
||||
}
|
||||
this.messages = [];
|
||||
this.currentConversationId = null;
|
||||
this.resetTokenStatistics?.();
|
||||
this.currentConversationTitle = '新对话';
|
||||
this.titleReady = true;
|
||||
this.suppressTitleTyping = false;
|
||||
try {
|
||||
const { useTaskStore } = await import('../../../stores/task');
|
||||
const taskStore = useTaskStore();
|
||||
taskStore.clearTask();
|
||||
} catch (_) {}
|
||||
this.clearLocalTaskUiState?.('switch-host-workspace');
|
||||
if (!preserveConversationView) {
|
||||
this.messages = [];
|
||||
this.currentConversationId = null;
|
||||
this.resetTokenStatistics?.();
|
||||
this.currentConversationTitle = '新对话';
|
||||
this.titleReady = true;
|
||||
this.suppressTitleTyping = false;
|
||||
try {
|
||||
const { useTaskStore } = await import('../../../stores/task');
|
||||
const taskStore = useTaskStore();
|
||||
taskStore.clearTask();
|
||||
} catch (_) {}
|
||||
this.clearLocalTaskUiState?.('switch-host-workspace');
|
||||
}
|
||||
await this.fetchHostWorkspaces();
|
||||
const activeStatuses = new Set(['pending', 'running', 'cancel_requested']);
|
||||
const activeTask = (Array.isArray(this.runningWorkspaceTasks) ? this.runningWorkspaceTasks : [])
|
||||
.filter((task: any) => String(task?.workspace_id || '') === this.currentHostWorkspaceId)
|
||||
.find((task: any) => activeStatuses.has(String(task?.status || '')) && task?.conversation_id);
|
||||
if (activeTask?.conversation_id) {
|
||||
await this.loadConversation(String(activeTask.conversation_id), { force: true });
|
||||
if (preserveConversationView) {
|
||||
// 不做视图跳转(/new 或自动进入运行中任务对话),仅刷新侧边栏列表
|
||||
this.conversationsOffset = 0;
|
||||
await this.loadConversationsList();
|
||||
} else {
|
||||
this.startTitleTyping('新对话', { animate: false });
|
||||
history.replaceState({}, '', '/new');
|
||||
await this.loadConversationsList();
|
||||
const activeStatuses = new Set(['pending', 'running', 'cancel_requested']);
|
||||
const activeTask = (Array.isArray(this.runningWorkspaceTasks) ? this.runningWorkspaceTasks : [])
|
||||
.filter((task: any) => String(task?.workspace_id || '') === this.currentHostWorkspaceId)
|
||||
.find((task: any) => activeStatuses.has(String(task?.status || '')) && task?.conversation_id);
|
||||
if (activeTask?.conversation_id) {
|
||||
await this.loadConversation(String(activeTask.conversation_id), { force: true });
|
||||
this.conversationsOffset = 0;
|
||||
await this.loadConversationsList();
|
||||
} else {
|
||||
this.startTitleTyping('新对话', { animate: false });
|
||||
history.replaceState({}, '', '/new');
|
||||
await this.loadConversationsList();
|
||||
}
|
||||
}
|
||||
const switchedWorkspace = (Array.isArray(this.hostWorkspaces) ? this.hostWorkspaces : [])
|
||||
.find((item: any) => String(item?.workspace_id || '') === this.currentHostWorkspaceId);
|
||||
@ -338,7 +350,7 @@ export const hostWorkspaceMethods = {
|
||||
const { conversationId, workspaceId } = payload || {};
|
||||
if (!conversationId || !workspaceId) return;
|
||||
if ((this.versioningHostMode || this.dockerProjectMode) && workspaceId !== this.currentHostWorkspaceId) {
|
||||
await this.handleHostWorkspaceSwitch(workspaceId);
|
||||
await this.handleHostWorkspaceSwitch(workspaceId, { preserveConversationView: true });
|
||||
}
|
||||
await this.loadConversation(conversationId, { force: true, workspaceId });
|
||||
},
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
<template>
|
||||
<aside
|
||||
class="quick-dock"
|
||||
:class="{ 'quick-dock--empty': !hasContent || userCollapsed }"
|
||||
:class="{
|
||||
'quick-dock--empty': !settingsReady || !hasContent || userCollapsed,
|
||||
'quick-dock--no-anim': noAnim
|
||||
}"
|
||||
>
|
||||
<div ref="scrollRef" class="quick-dock__scroll" @scroll.passive="handleStackScroll">
|
||||
<TodoWindow />
|
||||
@ -38,12 +41,17 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useQuickDockStore } from '@/stores/quickDock';
|
||||
import {
|
||||
useQuickDockStore,
|
||||
persistQuickDockHadContent,
|
||||
persistQuickDockConvContent
|
||||
} from '@/stores/quickDock';
|
||||
import { useSubAgentStore } from '@/stores/subAgent';
|
||||
import { useBackgroundCommandStore } from '@/stores/backgroundCommand';
|
||||
import { useConversationStore } from '@/stores/conversation';
|
||||
import { useUiStore } from '@/stores/ui';
|
||||
import { usePersonalizationStore } from '@/stores/personalization';
|
||||
import { usePersonalizationStore, hasCachedQuickDockAutoExpand } from '@/stores/personalization';
|
||||
import { useFileStore } from '@/stores/file';
|
||||
import TodoWindow from './TodoWindow.vue';
|
||||
import RunnerWindow from './RunnerWindow.vue';
|
||||
import RunnerDetailPanel from './RunnerDetailPanel.vue';
|
||||
@ -62,6 +70,7 @@ const subAgentStore = useSubAgentStore();
|
||||
const bgStore = useBackgroundCommandStore();
|
||||
const conversationStore = useConversationStore();
|
||||
const uiStore = useUiStore();
|
||||
const fileStore = useFileStore();
|
||||
const personalizationStore = usePersonalizationStore();
|
||||
|
||||
// hasContent / userCollapsed 提升到 store:App.vue 的展开收起按钮共用同一判定
|
||||
@ -78,6 +87,119 @@ watch(userCollapsed, (collapsed) => {
|
||||
/** 快捷窗口自动展开设置(个人空间·外观与显示,默认是;为否则只能手动点击按钮展开) */
|
||||
const autoExpand = computed(() => personalizationStore.form.quick_dock_auto_expand !== false);
|
||||
|
||||
/**
|
||||
* 个性化设置是否就绪。有本地缓存时首帧即就绪(表单已从缓存同步初始化,
|
||||
* 与主题缓存同理);无缓存(首次使用)或接口失败时退化为等待接口/按默认值呈现。
|
||||
* 就绪前强制空态(宽 0 不可见):否则手动模式会先渲染展开态再纠正。
|
||||
*/
|
||||
const settingsReady = computed(
|
||||
() =>
|
||||
hasCachedQuickDockAutoExpand() || personalizationStore.loaded || !!personalizationStore.error
|
||||
);
|
||||
|
||||
/**
|
||||
* 无过渡窗口(quick-dock--no-anim):禁用容器展开/收起过渡,窗口内状态校正瞬间完成。
|
||||
* 两个开启时机:
|
||||
* 1. 初始加载:设置与首批内容(子智能体/后台指令/bootstrap 回填/待办)异步到达,
|
||||
* 若不禁用会播「先空再展开」/「先展开再收起」动画;
|
||||
* 2. 切换对话:旧内容刻意保留至新对话 bootstrap 回填(防「先收再放」),回填后
|
||||
* 内容有无翻转同样不能播动画,需瞬间切换成新对话的收起/展开状态。
|
||||
* 窗口关闭条件:新一轮回填到齐(同步序号推进)+ 短宽限;超时强制关闭防挂起。
|
||||
*/
|
||||
const noAnim = ref(true);
|
||||
/** 回填到齐后的短宽限:合并同批次略晚到达的其余数据(如子智能体刷新) */
|
||||
const SYNC_SETTLE_GRACE_MS = 300;
|
||||
/** 兜底上限:回填异常慢/失败时也不能永久禁用过渡(到点强制关闭窗口) */
|
||||
const NO_ANIM_CAP_MS = 3000;
|
||||
|
||||
/** 窗口代数:重开窗口会使上一代未完成的 rAF 回调失效 */
|
||||
let windowGeneration = 0;
|
||||
/** 开窗时捕获的同步进度:序号超过它才算「新一轮回填已到齐」 */
|
||||
let gateTarget: { filesSeq: number; todoSeq: number } | null = null;
|
||||
let gateWatchStop: (() => void) | null = null;
|
||||
let releaseTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let forceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** 首批子智能体/后台指令拉取未完成前不关闭初始窗口(它们也参与 hasContent) */
|
||||
let initialRefreshPending = true;
|
||||
|
||||
function gatesPassed(): boolean {
|
||||
if (initialRefreshPending) {
|
||||
return false;
|
||||
}
|
||||
if (!gateTarget) {
|
||||
return true;
|
||||
}
|
||||
return quickDock.filesSyncSeq > gateTarget.filesSeq && fileStore.todoSyncSeq > gateTarget.todoSeq;
|
||||
}
|
||||
|
||||
function clearWindowTimers() {
|
||||
if (releaseTimer !== null) {
|
||||
clearTimeout(releaseTimer);
|
||||
releaseTimer = null;
|
||||
}
|
||||
if (forceTimer !== null) {
|
||||
clearTimeout(forceTimer);
|
||||
forceTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 开启/重开无过渡窗口:捕获当前同步进度,等新一轮回填到齐后关闭 */
|
||||
function openNoAnimWindow() {
|
||||
windowGeneration += 1;
|
||||
noAnim.value = true;
|
||||
gateTarget = { filesSeq: quickDock.filesSyncSeq, todoSeq: fileStore.todoSyncSeq };
|
||||
clearWindowTimers();
|
||||
if (!gateWatchStop) {
|
||||
gateWatchStop = watch(
|
||||
() => [quickDock.filesSyncSeq, fileStore.todoSyncSeq],
|
||||
() => {
|
||||
scheduleReleaseIfSynced();
|
||||
}
|
||||
);
|
||||
}
|
||||
scheduleReleaseIfSynced();
|
||||
forceTimer = setTimeout(() => {
|
||||
releaseNoAnim(true);
|
||||
}, NO_ANIM_CAP_MS);
|
||||
}
|
||||
|
||||
function scheduleReleaseIfSynced() {
|
||||
if (!noAnim.value || !gatesPassed()) {
|
||||
return;
|
||||
}
|
||||
if (releaseTimer !== null) {
|
||||
clearTimeout(releaseTimer);
|
||||
}
|
||||
releaseTimer = setTimeout(() => {
|
||||
releaseTimer = null;
|
||||
releaseNoAnim();
|
||||
}, SYNC_SETTLE_GRACE_MS);
|
||||
}
|
||||
|
||||
function releaseNoAnim(force = false) {
|
||||
if (!noAnim.value || (!force && !gatesPassed())) {
|
||||
return;
|
||||
}
|
||||
clearWindowTimers();
|
||||
if (gateWatchStop) {
|
||||
gateWatchStop();
|
||||
gateWatchStop = null;
|
||||
}
|
||||
gateTarget = null;
|
||||
// 顺带结束首帧乐观内容标记(幂等):此后 hasContent 只看真实状态
|
||||
quickDock.settleInitialContent();
|
||||
// 双 rAF:先把最终状态无过渡绘制一帧,再恢复过渡,避免恢复瞬间补播动画;
|
||||
// 期间若重开了窗口(代数变化),本次恢复作废
|
||||
const generation = windowGeneration;
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (generation === windowGeneration) {
|
||||
noAnim.value = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 内容集合变化(出现/清空)时按模式校正展开状态:
|
||||
// - 自动展开(默认):重置手动标记,新内容到达自动展开
|
||||
// (按钮常显后,无内容时也可能被点击置为收起;不重置会导致新内容到达不展开)
|
||||
@ -97,6 +219,20 @@ watch(autoExpand, (enabled) => {
|
||||
quickDock.userCollapsed = !enabled;
|
||||
});
|
||||
|
||||
// 持续维护「dock 是否有内容」缓存(全局 + 按对话两条):
|
||||
// 下次进入页面/切换对话时即可按最终状态静态渲染。
|
||||
// 乐观掩码生效期间不写,避免把猜测值当真;掩码关闭时若状态翻转
|
||||
// 本 watch 会触发并写入真实值,缓存自我修正。
|
||||
watch(hasContent, (content) => {
|
||||
if (!quickDock.assumedActive) {
|
||||
persistQuickDockHadContent(content);
|
||||
const convId = conversationStore.currentConversationId;
|
||||
if (convId) {
|
||||
persistQuickDockConvContent(convId, content);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const AGENT_TERMINAL = new Set(['completed', 'failed', 'timeout', 'terminated']);
|
||||
const CMD_TERMINAL = new Set(['completed', 'failed', 'timeout', 'cancelled']);
|
||||
|
||||
@ -281,10 +417,20 @@ function refreshAll() {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refreshAll();
|
||||
// 首批内容拉取(同时作为初始无过渡窗口的关闭条件之一)
|
||||
const initialRefresh = Promise.allSettled([
|
||||
subAgentStore.fetchSubAgents(),
|
||||
bgStore.fetchCommands()
|
||||
]);
|
||||
pollTimer = setInterval(refreshAll, 5000);
|
||||
document.addEventListener('keydown', onKeydown);
|
||||
document.addEventListener('click', onDocumentClick);
|
||||
// 开启初始无过渡窗口:新一轮回填到齐 + 首批拉取完成后关闭;兜底定时器到点强制关闭
|
||||
openNoAnimWindow();
|
||||
void initialRefresh.then(() => {
|
||||
initialRefreshPending = false;
|
||||
scheduleReleaseIfSynced();
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@ -292,6 +438,7 @@ onBeforeUnmount(() => {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
clearWindowTimers();
|
||||
document.removeEventListener('keydown', onKeydown);
|
||||
document.removeEventListener('click', onDocumentClick);
|
||||
quickDock.resetTransient();
|
||||
@ -304,7 +451,15 @@ watch(
|
||||
// 避免切换对话时列「先收起再展开」闪烁(/new 场景由 app watcher 负责清空)。
|
||||
quickDock.resetTransient();
|
||||
refreshAll();
|
||||
}
|
||||
// 切换期间重开无过渡窗口:新对话回填后的收起/展开状态瞬间切换,不播容器动画。
|
||||
// flush:'sync' 保证在 app watcher 清空回填(/new 场景)之前捕获同步序号,
|
||||
// 否则序号先推进、门控永远等不到而挂到超时
|
||||
openNoAnimWindow();
|
||||
// 按缓存乐观假定目标对话的 dock 状态:有内容则切过去即是展开(无动画、无延迟),
|
||||
// 无内容则旧内容保留至回填后瞬间收起;缓存在窗口关闭时自我纠正
|
||||
quickDock.assumeContentForConversation(conversationStore.currentConversationId);
|
||||
},
|
||||
{ flush: 'sync' }
|
||||
);
|
||||
</script>
|
||||
|
||||
|
||||
@ -43,6 +43,13 @@
|
||||
transform: translateX(24px);
|
||||
}
|
||||
|
||||
/* 初始加载窗口内禁用容器过渡:进入页面时设置与首批内容异步到达,
|
||||
首次状态校正必须瞬间完成,否则会看到「先空再展开」/「先展开再收起」的闪烁;
|
||||
窗口关闭(类移除)后恢复正常交互动画。规则位于 .quick-dock 之后,同优先级覆盖 */
|
||||
.quick-dock--no-anim {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.quick-dock__scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
@ -41,6 +41,9 @@ interface FileState {
|
||||
todoList: TodoList | null;
|
||||
/** true = 来自任务期实时事件(播动画);false = 来自加载/fetch(静态呈现) */
|
||||
todoListLive: boolean;
|
||||
/** 待办同步序号:fetchTodoList 成功 / setTodoList 调用时 +1。
|
||||
* 快捷窗口无过渡窗口通过比较序号识别「新一轮待办已回填」 */
|
||||
todoSyncSeq: number;
|
||||
fileTreeUnavailable: boolean;
|
||||
fileTreeMessage: string;
|
||||
contextMenu: ContextMenuState;
|
||||
@ -82,6 +85,7 @@ export const useFileStore = defineStore('file', {
|
||||
expandedFolders: {},
|
||||
todoList: null,
|
||||
todoListLive: false,
|
||||
todoSyncSeq: 0,
|
||||
fileTreeUnavailable: false,
|
||||
fileTreeMessage: '',
|
||||
contextMenu: {
|
||||
@ -182,6 +186,7 @@ export const useFileStore = defineStore('file', {
|
||||
// 顺序同 setTodoList:先写 live 标记(REST 拉取一律静态),再写数据
|
||||
this.todoListLive = false;
|
||||
this.todoList = data.data || null;
|
||||
this.todoSyncSeq += 1;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取待办列表失败:', error);
|
||||
@ -193,6 +198,7 @@ export const useFileStore = defineStore('file', {
|
||||
// 读到上一次调用残留的旧值,动画路径(划线/圆点弹跳)永远走不到。
|
||||
this.todoListLive = live;
|
||||
this.todoList = payload;
|
||||
this.todoSyncSeq += 1;
|
||||
},
|
||||
showContextMenu(payload: { node: FileNode; event: MouseEvent }) {
|
||||
if (!payload || !payload.node) {
|
||||
|
||||
@ -104,6 +104,7 @@ const EXPERIMENT_STORAGE_KEY = 'agents_personalization_experiments';
|
||||
const THEME_STORAGE_KEY = 'agents_ui_theme';
|
||||
const STACKED_HIDE_BORDERS_STORAGE_KEY = 'agents_stacked_hide_borders';
|
||||
const MINIMAL_EXPAND_HEIGHT_LIMITED_STORAGE_KEY = 'agents_minimal_expand_height_limited';
|
||||
const QUICK_DOCK_AUTO_EXPAND_STORAGE_KEY = 'agents_quick_dock_auto_expand';
|
||||
|
||||
const loadCachedTheme = (): PersonalForm['theme'] => {
|
||||
if (typeof window === 'undefined' || !window.localStorage) {
|
||||
@ -153,6 +154,38 @@ const persistStackedHideBorders = (value: boolean) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 快捷窗口自动展开设置的本地缓存(与主题缓存同理):
|
||||
* 进入页面时个性化接口是异步的,首帧渲染必须同步拿到该设置,
|
||||
* 否则 QuickDock 只能先按默认值渲染再等接口纠偏,产生「先展开再收起」/「先空再展开」闪烁。
|
||||
*/
|
||||
export const loadCachedQuickDockAutoExpand = (): boolean => {
|
||||
if (typeof window === 'undefined' || !window.localStorage) {
|
||||
return true;
|
||||
}
|
||||
const saved = window.localStorage.getItem(QUICK_DOCK_AUTO_EXPAND_STORAGE_KEY);
|
||||
return saved === null ? true : saved === 'true';
|
||||
};
|
||||
|
||||
/** 是否已有自动展开设置的本地缓存(无缓存的首次访问仍需等接口就绪) */
|
||||
export const hasCachedQuickDockAutoExpand = (): boolean => {
|
||||
if (typeof window === 'undefined' || !window.localStorage) {
|
||||
return false;
|
||||
}
|
||||
return window.localStorage.getItem(QUICK_DOCK_AUTO_EXPAND_STORAGE_KEY) !== null;
|
||||
};
|
||||
|
||||
const persistQuickDockAutoExpand = (value: boolean) => {
|
||||
if (typeof window === 'undefined' || !window.localStorage) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
window.localStorage.setItem(QUICK_DOCK_AUTO_EXPAND_STORAGE_KEY, value ? 'true' : 'false');
|
||||
} catch (error) {
|
||||
console.warn('写入快捷窗口自动展开设置缓存失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadCachedMinimalExpandHeightLimited = (): boolean => {
|
||||
if (typeof window === 'undefined' || !window.localStorage) {
|
||||
return true;
|
||||
@ -193,7 +226,7 @@ const defaultForm = (): PersonalForm => ({
|
||||
show_status_avatar: true,
|
||||
show_git_status_bar: true,
|
||||
auto_open_terminal_panel: true,
|
||||
quick_dock_auto_expand: true,
|
||||
quick_dock_auto_expand: loadCachedQuickDockAutoExpand(),
|
||||
stacked_hide_borders: loadCachedStackedHideBorders(),
|
||||
minimal_expand_height_limited: loadCachedMinimalExpandHeightLimited(),
|
||||
enhanced_tool_display_categories: [],
|
||||
@ -495,6 +528,7 @@ export const usePersonalizationStore = defineStore('personalization', {
|
||||
}
|
||||
persistStackedHideBorders(this.form.stacked_hide_borders);
|
||||
persistMinimalExpandHeightLimited(this.form.minimal_expand_height_limited);
|
||||
persistQuickDockAutoExpand(this.form.quick_dock_auto_expand);
|
||||
// 简略消息显示:以配置文件为准,同步到旧版 localStorage 镜像供 ChatArea 读取。
|
||||
// 一次性迁移:后端仍为默认 full,但本地缓存遗留 brief(旧版纯前端记录)时,回写到配置文件。
|
||||
const cachedCompact = this.experiments.compactMessageDisplay;
|
||||
|
||||
@ -15,6 +15,83 @@ export interface EditedFileEntry {
|
||||
ts?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 「上次页面离开时 dock 是否有内容」的本地缓存(与主题缓存同理)。
|
||||
* 进入页面时真实内容(文件记录/待办/子智能体/后台指令)全部异步到达,
|
||||
* 首帧只能按 hasContent=false 渲染 0 宽,数据到达后整列插入导致布局跳变。
|
||||
* 用缓存做首帧乐观展开,等首批数据到齐(settleInitialContent)后切回真实状态;
|
||||
* 缓存与真实不符时在初始无过渡窗口内瞬间纠正,用户无感知。
|
||||
*/
|
||||
const QUICK_DOCK_HAD_CONTENT_STORAGE_KEY = 'agents_quick_dock_had_content';
|
||||
|
||||
const loadCachedHadContent = (): boolean => {
|
||||
if (typeof window === 'undefined' || !window.localStorage) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return window.localStorage.getItem(QUICK_DOCK_HAD_CONTENT_STORAGE_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const persistQuickDockHadContent = (hadContent: boolean) => {
|
||||
if (typeof window === 'undefined' || !window.localStorage) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
window.localStorage.setItem(QUICK_DOCK_HAD_CONTENT_STORAGE_KEY, hadContent ? '1' : '0');
|
||||
} catch (error) {
|
||||
console.warn('写入快捷窗口内容状态缓存失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 「每个对话各自」的 dock 内容状态缓存(LRU,上限 50 条)。
|
||||
* 切换对话时目标对话的回填是异步的,用该缓存同步假定目标对话的 dock 状态:
|
||||
* 有内容则切过去即展开(无动画),回填到齐后切回真实状态,不符时瞬间纠正。
|
||||
*/
|
||||
const QUICK_DOCK_CONV_CONTENT_STORAGE_KEY = 'agents_quick_dock_conv_content';
|
||||
const CONV_CONTENT_CACHE_LIMIT = 50;
|
||||
|
||||
const loadConvContentMap = (): Record<string, boolean> => {
|
||||
if (typeof window === 'undefined' || !window.localStorage) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const raw = window.localStorage.getItem(QUICK_DOCK_CONV_CONTENT_STORAGE_KEY);
|
||||
const parsed = raw ? JSON.parse(raw) : {};
|
||||
return parsed && typeof parsed === 'object' ? (parsed as Record<string, boolean>) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
export const loadQuickDockConvContent = (conversationId: string): boolean => {
|
||||
return loadConvContentMap()[conversationId] === true;
|
||||
};
|
||||
|
||||
export const persistQuickDockConvContent = (conversationId: string, hadContent: boolean) => {
|
||||
if (typeof window === 'undefined' || !window.localStorage || !conversationId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const map = loadConvContentMap();
|
||||
// 删除重插:刷新插入序,让 Object.keys 顺序近似 LRU
|
||||
delete map[conversationId];
|
||||
map[conversationId] = hadContent;
|
||||
const keys = Object.keys(map);
|
||||
if (keys.length > CONV_CONTENT_CACHE_LIMIT) {
|
||||
for (const staleKey of keys.slice(0, keys.length - CONV_CONTENT_CACHE_LIMIT)) {
|
||||
delete map[staleKey];
|
||||
}
|
||||
}
|
||||
window.localStorage.setItem(QUICK_DOCK_CONV_CONTENT_STORAGE_KEY, JSON.stringify(map));
|
||||
} catch (error) {
|
||||
console.warn('写入快捷窗口对话内容缓存失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
export interface QuickDockDetailTarget {
|
||||
kind: 'agent' | 'cmd';
|
||||
id: string;
|
||||
@ -43,6 +120,17 @@ interface QuickDockState {
|
||||
menu: QuickDockMenuState | null;
|
||||
/** 用户通过顶部悬浮按钮手动收起(与「无内容自动收起」相互独立) */
|
||||
userCollapsed: boolean;
|
||||
/** 乐观假定的内容状态(来源:上次离开的全局缓存 / 目标对话的按对话缓存) */
|
||||
assumedContent: boolean;
|
||||
/** 乐观掩码是否生效(三态):生效时 hasContent 完全由 assumedContent 决定——
|
||||
* 包括「假定无内容」:切到缓存为空的对话时立即掩盖旧对话被刻意保留的真实内容,
|
||||
* 使 dock 与对话区在同一时刻切换(否则只能等 bootstrap 末尾回填才折叠,慢一拍)。
|
||||
* 真实数据到齐(settleInitialContent)后关闭,回退为纯真实状态 */
|
||||
assumedActive: boolean;
|
||||
/** 文件记录回填同步序号:每次 setEditedFiles +1(bootstrap 必经,空数组也调;
|
||||
* /new 场景 app watcher 也会清空调用)。初始加载/切换对话的无过渡窗口
|
||||
* 通过比较序号识别「新一轮内容已回填」 */
|
||||
filesSyncSeq: number;
|
||||
}
|
||||
|
||||
export const useQuickDockStore = defineStore('quickDock', {
|
||||
@ -52,7 +140,10 @@ export const useQuickDockStore = defineStore('quickDock', {
|
||||
detail: null,
|
||||
previewPath: null,
|
||||
menu: null,
|
||||
userCollapsed: false
|
||||
userCollapsed: false,
|
||||
assumedContent: loadCachedHadContent(),
|
||||
assumedActive: loadCachedHadContent(),
|
||||
filesSyncSeq: 0
|
||||
}),
|
||||
getters: {
|
||||
/** 四个窗口(待办/子智能体/后台指令/文件记录)任一有内容 */
|
||||
@ -61,12 +152,14 @@ export const useQuickDockStore = defineStore('quickDock', {
|
||||
const subAgentStore = useSubAgentStore();
|
||||
const bgStore = useBackgroundCommandStore();
|
||||
const todoCount = fileStore.todoList?.tasks?.length || 0;
|
||||
return (
|
||||
const real =
|
||||
todoCount > 0 ||
|
||||
subAgentStore.subAgents.length > 0 ||
|
||||
bgStore.commands.length > 0 ||
|
||||
state.editedFiles.length > 0
|
||||
);
|
||||
state.editedFiles.length > 0;
|
||||
// 乐观掩码生效期间(初始加载/切换对话的内容未到齐窗口)以假定状态为准;
|
||||
// 掩码关闭后纯真实状态
|
||||
return state.assumedActive ? state.assumedContent : real;
|
||||
},
|
||||
/** 实际处于展开态:有内容且未被用户手动收起 */
|
||||
expanded(state): boolean {
|
||||
@ -75,6 +168,7 @@ export const useQuickDockStore = defineStore('quickDock', {
|
||||
},
|
||||
actions: {
|
||||
setEditedFiles(list: EditedFileEntry[] | null | undefined, live = false) {
|
||||
this.filesSyncSeq += 1;
|
||||
this.editedFilesLive = live;
|
||||
if (!Array.isArray(list)) {
|
||||
this.editedFiles = [];
|
||||
@ -142,6 +236,17 @@ export const useQuickDockStore = defineStore('quickDock', {
|
||||
this.detail = null;
|
||||
this.previewPath = null;
|
||||
this.menu = null;
|
||||
},
|
||||
/** 真实内容数据到齐(无过渡窗口关闭时调用,幂等):关闭乐观掩码,此后 hasContent 只看真实状态 */
|
||||
settleInitialContent() {
|
||||
this.assumedActive = false;
|
||||
this.assumedContent = false;
|
||||
},
|
||||
/** 切换对话时按缓存乐观假定目标对话的 dock 内容状态(无缓存/无 id → 假定无内容)
|
||||
* 并开启掩码:与对话区同刻切换 dock 的收起/展开,不等待异步回填 */
|
||||
assumeContentForConversation(conversationId: string | null) {
|
||||
this.assumedContent = conversationId ? loadQuickDockConvContent(conversationId) : false;
|
||||
this.assumedActive = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user