From d74e84c87be3acfba9b5e9abf0c4567a3c49aabc Mon Sep 17 00:00:00 2001 From: yinjianm Date: Wed, 25 Mar 2026 05:54:43 +0800 Subject: [PATCH] fix(frontend): restore terminal tab scroll position preserve xterm viewport intent when switching terminal tabs so bottom-pinned sessions stay pinned and manually scrolled sessions keep their history position unify viewport restoration across activation, fit, and resize paths to avoid losing scroll state after terminal reflow --- .helloagents/CHANGELOG.md | 2 + .helloagents/INDEX.md | 2 +- .../.status.json | 1 + .../proposal.md | 65 +++++++++++++++++ .../tasks.md | 51 +++++++++++++ .helloagents/archive/_index.md | 2 + .helloagents/modules/frontend.md | 2 +- packages/frontend/src/components/Terminal.vue | 71 +++++++++++++++++-- 8 files changed, 190 insertions(+), 6 deletions(-) create mode 100644 .helloagents/archive/2026-03/202603250547_terminal-tab-scroll-restore/.status.json create mode 100644 .helloagents/archive/2026-03/202603250547_terminal-tab-scroll-restore/proposal.md create mode 100644 .helloagents/archive/2026-03/202603250547_terminal-tab-scroll-restore/tasks.md diff --git a/.helloagents/CHANGELOG.md b/.helloagents/CHANGELOG.md index 6614402..e842e9f 100644 --- a/.helloagents/CHANGELOG.md +++ b/.helloagents/CHANGELOG.md @@ -10,3 +10,5 @@ ### 修复 - **[frontend]**: 统一 `QuickCommandsView.vue` 的按钮主题适配,移除残留硬编码 hover 色值并切回主题变量体系 — by yinjianm - 方案: [202603250532_quickcommands-theme-alignment](archive/2026-03/202603250532_quickcommands-theme-alignment/) +- **[frontend]**: 修复终端标签切换后的视口恢复逻辑,贴底终端重新激活后自动贴底,上翻终端保留历史位置 — by yinjianm + - 方案: [202603250547_terminal-tab-scroll-restore](archive/2026-03/202603250547_terminal-tab-scroll-restore/) diff --git a/.helloagents/INDEX.md b/.helloagents/INDEX.md index 9cd4b7b..664456c 100644 --- a/.helloagents/INDEX.md +++ b/.helloagents/INDEX.md @@ -31,7 +31,7 @@ ```yaml kb_version: 2.3.7 -最后更新: 2026-03-25 05:39 +最后更新: 2026-03-25 05:52 模块数量: 4 待执行方案: 0 ``` diff --git a/.helloagents/archive/2026-03/202603250547_terminal-tab-scroll-restore/.status.json b/.helloagents/archive/2026-03/202603250547_terminal-tab-scroll-restore/.status.json new file mode 100644 index 0000000..d529882 --- /dev/null +++ b/.helloagents/archive/2026-03/202603250547_terminal-tab-scroll-restore/.status.json @@ -0,0 +1 @@ +{"status":"completed","completed":5,"failed":0,"pending":0,"total":5,"done":5,"percent":100,"current":"已完成 Terminal.vue 视口恢复修复与验证,等待归档","updated_at":"2026-03-25 05:52:11"} diff --git a/.helloagents/archive/2026-03/202603250547_terminal-tab-scroll-restore/proposal.md b/.helloagents/archive/2026-03/202603250547_terminal-tab-scroll-restore/proposal.md new file mode 100644 index 0000000..7d6bae6 --- /dev/null +++ b/.helloagents/archive/2026-03/202603250547_terminal-tab-scroll-restore/proposal.md @@ -0,0 +1,65 @@ +# 变更提案: terminal-tab-scroll-restore + +## 元信息 +```yaml +类型: 缺陷修复 +方案类型: implementation +优先级: P1 +状态: 已完成 +状态说明: 已完成 Terminal.vue 视口恢复修复与构建验证,待归档 +创建: 2026-03-25 +``` + +--- + +## 1. 需求 + +### 背景 +在 `/workspace` 切换不同终端标签时,xterm 视口偶发没有停留在底部,导致最新输出不在可见区域底部。用户期望保留“看历史”和“追最新输出”两种不同滚动意图。 + +### 目标 +- 修复终端标签切换后的视口恢复行为。 +- 如果终端切换前原本就在底部附近,则重新激活后自动贴底。 +- 如果用户切换前主动上翻查看历史,则重新激活后保留原滚动位置。 + +### 约束条件 +```yaml +范围约束: 优先限制在前端终端组件与现有会话终端实例管理链路 +实现约束: 不改动 SSH 数据流、标签切换语义和后端协议 +体验约束: 不得把所有终端切换都强制滚到底部 +兼容约束: 兼容现有 keep-alive + v-show 的终端挂载方式 +``` + +### 验收标准 +- [ ] 切换终端标签时,原本贴底的终端重新显示后仍贴底 +- [ ] 切换终端标签时,原本已上翻查看历史的终端重新显示后保留原滚动位置 +- [ ] 终端 `fit`、重激活与滚动恢复逻辑不引入明显回归 +- [ ] 前端构建通过 + +--- + +## 2. 方案 + +### 技术方案 +在 `Terminal.vue` 内为每个终端实例记录当前视口行号和“是否应贴底”的本地状态。使用 xterm 的 `buffer.active.ydisp` / `baseY` 判断当前距离底部的行数,并定义一个“小于等于阈值即视为贴底”的规则。终端重新激活或执行 `fit` 时,不再只做尺寸重算,而是按切换前快照恢复:贴底终端调用 `scrollToBottom()`,上翻终端调用 `scrollToLine(savedYdisp)` 恢复到原位置。 + +### 影响范围 +```yaml +涉及模块: + - frontend: Terminal.vue 终端视口恢复逻辑 +预计变更文件: 1 +``` + +### 风险评估 +| 风险 | 等级 | 应对 | +|------|------|------| +| `fit()` 后再次滚动导致闪动 | 低 | 将滚动恢复整合到同一恢复函数中,减少多次跳转 | +| 输出写入与隐藏标签状态交织时覆盖用户历史位置 | 低 | 切换前冻结快照,重新激活后按快照恢复 | +| 阈值过大导致“已上翻少量行”也被当成贴底 | 低 | 使用保守的小阈值,仅认定底部附近少量行差 | + +### 实施结果 +- `Terminal.vue` 新增终端视口快照逻辑,使用 `buffer.active.viewportY` 与 `baseY` 判断当前是否处于底部附近。 +- 终端在失活时会冻结“视口行号 + 是否贴底”状态,重新激活时按该状态恢复,而不是无条件停在任意位置。 +- `fit()` 和 `ResizeObserver` 路径都纳入了同一套恢复逻辑,避免尺寸重算后把视口意图覆盖掉。 +- `npm run build --workspace @nexus-terminal/frontend` 通过。 +- 受本地登录态与后端 SSH 会话现场限制,本轮未在浏览器里真实完成“多终端切换 + 输出滚动”交互验收。 diff --git a/.helloagents/archive/2026-03/202603250547_terminal-tab-scroll-restore/tasks.md b/.helloagents/archive/2026-03/202603250547_terminal-tab-scroll-restore/tasks.md new file mode 100644 index 0000000..0140465 --- /dev/null +++ b/.helloagents/archive/2026-03/202603250547_terminal-tab-scroll-restore/tasks.md @@ -0,0 +1,51 @@ +# 任务清单: terminal-tab-scroll-restore + +```yaml +@feature: terminal-tab-scroll-restore +@created: 2026-03-25 +@status: completed +@mode: R2 +``` + +## 进度概览 + +| 完成 | 失败 | 跳过 | 总数 | +|------|------|------|------| +| 5 | 0 | 0 | 5 | + +--- + +## 任务列表 + +### 1. 方案与范围确认 + +- [√] 1.1 创建终端切换滚动恢复方案包并锁定前端终端组件范围 | depends_on: [] + +### 2. 终端滚动恢复修复 + +- [√] 2.1 盘点终端切换、fit 与 xterm 视口状态的现有实现 | depends_on: [1.1] +- [√] 2.2 在 `Terminal.vue` 中实现“贴底优先、上翻保留”的视口恢复逻辑 | depends_on: [2.1] + +### 3. 验证与同步 + +- [√] 3.1 运行前端最小验证并记录结果 | depends_on: [2.2] +- [√] 3.2 更新 `.helloagents` 文档与变更记录 | depends_on: [3.1] + +--- + +## 执行日志 + +| 时间 | 任务 | 状态 | 备注 | +|------|------|------|------| +| 2026-03-25 05:47 | 1.1 | 完成 | 创建 implementation 方案包,范围锁定为终端标签切换后的 xterm 视口恢复 | +| 2026-03-25 05:49 | 2.1 / 2.2 | 完成 | 在 Terminal.vue 中加入视口快照、贴底判断与重激活恢复逻辑,并将 fit/ResizeObserver 路径统一纳入恢复流程 | +| 2026-03-25 05:51 | 3.1 | 完成 | `npm run build --workspace @nexus-terminal/frontend` 通过 | +| 2026-03-25 05:52 | 3.2 | 完成 | 更新 frontend 模块文档并准备归档 | + +--- + +## 执行备注 + +- 本次修复目标是恢复滚动意图,不是简单强制滚到底部。 +- 当前最可能的落点是 `Terminal.vue` 的激活与 `fit()` 逻辑,而非 `TerminalTabBar.vue` 本身。 +- 运行态真实验收仍依赖本地可用的多 SSH 会话现场;本轮以构建验证和代码路径审查为主。 diff --git a/.helloagents/archive/_index.md b/.helloagents/archive/_index.md index 51f9a9d..09194f5 100644 --- a/.helloagents/archive/_index.md +++ b/.helloagents/archive/_index.md @@ -9,6 +9,7 @@ |--------|------|------|---------|------|------| | 202603250317 | ghcr-docker-publish | implementation | workspace-root | ghcr-docker-publish#D001 | ✅完成 | | 202603250532 | quickcommands-theme-alignment | implementation | frontend | - | ✅完成 | +| 202603250547 | terminal-tab-scroll-restore | implementation | frontend | - | ✅完成 | | 202603251200 | workspace-workbench-monitor | implementation | frontend, backend | workspace-workbench-monitor#D001 | ✅完成 | ## 按月归档 @@ -16,6 +17,7 @@ ### 2026-03 - [202603250317_ghcr-docker-publish](./2026-03/202603250317_ghcr-docker-publish/) - 新增 GHCR 镜像发布 workflow 并切换 compose 镜像来源 - [202603250532_quickcommands-theme-alignment](./2026-03/202603250532_quickcommands-theme-alignment/) - 统一快捷指令视图按钮主题适配,移除残留硬编码 hover 色值 +- [202603250547_terminal-tab-scroll-restore](./2026-03/202603250547_terminal-tab-scroll-restore/) - 修复终端标签切换后的贴底/历史滚动恢复逻辑 - [202603251200_workspace-workbench-monitor](./2026-03/202603251200_workspace-workbench-monitor/) - `/workspace` 改为三栏 Workbench 布局,并新增开机累计流量监控 ## 结果状态说明 diff --git a/.helloagents/modules/frontend.md b/.helloagents/modules/frontend.md index f684b6f..e5aba48 100644 --- a/.helloagents/modules/frontend.md +++ b/.helloagents/modules/frontend.md @@ -36,7 +36,7 @@ ### 工作区交互 **条件**: 用户进入 `/workspace` 或相关管理页面。 -**行为**: 通过组件、Pinia 与 composable 协同管理终端、文件管理、命令历史、布局配置、主题和状态监控;当前 `/workspace` 默认主布局为“左侧 Workbench、中央终端、右侧状态监控”,其中 Workbench 以 tab 容器整合快捷指令、命令历史、文件管理和编辑器,默认激活快捷指令。`QuickCommandsView.vue` 内的新增按钮、空状态按钮和列表操作按钮统一复用 `bg-button`、`text-button-text`、`hover:bg-button-hover`、`hover:bg-border` 等主题变量类,避免写死黑白 hover 色值。 +**行为**: 通过组件、Pinia 与 composable 协同管理终端、文件管理、命令历史、布局配置、主题和状态监控;当前 `/workspace` 默认主布局为“左侧 Workbench、中央终端、右侧状态监控”,其中 Workbench 以 tab 容器整合快捷指令、命令历史、文件管理和编辑器,默认激活快捷指令。`QuickCommandsView.vue` 内的新增按钮、空状态按钮和列表操作按钮统一复用 `bg-button`、`text-button-text`、`hover:bg-button-hover`、`hover:bg-border` 等主题变量类,避免写死黑白 hover 色值;`Terminal.vue` 会跟踪 xterm 的视口行号与贴底状态,在终端标签切换、重新激活和 `fit()` 后按原滚动意图恢复。 **结果**: 页面逻辑分散在 `views/`、`components/`、`stores/` 与 `composables/`,其中布局与交互微调优先落在 `layout.store.ts`、`LayoutRenderer.vue`、`WorkspaceWorkbench.vue`、`QuickCommandsView.vue` 和 `Terminal.vue`。 ## 依赖关系 diff --git a/packages/frontend/src/components/Terminal.vue b/packages/frontend/src/components/Terminal.vue index c2d0b44..ed52ccf 100644 --- a/packages/frontend/src/components/Terminal.vue +++ b/packages/frontend/src/components/Terminal.vue @@ -36,9 +36,13 @@ let resizeObserver: ResizeObserver | null = null; let observedElement: HTMLElement | null = null; // +++ Store the observed element +++ let debounceTimer: number | null = null; // 用于防抖的计时器 ID let selectionListenerDisposable: IDisposable | null = null; // +++ 提升声明并添加类型 +++ +let scrollListenerDisposable: IDisposable | null = null; let lastResizeObserverWidth = 0; let lastResizeObserverHeight = 0; const RESIZE_THRESHOLD = 0.5; // px +const BOTTOM_STICK_THRESHOLD = 2; +let lastKnownViewportLine = 0; +let lastKnownShouldStickToBottom = true; const { isMobile } = useDeviceDetection(); // 设备检测 @@ -89,6 +93,45 @@ const debounce = (func: Function, delay: number) => { }; }; +type TerminalViewportSnapshot = { + viewportLine: number; + shouldStickToBottom: boolean; +}; + +const getViewportSnapshot = (term: Terminal): TerminalViewportSnapshot => { + const buffer = term.buffer.active; + const maxScrollLine = Math.max(0, buffer.baseY); + const viewportLine = Math.max(0, Math.min(buffer.viewportY, maxScrollLine)); + + return { + viewportLine, + shouldStickToBottom: maxScrollLine - viewportLine <= BOTTOM_STICK_THRESHOLD, + }; +}; + +const syncViewportTracking = (term: Terminal): TerminalViewportSnapshot => { + const snapshot = getViewportSnapshot(term); + lastKnownViewportLine = snapshot.viewportLine; + lastKnownShouldStickToBottom = snapshot.shouldStickToBottom; + return snapshot; +}; + +const restoreViewportSnapshot = (term: Terminal, snapshot?: TerminalViewportSnapshot) => { + const effectiveSnapshot = snapshot ?? { + viewportLine: lastKnownViewportLine, + shouldStickToBottom: lastKnownShouldStickToBottom, + }; + + if (effectiveSnapshot.shouldStickToBottom) { + term.scrollToBottom(); + } else { + const targetLine = Math.min(effectiveSnapshot.viewportLine, Math.max(0, term.buffer.active.baseY)); + term.scrollToLine(targetLine); + } + + syncViewportTracking(term); +}; + // 防抖处理由 ResizeObserver 触发的 resize 事件 const debouncedEmitResize = debounce((term: Terminal) => { if (term && props.isActive) { // 仅当标签仍处于活动状态时才发送防抖后的 resize @@ -105,13 +148,15 @@ const debouncedEmitResize = debounce((term: Terminal) => { }, 150); // 150ms 防抖延迟 // 立即执行 Fit 并发送 Resize 的函数 -const fitAndEmitResizeNow = (term: Terminal) => { +const fitAndEmitResizeNow = (term: Terminal, snapshotOverride?: TerminalViewportSnapshot) => { // terminalRef 现在指向内部容器,检查它即可 if (!term || !terminalRef.value) return; try { // 确保容器可见且有尺寸 if (terminalRef.value.offsetHeight > 0 && terminalRef.value.offsetWidth > 0) { + const viewportSnapshot = snapshotOverride ?? syncViewportTracking(term); fitAddon?.fit(); + restoreViewportSnapshot(term, viewportSnapshot); const dimensions = { cols: term.cols, rows: term.rows }; emitWorkspaceEvent('terminal:resize', { sessionId: props.sessionId, dims: dimensions }); // 发出稳定尺寸事件 @@ -268,14 +313,19 @@ onMounted(() => { console.log(`[Terminal ${props.sessionId}] Xterm open() called, considering DOM ready for initial style checks.`); // 适应容器大小 - fitAddon.fit(); - emitWorkspaceEvent('terminal:resize', { sessionId: props.sessionId, dims: { cols: terminal.cols, rows: terminal.rows } }); // 触发初始 resize 事件 + fitAndEmitResizeNow(terminal); // 监听用户输入 terminal.onData((data) => { emitWorkspaceEvent('terminal:input', { sessionId: props.sessionId, data }); }); + scrollListenerDisposable = terminal.onScroll(() => { + if (terminal && props.isActive) { + syncViewportTracking(terminal); + } + }); + // 监听终端大小变化 (通过 ResizeObserver) - 主要处理浏览器窗口大小变化等 // ResizeObserver 观察内部容器 terminalRef if (terminalRef.value) { @@ -317,7 +367,9 @@ onMounted(() => { if (rectHeight > 0 && rectWidth > 0) { try { + const viewportSnapshot = syncViewportTracking(terminal); fitAddon?.fit(); + restoreViewportSnapshot(terminal, viewportSnapshot); debouncedEmitResize(terminal); // This will log the cols/rows after debouncing emitWorkspaceEvent('terminal:stabilizedResize', { sessionId: props.sessionId, width: roundedWidth, height: roundedHeight }); } catch (e) { @@ -340,6 +392,10 @@ onMounted(() => { if (newValue) { // --- Become Active --- console.log(`[Terminal ${props.sessionId}] Becoming active. Observing element and fitting.`); + const activationViewportSnapshot = { + viewportLine: lastKnownViewportLine, + shouldStickToBottom: lastKnownShouldStickToBottom, + }; // Start observing try { resizeObserver.observe(observedElement); @@ -351,7 +407,7 @@ onMounted(() => { setTimeout(() => { // 检查内部容器 terminalRef if (props.isActive && terminal && terminalRef.value && terminalRef.value.offsetHeight > 0) { - fitAndEmitResizeNow(terminal); + fitAndEmitResizeNow(terminal, activationViewportSnapshot); // Also ensure focus when becoming active terminal.focus(); } else { @@ -362,6 +418,9 @@ onMounted(() => { } else { // --- Become Inactive --- console.log(`[Terminal ${props.sessionId}] Becoming inactive. Unobserving element.`); + if (terminal) { + syncViewportTracking(terminal); + } // Stop observing try { resizeObserver.unobserve(observedElement); @@ -602,6 +661,10 @@ onBeforeUnmount(() => { selectionListenerDisposable.dispose(); } + if (scrollListenerDisposable) { + scrollListenerDisposable.dispose(); + } + // 确保在卸载时移除右键监听器 removeContextMenuListener();