feat: 内容型打开自动展开承载面板(宽视口)
宽视口下 openTab 带 path/url seed 时,承载落点 pane 的面板折叠则自动展开: 落点在底部树展开底部面板,否则展开右侧面板;去重聚焦既有 tab 同样展开 (打开必须落在视野内)。窄视口抽屉逻辑不变;类型型打开(+ 菜单、agent 终端自动补 tab)不展开。同步 AGENTS.md / README 文档与测试。
This commit is contained in:
@@ -364,6 +364,9 @@ interface BetterSidebarService {
|
||||
* url 可选:落地后把 tab 的 path 预填为 URL(侧边栏浏览器导航种子,
|
||||
* 通常配合 hostname title;对 createTab 铸造的 tab 同样生效)。
|
||||
* 被设置禁用的类型是 no-op(console.warn 提示)。
|
||||
* 内容型打开(带 path/url seed)必须落在视野内:承载落点 pane 的面板
|
||||
* 折叠时自动展开(右侧面板;落点 pane 在底部树则展开底部面板;窄视口
|
||||
* 展开合并抽屉);类型型打开(+ 菜单、agent 终端自动补 tab)不展开。
|
||||
*/
|
||||
openTab(seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string; url?: string }): void
|
||||
/** 关闭一个 tab */
|
||||
|
||||
@@ -9,14 +9,14 @@
|
||||
- **🗂️ 资源管理器**:懒加载目录树(根 = 会话 cwd)、点击在侧边栏打开、行尾 `@文件` 引用到输入框、右键复制路径
|
||||
- **📝 编辑与预览**:CodeMirror 6 多语言高亮 + Ctrl/Cmd+S 原子保存;图片 / Markdown(预览/编辑切换)/ HTML(沙箱 iframe 预览,相对资源可加载)/ PDF / Word / Excel / PPT 内联预览,切换 Tab 不丢草稿
|
||||
- **⚡ 客户端懒加载**:Office / 终端 / 代码编辑器等重依赖按需分块加载——启动只拉 ~325KB 核心,打开 .xlsx 才拉 Univer(~20MB)、打开 .docx 只拉 docx 预览器、打开终端才拉 xterm;首次打开短暂 loading 后即用(详见 `docs/plans/2026-08-12-lazy-chunks-design.md`)
|
||||
- **🌐 浏览器**:内嵌网页浏览 tab(多开),后退/前进/刷新 +「在浏览器中打开」;页面在**沙箱 iframe** 中运行(不透明源:无法访问界面数据与本地文件,拒绝 localhost 等本机地址),界面实时显示沙箱状态、可临时解锁(关闭时红色警示);被站点拒绝嵌入(X-Frame-Options)时显示原因面板;聊天/界面里的 http(s) 外链默认在侧边栏打开
|
||||
- **🌐 浏览器**:内嵌网页浏览 tab(多开),后退/前进/刷新 +「在浏览器中打开」;页面在**沙箱 iframe** 中运行(不透明源:无法访问界面数据与本地文件,拒绝 localhost 等本机地址),界面实时显示沙箱状态、可临时解锁(关闭时红色警示);被站点拒绝嵌入(X-Frame-Options)时显示原因面板;聊天/界面里的 http(s) 外链默认在侧边栏打开(侧边栏折叠时自动展开面板)
|
||||
- **💻 终端**:xterm.js + node-pty 真实 shell(每会话 3 个 UI 上限)、Tab 保活重连回放;可选为模型注入 8 个 `terminal_*` 工具
|
||||
- **🌿 Git 面板**:真 diff + VSCode 式 diff tab、懒加载历史、右键暂存/放弃/提交/还原/捡取
|
||||
- **🧩 Subagent 页面**:主会话完整 agent 拓扑、点击直达执行记录、实时工具调用轮询、新子代理自动展开
|
||||
- **🪟 底部面板**:独立的第二个工作台(与右侧栏同类的标签页),只挤占中间 Agent 输出区、不覆盖左右侧边栏;**首次展开自动开一个新终端**(终端卡片二级设置可关);右上角 x 一键折叠
|
||||
- **📱 移动端**:视口 < 768px(真正的移动端宽度,不对齐宿主 1024 断点)时只显示右侧栏——进入窄屏时底部面板的标签页**直接并入右侧栏标签条**,右上角只剩一枚开关,面板为全宽抽屉;新会话默认收起,聊天里点文件/外链自动展开,不挤压对话区
|
||||
- **🔧 分栏工作台**:拖 Tab 拆分/合并分栏(可**跨面板拖 Tab**)、分隔线调比例;右上角持久按钮簇(底栏 + 侧拉 glyph)折叠/展开两个面板;两面板共享拐角双向拖动调节尺寸,拖动 rAF 直写 DOM 保持流畅
|
||||
- **🔁 会话隔离**:布局/分栏/Tab/两面板状态按会话持久化(localStorage),陈旧状态自动净化;聊天「产出文件」改在侧边栏打开
|
||||
- **🔁 会话隔离**:布局/分栏/Tab/两面板状态按会话持久化(localStorage),陈旧状态自动净化;聊天「产出文件」改在侧边栏打开(面板折叠时自动展开)
|
||||
- **⚙️ 声明式设置**:设置页「侧边卡片」分区按注册表渲染功能清单(小卡片网格,高亮 = 启用),每项可独立开/关;二级设置(子代理自动展开、终端工具、底部面板首展自动开终端、沙箱开关等)经齿轮按钮在原生弹窗中编辑
|
||||
- **🔌 服务化**:暴露 `ctx.betterSidebar` 服务,其他插件可注册侧边栏 tab 与文件预览器(内置 7 tab + 9 viewer 也走同一服务,详见 [AGENTS.md](./AGENTS.md))
|
||||
|
||||
|
||||
+28
-9
@@ -22,7 +22,7 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Context } from '../context-types.ts'
|
||||
import {
|
||||
activateTab, allLeaves, closeTab as closeTabReducer, openTabInActivePane, patchTab, togglePanel,
|
||||
activateTab, allLeaves, closeTab as closeTabReducer, openTabInActivePane, patchTab, togglePanel, treeOf,
|
||||
type SidebarState, type SidebarStore, type SidebarTab,
|
||||
} from './state.ts'
|
||||
import { isNarrowWidth } from './breakpoints.ts'
|
||||
@@ -199,6 +199,13 @@ export interface BetterSidebarService {
|
||||
* pre-set to the URL (the browser tab's navigation seed; the caller
|
||||
* usually pairs it with a hostname `title`). A disabled tab type is a
|
||||
* no-op.
|
||||
*
|
||||
* A CONTENT open (a `path` or `url` seed) must land in sight: when the
|
||||
* panel hosting the landing pane is collapsed, it is expanded
|
||||
* automatically (the right panel by default, the bottom panel when the
|
||||
* active pane lives there; on narrow viewports the merged drawer opens).
|
||||
* Type-only opens (the + menu, agent-terminal auto-tabs) never expand —
|
||||
* the panel behavior is their caller's business.
|
||||
*/
|
||||
openTab(seed: { type: string; title?: string; path?: string; diff?: SidebarTab['diff']; id?: string; url?: string }): void
|
||||
/** Close a tab by id. */
|
||||
@@ -345,18 +352,30 @@ export function createBetterSidebarService(store: SidebarStore): BetterSidebarSe
|
||||
} else {
|
||||
landed = next
|
||||
}
|
||||
// Narrow-viewport drawer: a CONTENT open (file / browser) must be
|
||||
// visible — expand the collapsed drawer so the open lands in sight.
|
||||
// Type-only opens (+ menu, agent-terminal auto-tabs) never expand; on
|
||||
// wide viewports nothing here changes (the panel behavior is the
|
||||
// caller's business).
|
||||
// A CONTENT open (file / browser) must land in sight: when the panel
|
||||
// hosting the landing pane is collapsed, expand it. On narrow
|
||||
// viewports the two workbenches merge into one drawer, so the drawer
|
||||
// (panelOpen) is the only lever; on wide viewports the landing pane's
|
||||
// own panel opens — the bottom panel when the active pane lives in the
|
||||
// bottom tree, else the right panel. Type-only opens (+ menu,
|
||||
// agent-terminal auto-tabs) never expand (the panel behavior is their
|
||||
// caller's business). The check runs on the post-dedupe state, so a
|
||||
// content open that merely FOCUSES an existing tab expands the panel
|
||||
// too — the open must never land out of sight.
|
||||
if (
|
||||
typeof window !== 'undefined'
|
||||
&& isNarrowWidth(window.innerWidth)
|
||||
&& (seed.path !== undefined || seed.url !== undefined)
|
||||
&& !landed.panelOpen
|
||||
) {
|
||||
return togglePanel(landed)
|
||||
if (isNarrowWidth(window.innerWidth)) {
|
||||
if (!landed.panelOpen) return togglePanel(landed)
|
||||
} else {
|
||||
const hostKey = treeOf(landed, landed.activePane ?? '')
|
||||
if (hostKey === 'bottomSplits') {
|
||||
if (!landed.bottomOpen) return { ...landed, bottomOpen: true }
|
||||
} else if (!landed.panelOpen) {
|
||||
return togglePanel(landed)
|
||||
}
|
||||
}
|
||||
}
|
||||
return landed
|
||||
})
|
||||
|
||||
+57
-3
@@ -466,11 +466,15 @@ describe('service.openTab across the two panels', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('service.openTab narrow-viewport auto-expand', () => {
|
||||
describe('service.openTab auto-expand for content opens', () => {
|
||||
/** The window stub is a plain object (see the file header), so the width is writable. */
|
||||
const setWidth = (width: number): void => {
|
||||
;(g.window as { innerWidth: number }).innerWidth = width
|
||||
}
|
||||
/** Collapse the right panel (the store defaults it open). */
|
||||
const collapseRightPanel = (store: ReturnType<typeof createSidebarStore>): void => {
|
||||
store.reduce(s => ({ ...s, panelOpen: false }))
|
||||
}
|
||||
|
||||
it('expands the collapsed drawer for a path (file) open on a narrow viewport', () => {
|
||||
setWidth(390)
|
||||
@@ -517,13 +521,50 @@ describe('service.openTab narrow-viewport auto-expand', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('never expands on a wide viewport (path open keeps the caller’s choice)', () => {
|
||||
it('expands the collapsed right panel for a path (file) open on a wide viewport', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'editor', title: 'Editor', component: () => null })
|
||||
store.setSession('s1')
|
||||
store.reduce(s => ({ ...s, panelOpen: false }))
|
||||
collapseRightPanel(store)
|
||||
service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
|
||||
const state = store.getSnapshot().state!
|
||||
expect(state.panelOpen).toBe(true)
|
||||
expect(allLeaves(state.splits).flatMap(l => l.tabs).some(t => t.type === 'editor')).toBe(true)
|
||||
})
|
||||
|
||||
it('expands the collapsed right panel for a URL (browser) open on a wide viewport', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'browser', title: 'Browser', component: () => null })
|
||||
store.setSession('s1')
|
||||
collapseRightPanel(store)
|
||||
service.openTab({ type: 'browser', url: 'https://example.com', title: 'example.com' })
|
||||
expect(store.getSnapshot().state!.panelOpen).toBe(true)
|
||||
})
|
||||
|
||||
it('a wide-viewport path open landing in the bottom tree expands the bottom panel instead', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'editor', title: 'Editor', component: () => null })
|
||||
store.setSession('s1')
|
||||
// The last-touched pane lives in the bottom tree and BOTH panels are
|
||||
// collapsed: the open must surface the bottom panel, not the right one.
|
||||
store.reduce(s => ({ ...s, activePane: (s.bottomSplits as { id: string }).id, panelOpen: false, bottomOpen: false }))
|
||||
service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
|
||||
const state = store.getSnapshot().state!
|
||||
expect(state.bottomOpen).toBe(true)
|
||||
expect(state.panelOpen).toBe(false)
|
||||
expect(allLeaves(state.bottomSplits).flatMap(l => l.tabs).some(t => t.type === 'editor')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps a collapsed panel for a type-only open on a wide viewport', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'explorer', title: 'Explorer', component: () => null })
|
||||
store.setSession('s1')
|
||||
collapseRightPanel(store)
|
||||
service.openTab({ type: 'explorer', title: 'Explorer' })
|
||||
expect(store.getSnapshot().state?.panelOpen).toBe(false)
|
||||
})
|
||||
|
||||
@@ -542,4 +583,17 @@ describe('service.openTab narrow-viewport auto-expand', () => {
|
||||
setWidth(1024)
|
||||
}
|
||||
})
|
||||
|
||||
it('expands on a wide viewport even when the open focuses an existing tab (id dedupe)', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({ id: 'editor', title: 'Editor', component: () => null })
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
|
||||
collapseRightPanel(store)
|
||||
service.openTab({ type: 'editor', title: 'main.ts', path: '/p/main.ts' })
|
||||
const state = store.getSnapshot().state!
|
||||
expect(state.panelOpen).toBe(true)
|
||||
expect(allLeaves(state.splits).flatMap(l => l.tabs).filter(t => t.type === 'editor')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user