fix(quickdock): 进页与切换对话时快捷窗口静态呈现,修复跨工作区输入栏跳动

- 设置与内容状态改为 localStorage 同步缓存(主题缓存同模式):
  自动展开设置、全局内容状态、按对话 ID 内容状态(LRU 上限 50)
- 三态乐观掩码:assumedActive 生效时 hasContent 完全由缓存假定值
  决定(含假定无内容),dock 与对话区同刻切换收起/展开
- 无过渡窗口重写为可重入机制:初始加载与切换对话(flush:sync)
  两个时机开启,按 filesSyncSeq/todoSyncSeq 序号推进精确关窗
- 跨工作区点选对话时保持当前对话视图冻结,不再经过空对话
  中间态,消除输入栏先上后下
This commit is contained in:
JOJO 2026-07-28 23:24:19 +08:00
parent 0aa8af8618
commit a1e05032a2
6 changed files with 352 additions and 33 deletions

View File

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

View File

@ -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 storeApp.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>

View File

@ -43,6 +43,13 @@
transform: translateX(24px);
}
/* 初始加载窗口内禁用容器过渡进入页面时设置与首批内容异步到达
首次状态校正必须瞬间完成否则会看到先空再展开/先展开再收起的闪烁
窗口关闭类移除后恢复正常交互动画规则位于 .quick-dock 之后同优先级覆盖 */
.quick-dock--no-anim {
transition: none;
}
.quick-dock__scroll {
flex: 1;
min-height: 0;

View File

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

View File

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

View File

@ -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 +1bootstrap
* /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;
}
}
});