feat(chat): 快捷导航三态自适应布局与快捷窗口手动收起

- 左侧输入跳转导航支持钉死/居中/隐藏三态:随轨宽空白自适应,
  波浪宽度按可用空白比例压缩避免遮挡消息;滚动位置同步高亮当前阅读轮次
- QuickDock 新增悬浮展开/收起按钮,userCollapsed 与无内容自动收起相互独立
- 轨宽/空白经 ResizeObserver 实测校准,兼容 macOS 经典滚动条宽度差异
This commit is contained in:
JOJO 2026-07-27 20:07:03 +08:00
parent eaa312acd4
commit 8741edec18
7 changed files with 287 additions and 24 deletions

View File

@ -356,6 +356,28 @@
v-if="!isMobileViewport" v-if="!isMobileViewport"
:host-mode="versioningHostMode" :host-mode="versioningHostMode"
/> />
<!-- 快捷窗口展开/收起按钮零宽锚点始终贴在 QuickDock 右缘
按钮绝对定位悬浮在原处不随面板挤压移动 -->
<div
v-if="!isMobileViewport && qdHasContent"
class="qd-toggle-anchor"
>
<button
type="button"
class="qd-toggle"
:class="{ 'is-active': qdExpanded }"
:aria-label="qdExpanded ? '收起快捷窗口' : '展开快捷窗口'"
:aria-pressed="qdExpanded"
@click="qdUserCollapsed = !qdUserCollapsed"
>
<svg viewBox="0 0 18 18" width="16" height="16" aria-hidden="true">
<line x1="2" y1="3.5" x2="16" y2="3.5" />
<line x1="2" y1="7.5" x2="10" y2="7.5" />
<line x1="2" y1="11.5" x2="14" y2="11.5" />
<line x1="2" y1="15.5" x2="7" y2="15.5" />
</svg>
</button>
</div>
<FilePreviewPanel <FilePreviewPanel
v-if="!isMobileViewport" v-if="!isMobileViewport"
/> />

View File

@ -16,6 +16,7 @@ import { usePolicyStore } from '../stores/policy';
import { useSubAgentStore } from '../stores/subAgent'; import { useSubAgentStore } from '../stores/subAgent';
import { useBackgroundCommandStore } from '../stores/backgroundCommand'; import { useBackgroundCommandStore } from '../stores/backgroundCommand';
import { usePersonalizationStore } from '../stores/personalization'; import { usePersonalizationStore } from '../stores/personalization';
import { useQuickDockStore } from '../stores/quickDock';
import { getToolStatusText, getToolDescription } from '../utils/chatDisplay'; import { getToolStatusText, getToolDescription } from '../utils/chatDisplay';
import { toolFaceKey } from '../utils/avatarFace'; import { toolFaceKey } from '../utils/avatarFace';
@ -65,6 +66,13 @@ export const computed = {
'reasoningEffort' 'reasoningEffort'
]), ]),
...mapState(useFileStore, ['contextMenu', 'fileTree', 'expandedFolders', 'todoList']), ...mapState(useFileStore, ['contextMenu', 'fileTree', 'expandedFolders', 'todoList']),
...mapState(useQuickDockStore, {
qdHasContent: 'hasContent',
qdExpanded: 'expanded'
}),
...mapWritableState(useQuickDockStore, {
qdUserCollapsed: 'userCollapsed'
}),
...mapWritableState(useUiStore, [ ...mapWritableState(useUiStore, [
'sidebarCollapsed', 'sidebarCollapsed',
'chatDisplayMode', 'chatDisplayMode',

View File

@ -681,9 +681,10 @@
</div> </div>
<!-- 左侧用户输入快捷跳转导航每个真实用户输入一根横线hover 波浪 + 预览点击跳转 --> <!-- 左侧用户输入快捷跳转导航每个真实用户输入一根横线hover 波浪 + 预览点击跳转 -->
<nav <nav
v-if="quickNavItems.length > 0" v-if="quickNavItems.length > 0 && quickNavMode !== 'hidden'"
class="chat-quick-nav" class="chat-quick-nav"
:style="{ '--qn-count': String(quickNavItems.length) }" :class="{ 'chat-quick-nav--center': quickNavMode === 'center' }"
:style="quickNavNavStyle"
aria-label="用户输入快捷跳转" aria-label="用户输入快捷跳转"
@mousemove="onQuickNavMouseMove" @mousemove="onQuickNavMouseMove"
@mouseleave="onQuickNavMouseLeave" @mouseleave="onQuickNavMouseLeave"
@ -692,7 +693,7 @@
v-for="(item, i) in quickNavItems" v-for="(item, i) in quickNavItems"
:key="item.msgIndex" :key="item.msgIndex"
class="qn-line" class="qn-line"
:class="{ 'is-hot': quickNavActiveIndex === i }" :class="{ 'is-hot': quickNavActiveIndex === i, 'is-current': quickNavCurrentIndex === i }"
:style="{ width: quickNavLineWidth(i) + 'px' }" :style="{ width: quickNavLineWidth(i) + 'px' }"
:ref="(el) => registerQuickNavLine(i, el)" :ref="(el) => registerQuickNavLine(i, el)"
@click="onQuickNavLineClick(item)" @click="onQuickNavLineClick(item)"
@ -1346,6 +1347,9 @@ function attachBounceListener() {
lastObservedTop = top; lastObservedTop = top;
lastObservedHeight = height; lastObservedHeight = height;
// DOM
updateQuickNavCurrent(top);
if (heightDelta !== 0) { if (heightDelta !== 0) {
bounceTraceLog( bounceTraceLog(
'height-change', 'height-change',
@ -2058,6 +2062,7 @@ onMounted(() => {
timerHandle = window.setInterval(() => { timerHandle = window.setInterval(() => {
nowMs.value = Date.now(); nowMs.value = Date.now();
}, 1000); }, 1000);
setupQuickNavLayoutObserver();
}); });
watch(scrollRef, () => { watch(scrollRef, () => {
@ -2091,6 +2096,7 @@ onBeforeUnmount(() => {
clearInterval(timerHandle); clearInterval(timerHandle);
timerHandle = null; timerHandle = null;
} }
teardownQuickNavLayoutObserver();
}); });
const isStackable = (action: any) => const isStackable = (action: any) =>
@ -2246,9 +2252,115 @@ function quickNavLineWidth(index: number): number {
return QUICK_NAV_LINE_BASE_WIDTH; return QUICK_NAV_LINE_BASE_WIDTH;
} }
const dist = Math.abs(index - active); const dist = Math.abs(index - active);
return QUICK_NAV_LINE_WIDTHS[dist] ?? QUICK_NAV_LINE_BASE_WIDTH; const raw = QUICK_NAV_LINE_WIDTHS[dist] ?? QUICK_NAV_LINE_BASE_WIDTH;
if (raw === QUICK_NAV_LINE_BASE_WIDTH) {
return raw;
}
// /
const scale = quickNavWaveScale.value;
return Math.max(QUICK_NAV_LINE_BASE_WIDTH, Math.round(raw * scale));
} }
// ===== 线 =====
// attachBounceListener DOM scroll virtua onScroll
const quickNavCurrentIndex = ref(-1);
function updateQuickNavCurrent(offset?: number) {
const items = quickNavItems.value;
if (!items.length) {
quickNavCurrentIndex.value = -1;
return;
}
const v = virtualizerRef.value;
if (!v) {
return;
}
const scrollOffset = typeof offset === 'number' ? offset : Number(v.scrollOffset) || 0;
const viewport = Number(v.viewportSize) || shellRef.value?.clientHeight || 0;
// 25% 线
const mark = scrollOffset + viewport * 0.25;
const itemIdx = v.findItemIndex(mark);
// itemIdx msgIndex
let k = -1;
for (let i = 0; i < items.length; i += 1) {
if (items[i].msgIndex <= itemIdx) {
k = i;
} else {
break;
}
}
quickNavCurrentIndex.value = k < 0 ? 0 : k;
}
watch(quickNavItems, () => {
updateQuickNavCurrent();
});
// ===== 线/ / =====
// _chat-area.scss rail = min(900px, 100% - 57px) 48.7px
//
const QUICK_NAV_RAIL_MAX = 900;
const QUICK_NAV_GAP_HIDE = 36; // < 36px 100%
// = padding 24 + rail (57-7.52)/2 + /2
// 57/7.52 _chat-area.scss --chat-rail-fluid / --chat-message-rail-width
// 线 = 线
// macOS 4px 2px
const QUICK_NAV_GAP_CRITICAL_BASE = 48.74;
const QUICK_NAV_LINE_MAX_WIDTH = 34;
const quickNavGap = ref(100); // ResizeObserver
const quickNavRailAtMax = ref(true);
const quickNavPinCenter = ref(QUICK_NAV_GAP_CRITICAL_BASE / 2);
const quickNavMode = computed<'pinned' | 'center' | 'hidden'>(() => {
if (quickNavGap.value < QUICK_NAV_GAP_HIDE) {
return 'hidden';
}
return quickNavRailAtMax.value ? 'pinned' : 'center';
});
const quickNavWaveScale = computed(() => {
const gap = quickNavGap.value;
if (quickNavMode.value === 'pinned') {
const pinLeft = quickNavPinCenter.value - QUICK_NAV_LINE_BASE_WIDTH / 2;
return Math.max(0, Math.min(1, (gap - pinLeft - 6) / QUICK_NAV_LINE_MAX_WIDTH));
}
if (quickNavMode.value === 'center') {
return Math.max(0, Math.min(1, (gap - 6) / QUICK_NAV_LINE_MAX_WIDTH));
}
return 1;
});
const quickNavNavStyle = computed<Record<string, string>>(() => {
const base: Record<string, string> = { '--qn-count': String(quickNavItems.value.length) };
if (quickNavMode.value === 'center') {
// = 线
return { ...base, left: '0px', width: `${quickNavGap.value}px` };
}
//
const pinLeft = quickNavPinCenter.value - QUICK_NAV_LINE_BASE_WIDTH / 2;
return { ...base, left: `${pinLeft}px`, width: `${QUICK_NAV_LINE_MAX_WIDTH}px` };
});
function updateQuickNavLayout() {
const shell = shellRef.value;
const rail = contentRef.value;
if (!shell || !rail) {
return;
}
const shellRect = shell.getBoundingClientRect();
const railRect = rail.getBoundingClientRect();
// updateChatScrollbarWidth
// /线
const scroller = scrollRef.value;
const sb = scroller ? Math.max(0, scroller.offsetWidth - scroller.clientWidth) : 0;
quickNavPinCenter.value = (QUICK_NAV_GAP_CRITICAL_BASE + sb / 2) / 2;
quickNavGap.value = railRect.left - shellRect.left;
quickNavRailAtMax.value = railRect.width >= QUICK_NAV_RAIL_MAX - 0.5;
}
let quickNavLayoutObserver: ResizeObserver | null = null;
function registerQuickNavLine(index: number, el: any) { function registerQuickNavLine(index: number, el: any) {
if (el) { if (el) {
quickNavLineEls.set(index, el as HTMLElement); quickNavLineEls.set(index, el as HTMLElement);
@ -2275,6 +2387,28 @@ function onQuickNavMouseLeave() {
quickNavActiveIndex.value = -1; quickNavActiveIndex.value = -1;
} }
function setupQuickNavLayoutObserver() {
if (typeof ResizeObserver === 'undefined' || !shellRef.value) {
return;
}
quickNavLayoutObserver = new ResizeObserver(() => {
updateQuickNavLayout();
});
quickNavLayoutObserver.observe(shellRef.value);
// / shell
if (contentRef.value) {
quickNavLayoutObserver.observe(contentRef.value);
}
updateQuickNavLayout();
}
function teardownQuickNavLayoutObserver() {
if (quickNavLayoutObserver) {
quickNavLayoutObserver.disconnect();
quickNavLayoutObserver = null;
}
}
function onQuickNavLineClick(item: any) { function onQuickNavLineClick(item: any) {
virtualizerRef.value?.scrollToIndex?.(item.msgIndex, { align: 'start', smooth: true }); virtualizerRef.value?.scrollToIndex?.(item.msgIndex, { align: 'start', smooth: true });
} }

View File

@ -1,5 +1,8 @@
<template> <template>
<aside class="quick-dock" :class="{ 'quick-dock--empty': !hasContent }"> <aside
class="quick-dock"
:class="{ 'quick-dock--empty': !hasContent || userCollapsed }"
>
<div ref="scrollRef" class="quick-dock__scroll" @scroll.passive="handleStackScroll"> <div ref="scrollRef" class="quick-dock__scroll" @scroll.passive="handleStackScroll">
<TodoWindow /> <TodoWindow />
<RunnerWindow kind="agent" /> <RunnerWindow kind="agent" />
@ -39,7 +42,6 @@ import { useQuickDockStore } from '@/stores/quickDock';
import { useSubAgentStore } from '@/stores/subAgent'; import { useSubAgentStore } from '@/stores/subAgent';
import { useBackgroundCommandStore } from '@/stores/backgroundCommand'; import { useBackgroundCommandStore } from '@/stores/backgroundCommand';
import { useConversationStore } from '@/stores/conversation'; import { useConversationStore } from '@/stores/conversation';
import { useFileStore } from '@/stores/file';
import { useUiStore } from '@/stores/ui'; import { useUiStore } from '@/stores/ui';
import TodoWindow from './TodoWindow.vue'; import TodoWindow from './TodoWindow.vue';
import RunnerWindow from './RunnerWindow.vue'; import RunnerWindow from './RunnerWindow.vue';
@ -58,21 +60,24 @@ const quickDock = useQuickDockStore();
const subAgentStore = useSubAgentStore(); const subAgentStore = useSubAgentStore();
const bgStore = useBackgroundCommandStore(); const bgStore = useBackgroundCommandStore();
const conversationStore = useConversationStore(); const conversationStore = useConversationStore();
const fileStore = useFileStore();
const uiStore = useUiStore(); const uiStore = useUiStore();
const { menu } = storeToRefs(quickDock); // hasContent / userCollapsed storeApp.vue
const { menu, hasContent, userCollapsed } = storeToRefs(quickDock);
const scrollRef = ref<HTMLElement | null>(null); const scrollRef = ref<HTMLElement | null>(null);
/** 四个窗口任一有内容时才占位显示(空列不挤压对话区) */ // //
const hasContent = computed(() => { watch(userCollapsed, (collapsed) => {
const todoCount = fileStore.todoList?.tasks?.length || 0; if (collapsed) {
return ( quickDock.resetTransient();
todoCount > 0 || }
subAgentStore.subAgents.length > 0 || });
bgStore.commands.length > 0 ||
quickDock.editedFiles.length > 0 //
); watch(hasContent, (has) => {
if (!has) {
quickDock.userCollapsed = false;
}
}); });
const AGENT_TERMINAL = new Set(['completed', 'failed', 'timeout', 'terminated']); const AGENT_TERMINAL = new Set(['completed', 'failed', 'timeout', 'terminated']);

View File

@ -18,7 +18,8 @@
.quick-dock { .quick-dock {
flex: none; flex: none;
width: 294px; /* 270 + 左右 12 padding */ width: 294px; /* 270 + 左右 12 padding */
padding: 20px 12px 20px 0; /* top 44px避开右上角展开/收起悬浮按钮top 8 + 高 28 + 8 间距) */
padding: 44px 12px 20px 0;
box-sizing: border-box; box-sizing: border-box;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -953,3 +954,49 @@
color: var(--text-tertiary); color: var(--text-tertiary);
cursor: default; cursor: default;
} }
/* ---------------- 展开/收起悬浮按钮App.vue 零宽锚点) ---------------- */
/* 零宽锚点flex 兄弟节点始终贴在 QuickDock 右缘
dock 收起时 = main 右缘落在对话标题栏右端展开时 = dock 右缘悬浮面板上方 */
.qd-toggle-anchor {
flex: none;
width: 0;
position: relative;
overflow: visible;
z-index: 70;
}
.qd-toggle {
position: absolute;
top: 8px;
right: 16px;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
color: var(--text-secondary);
/* 底色与背后面板一致(收起=对话标题栏 / 展开=应用背景,两者同为 --surface-panel */
background: var(--surface-panel);
border: none;
border-radius: 8px;
cursor: pointer;
transition: color 140ms ease;
}
.qd-toggle svg line {
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
}
/* hover 只变图标颜色,不变底色 */
.qd-toggle:hover {
color: var(--text-primary);
}
.qd-toggle.is-active {
color: var(--accent);
}

View File

@ -1,4 +1,7 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { useFileStore } from './file';
import { useSubAgentStore } from './subAgent';
import { useBackgroundCommandStore } from './backgroundCommand';
/** /**
* Quick Dock * Quick Dock
@ -38,6 +41,8 @@ interface QuickDockState {
detail: QuickDockDetailTarget | null; detail: QuickDockDetailTarget | null;
previewPath: string | null; previewPath: string | null;
menu: QuickDockMenuState | null; menu: QuickDockMenuState | null;
/** 用户通过顶部悬浮按钮手动收起(与「无内容自动收起」相互独立) */
userCollapsed: boolean;
} }
export const useQuickDockStore = defineStore('quickDock', { export const useQuickDockStore = defineStore('quickDock', {
@ -46,8 +51,28 @@ export const useQuickDockStore = defineStore('quickDock', {
editedFilesLive: false, editedFilesLive: false,
detail: null, detail: null,
previewPath: null, previewPath: null,
menu: null menu: null,
userCollapsed: false
}), }),
getters: {
/** 四个窗口(待办/子智能体/后台指令/文件记录)任一有内容 */
hasContent(state): boolean {
const fileStore = useFileStore();
const subAgentStore = useSubAgentStore();
const bgStore = useBackgroundCommandStore();
const todoCount = fileStore.todoList?.tasks?.length || 0;
return (
todoCount > 0 ||
subAgentStore.subAgents.length > 0 ||
bgStore.commands.length > 0 ||
state.editedFiles.length > 0
);
},
/** 实际处于展开态:有内容且未被用户手动收起 */
expanded(state): boolean {
return this.hasContent && !state.userCollapsed;
}
},
actions: { actions: {
setEditedFiles(list: EditedFileEntry[] | null | undefined, live = false) { setEditedFiles(list: EditedFileEntry[] | null | undefined, live = false) {
this.editedFilesLive = live; this.editedFilesLive = live;

View File

@ -2,7 +2,9 @@
.chat-container { .chat-container {
--chat-surface-color: var(--surface-panel); --chat-surface-color: var(--surface-panel);
--chat-rail-max: 900px; --chat-rail-max: 900px;
--chat-rail-fluid: 94%; /* 流式宽度两侧空白固定消息侧 49px / 输入栏侧 53px窗口变窄只压缩轨宽
57px 的取值与旧 94% 公式在轨宽 900px 临界点处连续移动端仍覆盖为 100% */
--chat-rail-fluid: calc(100% - 57px);
--chat-scrollbar-width: 8px; --chat-scrollbar-width: 8px;
--chat-rail-width: min(var(--chat-rail-max), var(--chat-rail-fluid)); --chat-rail-width: min(var(--chat-rail-max), var(--chat-rail-fluid));
--chat-message-rail-width: min( --chat-message-rail-width: min(
@ -28,7 +30,7 @@
position: absolute; position: absolute;
inset: 0 0 auto 0; inset: 0 0 auto 0;
height: 44px; height: 44px;
padding: 8px 16px; padding: 8px 52px 8px 16px; /* 右侧为快捷窗口展开/收起悬浮按钮预留位置 */
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
@ -105,17 +107,22 @@
/* 左侧用户输入快捷跳转导航 */ /* 左侧用户输入快捷跳转导航 */
.chat-quick-nav { .chat-quick-nav {
position: absolute; position: absolute;
left: 18px; left: 0; /* left/width 由 JS 按模式(钉死/居中)驱动 */
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
padding: 10px 8px; padding: 10px 0;
height: min(62vh, calc(var(--qn-count, 1) * 9px + 20px)); height: min(62vh, calc(var(--qn-count, 1) * 9px + 20px));
z-index: 30; z-index: 30;
} }
/* 居中模式:占满空白宽度,横线水平居中,宽度变化即对称展开 */
.chat-quick-nav--center {
align-items: center;
}
.qn-line { .qn-line {
height: 2px; height: 2px;
width: 8px; width: 8px;
@ -123,7 +130,9 @@
background: var(--text-tertiary); background: var(--text-tertiary);
opacity: 0.45; opacity: 0.45;
cursor: pointer; cursor: pointer;
transition: width 0.16s ease, opacity 0.16s ease, background-color 0.16s ease; /* 宽度hover 波浪保持快速颜色/透明度当前位置亮灭 0.25s 交叉渐变
一根逐渐亮起一根逐渐熄灭肉眼可分辨但仍然很快 */
transition: width 0.16s ease, opacity 0.25s ease, background-color 0.25s ease;
position: relative; position: relative;
flex-shrink: 0; flex-shrink: 0;
} }
@ -138,6 +147,19 @@
bottom: -5px; bottom: -5px;
} }
/* 滚动位置同步:当前阅读轮次(与 hover 同色,保证醒目) */
.qn-line.is-current {
background: var(--text-primary);
opacity: 1;
}
/* hover 任一根线时,当前位置线让位:只有 hover 的那根亮 */
.chat-quick-nav:hover .qn-line.is-current:not(:hover) {
background: var(--text-tertiary);
opacity: 0.45;
}
/* hover 高亮优先于滚动定位 */
.qn-line.is-hot { .qn-line.is-hot {
background: var(--text-primary); background: var(--text-primary);
opacity: 1; opacity: 1;