فهرست منبع

fix(layout): prepare destination widths before fullscreen retreat

imccyu 3 هفته پیش
والد
کامیت
4a60189a63

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.md
-2026-09-07-sidebar-responsive-tab-info.md: d06b8e523b11e04a747da1ecf22ca5cc898f882a
-2026-09-07-sidebar-responsive-tab-info.zh.md: f0e615191777d9450a04af1e580ef7ef7c512eb7
+2026-09-07-sidebar-responsive-tab-info.md: ade20148cccd2233224417f762e65492828943bb
+2026-09-07-sidebar-responsive-tab-info.zh.md: 0903e4ff06ca187fcc1fd0e9d8de63f70d5cc427

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.md

@@ -14,7 +14,7 @@ The slot framework injects one `useTabInfo()` returning nested `sidebar`, `panel
 
 The frame protects 400px for the conversation by shrinking the right column, then closing it before shrinking the conversation. Its first-open preference is 45% of the viewport, retained thereafter in pixels, with a 300px floor and 70% viewport ceiling. The left column keeps its preference at widths of at least 1024px. Closing is recorded state: widening never opens it, while a user action or explicit Session API may. Refresh restores defaults rather than persisting layout.
 
-Fullscreen uses the same mounted content tree and covers the viewport while retaining the underlying column reservation. Opening below 768px selects automatic fullscreen; exiting it there closes the Sidebar. Widening can end automatic fullscreen but leaves manually selected fullscreen intact. The product permits two horizontal panes, a 50/50 initial split, and a 20–80% divider; narrow panes refuse new splits. The generic docking engine retains its independent capabilities. A fullscreen entry completes its slide before reporting the underlying track; that covered width change is instantaneous, so neither entry nor returning to normal reveals a background reflow. At the two-pane budget the split control is hidden; a one-pane width refusal remains disabled.
+Fullscreen uses the same mounted content tree and covers the viewport while retaining the underlying column reservation. Opening below 768px selects automatic fullscreen; exiting it there closes the Sidebar. Widening can end automatic fullscreen but leaves manually selected fullscreen intact. The product permits two horizontal panes, a 50/50 initial split, and a 20–80% divider; narrow panes refuse new splits. The generic docking engine retains its independent capabilities. A fullscreen entry completes its slide before reporting the underlying track; that covered width change is instantaneous, so neither entry nor returning to normal reveals a background reflow. At the two-pane budget the split control is hidden; a one-pane width refusal remains disabled. On exit, the frame prepares the destination before the overlay retreats: no right track for close, a normal track for restore. Transition suppression survives clearing the fullscreen report and ends on the next geometry action.
 
 This decision supersedes the flat owner-props choice in [tab types and navigation](2026-09-05-sidebar-tab-types-and-navigation.md) and the no-concession, overlay presentation and product pane limit in [docking infrastructure](../feature/2026-09-04-right-sidebar-docking-infrastructure.md). Their registration, record-lifetime, state ownership and engine-selection rationale remain active.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-07-sidebar-responsive-tab-info.zh.md

@@ -14,7 +14,7 @@ Slot 框架注入一个 `useTabInfo()`,返回嵌套的 `sidebar`、`panel` 与
 
 框架先缩小右列,再关闭右列,最后才缩小会话区,以保护会话区的 400px 宽度。右列首次打开偏好为视口的 45%,此后按像素保留,下限为 300px,上限为视口的 70%。在宽度至少为 1024px 时,左列保持自身偏好。关闭是被记录的状态:变宽不会打开右栏,用户动作或显式 Session API 可以打开。刷新恢复默认值,不持久化布局。
 
-全屏使用同一棵已挂载内容树,覆盖视口并保留底层列的占位。在 768px 以下打开会选择自动全屏;在此宽度下退出全屏会关闭 Sidebar。变宽可以结束自动全屏,但保留手动选择的全屏。产品允许两个水平窗格,初始按 50/50 分割,分割线范围为 20–80%;窄窗格拒绝新分栏。通用停靠引擎保留其独立能力。 全屏入场先完成滑入,再报告底层轨道;被覆盖的宽度变化瞬间完成,因此入场及返回普通模式都不暴露底层重排。达到两格预算时隐藏分栏控件;单格宽度不足时仍显示禁用控件。
+全屏使用同一棵已挂载内容树,覆盖视口并保留底层列的占位。在 768px 以下打开会选择自动全屏;在此宽度下退出全屏会关闭 Sidebar。变宽可以结束自动全屏,但保留手动选择的全屏。产品允许两个水平窗格,初始按 50/50 分割,分割线范围为 20–80%;窄窗格拒绝新分栏。通用停靠引擎保留其独立能力。 全屏入场先完成滑入,再报告底层轨道;被覆盖的宽度变化瞬间完成,因此入场及返回普通模式都不暴露底层重排。达到两格预算时隐藏分栏控件;单格宽度不足时仍显示禁用控件。 退场时,框架先准备目标布局,再让覆盖层退出:关闭不留右轨道,恢复保留普通轨道。清除全屏报告时仍保留过渡抑制,直到下一次几何操作才结束。
 
 本决策取代[标签类型与导航](2026-09-05-sidebar-tab-types-and-navigation.zh.md)的平铺 owner props 选择,以及[停靠基础设施](../feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md)中的无让步、覆盖模式和产品窗格上限。它们的注册、记录生命周期、状态所有权与引擎选型理由继续有效。
 

+ 2 - 0
apps/web/tests/details-session-lifecycle.e2e.ts

@@ -93,6 +93,7 @@ async function sidebarSnapshot(page: Page) {
     return {
       viewport: [window.innerWidth, window.innerHeight],
       columns: getComputedStyle(frame).gridTemplateColumns.split(' ').map(value => Math.round(Number.parseFloat(value))),
+      columnTransition: getComputedStyle(frame).transitionProperty,
       expanded,
       mode: panel.getAttribute('data-sidebar-right-panel'),
       panelContentWidth: expanded ? Math.round(Number.parseFloat(style.width)) : 0,
@@ -261,6 +262,7 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S
     // Closing the fullscreen panel exposes Session navigation without changing
     // its manual mode; reopening after the round trip must restore that mode.
     await close()
+    expect((await sidebarSnapshot(page)).columnTransition).toBe('none')
     await checkpoint('A closed with manual fullscreen retained')
     await select(seeded, 'DONE')
     await expect.poll(() => detailsTrack(page)).toBe(0)

+ 46 - 15
apps/web/tests/sidebar-right.e2e.ts

@@ -85,6 +85,28 @@ async function pointIn(locator: Locator, fx: number, fy: number): Promise<{ x: n
   return { x: box.x + box.width * fx, y: box.y + box.height * fy }
 }
 
+/** Pause the panel's next real transform transition after observing its initial frame geometry. */
+async function holdPanelSlide(panel: Locator) {
+  return await panel.evaluateHandle((node) => {
+    const controller = new AbortController()
+    const state = { animation: null as Animation | null, columnsAtStart: '', dispose: () => { controller.abort() } }
+    node.addEventListener('transitionrun', (event) => {
+      if (event.target !== node || (event as TransitionEvent).propertyName !== 'transform') return
+      const frame = node.closest('[style*="grid-template-columns"]')
+      if (frame === null) throw new Error('panel frame is unavailable')
+      state.columnsAtStart = getComputedStyle(frame).gridTemplateColumns
+      const slide = node.getAnimations().find(animation =>
+        'transitionProperty' in animation && animation.transitionProperty === 'transform')
+      if (slide === undefined || slide.effect === null) throw new Error('panel transform transition is unavailable')
+      slide.pause()
+      slide.currentTime = Number(slide.effect.getComputedTiming().endTime) / 2
+      state.animation = slide
+      controller.abort()
+    }, { signal: controller.signal })
+    return state
+  })
+}
+
 /** The expand button in the conversation header, present only while collapsed. */
 function expandOf(page: Page): Locator {
   return page.locator('[data-sidebar-right-expand]')
@@ -457,21 +479,7 @@ describe('web e2e: shipped right Sidebar', () => {
 
       // Pause the real CSS transition at its midpoint so host scheduling cannot
       // skip the partly covered frame whose underlying width is under test.
-      const held = await panel.evaluateHandle((node) => {
-        const controller = new AbortController()
-        const state = { animation: null as Animation | null, dispose: () => { controller.abort() } }
-        node.addEventListener('transitionrun', (event) => {
-          if (event.target !== node || (event as TransitionEvent).propertyName !== 'transform') return
-          const slide = node.getAnimations().find(animation =>
-            'transitionProperty' in animation && animation.transitionProperty === 'transform')
-          if (slide === undefined || slide.effect === null) throw new Error('panel transform transition is unavailable')
-          slide.pause()
-          slide.currentTime = Number(slide.effect.getComputedTiming().endTime) / 2
-          state.animation = slide
-          controller.abort()
-        }, { signal: controller.signal })
-        return state
-      })
+      const held = await holdPanelSlide(panel)
       try {
         await expandOf(page).click()
         await expect.poll(() => held.evaluate(state => state.animation?.playState)).toBe('paused')
@@ -487,6 +495,29 @@ describe('web e2e: shipped right Sidebar', () => {
         await expect.poll(() => frame.getAttribute('data-rightbar-fullscreen')).toBe('true')
         expect(await panel.boundingBox()).toEqual({ x: 0, y: 0, ...viewport })
         expect(await geometry()).toEqual({ columns: normalColumns, transition: 'none', handles: ['none'], animatingGrid: false })
+
+        const exit = await holdPanelSlide(panel)
+        try {
+          await column.locator('[data-sidebar-right-toggle]').click()
+          await expect.poll(() => exit.evaluate(state => state.animation?.playState)).toBe('paused')
+          expect(await exit.evaluate(state => state.columnsAtStart)).toBe(closedColumns)
+          const leaving = await panel.boundingBox()
+          if (leaving === null) throw new Error('leaving panel is not rendered')
+          expect(leaving.x).toBeGreaterThan(0)
+          expect(leaving.x).toBeLessThan(viewport.width)
+          expect(await geometry()).toEqual({ columns: closedColumns, transition: 'none', handles: ['none'], animatingGrid: false })
+          await exit.evaluate((state) => { (state.animation as Animation).finish() })
+          expect((await geometry()).columns).toBe(closedColumns)
+        } finally {
+          await exit.evaluate((state) => {
+            state.dispose()
+            if (state.animation?.playState === 'paused') state.animation.finish()
+          })
+          await exit.dispose()
+        }
+
+        await expandOf(page).click()
+        await expect.poll(() => frame.getAttribute('data-rightbar-fullscreen')).toBe('true')
         await column.locator('[data-sidebar-right-mode="push"]').click()
         expect((await geometry()).columns).toBe(normalColumns)
         expect((await geometry()).animatingGrid).toBe(false)

+ 2 - 2
packages/client/ui-layout/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-layout/README.md
-README.md: 9f7a050425e203e1c5db58b7e4a03c5017cdf192
-README.zh.md: dc68b4b1265209a257703c5e66d7f28e65946270
+README.md: 0e353e21f39ba8a84e661f1c540db2c2424bcbfa
+README.zh.md: 16471fdabb46bbfb35158ef85aa2544db4ceae93

+ 1 - 1
packages/client/ui-layout/README.md

@@ -39,7 +39,7 @@ The presenter consumes resolved theme snapshots and projects them onto the docum
 <details>
 <summary>Implementation internals — click to expand</summary>
 
-One registration declares four child slots and binds `ctx.layout` methods `toggleSidebar`, `openRightbar(track, fullscreen)`, and `closeRightbar`. The store owns the single frame-width measurement, width preferences, and the occupant's presentation report. The `rightbar` owner supplies actual `width`, `viewportWidth`, and normal-presentation eligibility `canShow`; insufficient room causes a deterministic close, never automatic reopening on widening. Fullscreen hides the width handle without releasing a track the occupant retains. AppFrame always mounts the conversation and right columns; a connected Session renders through `SessionProvider`, and without one the right column is an empty zero-width track. It projects the selected Session title over the build-configured product title or the localized `common.brand.localBuild` fallback, so locale revisions update document metadata with the root entry. The theme presenter is a second effect: pure DOM writes from resolved snapshots — initial state through the getter once, then event-driven only, with no React path. It applies palette, font-size, and token variables before measuring the rendered background as the single color authority. Fullscreen presentation suppresses grid and handle transitions; its occupant reports the new columns only after covering the frame.
+One registration declares four child slots and binds `ctx.layout` methods `toggleSidebar`, `openRightbar(track, fullscreen)`, and `closeRightbar`. The store owns the single frame-width measurement, width preferences, and the occupant's presentation report. The `rightbar` owner supplies actual `width`, `viewportWidth`, and normal-presentation eligibility `canShow`; insufficient room causes a deterministic close, never automatic reopening on widening. Fullscreen hides the width handle without releasing a track the occupant retains. AppFrame always mounts the conversation and right columns; a connected Session renders through `SessionProvider`, and without one the right column is an empty zero-width track. It projects the selected Session title over the build-configured product title or the localized `common.brand.localBuild` fallback, so locale revisions update document metadata with the root entry. The theme presenter is a second effect: pure DOM writes from resolved snapshots — initial state through the getter once, then event-driven only, with no React path. It applies palette, font-size, and token variables before measuring the rendered background as the single color authority. Fullscreen presentation suppresses grid and handle transitions; its occupant reports the new columns only after covering the frame. Fullscreen exit keeps transitions suppressed while the frame installs its destination geometry: close removes the right track, and restore retains it. Subsequent normal geometry actions restore ordinary transitions.
 
 </details>
 

+ 1 - 1
packages/client/ui-layout/README.zh.md

@@ -39,7 +39,7 @@ kind: "package-reference"
 <details>
 <summary>实现细节——点击展开</summary>
 
-一次注册声明四个子slot并绑定 `ctx.layout` 的 `toggleSidebar`、`openRightbar(track, fullscreen)` 与 `closeRightbar`。store持有唯一的frame宽度测量、左右栏偏好及占用方报告的呈现状态。`rightbar` 的owner参数为实际 `width`、`viewportWidth` 与普通呈现的 `canShow`;占用方在空间不足时执行确定性的收起,变宽不自行重新展开。全屏隐藏宽度手柄,但不自行释放占用方要求保留的轨道。AppFrame 始终挂载会话与右栏;已连接 Session 经 `SessionProvider` 渲染,没有 Session 时右栏是一条空的零宽轨道。它把所选 Session 标题投影到构建配置的产品标题或本地化 `common.brand.localBuild` 回退值之上,因此 locale revision 会随根 entry 一起更新文档元数据。主题呈现器是第二个 effect:从解析后的快照做纯 DOM 写入——初始状态经 getter 读取一次,此后仅事件驱动,不经过 React。它先应用调色板、字号与 token 变量,再把渲染出的背景测量为唯一的颜色依据。 全屏呈现禁用网格和手柄过渡;占用方完全覆盖框架后才报告新的列宽。
+一次注册声明四个子slot并绑定 `ctx.layout` 的 `toggleSidebar`、`openRightbar(track, fullscreen)` 与 `closeRightbar`。store持有唯一的frame宽度测量、左右栏偏好及占用方报告的呈现状态。`rightbar` 的owner参数为实际 `width`、`viewportWidth` 与普通呈现的 `canShow`;占用方在空间不足时执行确定性的收起,变宽不自行重新展开。全屏隐藏宽度手柄,但不自行释放占用方要求保留的轨道。AppFrame 始终挂载会话与右栏;已连接 Session 经 `SessionProvider` 渲染,没有 Session 时右栏是一条空的零宽轨道。它把所选 Session 标题投影到构建配置的产品标题或本地化 `common.brand.localBuild` 回退值之上,因此 locale revision 会随根 entry 一起更新文档元数据。主题呈现器是第二个 effect:从解析后的快照做纯 DOM 写入——初始状态经 getter 读取一次,此后仅事件驱动,不经过 React。它先应用调色板、字号与 token 变量,再把渲染出的背景测量为唯一的颜色依据。 全屏呈现禁用网格和手柄过渡;占用方完全覆盖框架后才报告新的列宽。 退出全屏时,框架先保持无过渡并安装目标布局:关闭移除右轨道,恢复保留右轨道。后续普通几何操作恢复正常过渡。
 
 </details>
 

+ 4 - 2
packages/client/ui-layout/src/client/AppFrame.module.css

@@ -60,9 +60,11 @@
   transition: none;
 }
 
-/* Fullscreen covers the frame before its underlying columns change. */
+/* Fullscreen covers the frame while its underlying columns enter or exit. */
 .frame[data-rightbar-fullscreen],
-.frame[data-rightbar-fullscreen] .handle {
+.frame[data-rightbar-fullscreen] .handle,
+.frame[data-rightbar-instant],
+.frame[data-rightbar-instant] .handle {
   transition: none;
 }
 

+ 1 - 0
packages/client/ui-layout/src/client/AppFrame.tsx

@@ -201,6 +201,7 @@ export function AppFrame({
       data-sidebar-collapsed={sidebarCollapsed || undefined}
       data-rightbar-collapsed={cols.rightbar === 0 || undefined}
       data-rightbar-fullscreen={panels.rightbarFullscreen || undefined}
+      data-rightbar-instant={panels.rightbarInstant || undefined}
       data-dragging={dragging || undefined}
     >
       <DocumentTitle

+ 15 - 1
packages/client/ui-layout/src/client/stores.ts

@@ -38,6 +38,8 @@ type LayoutState = {
   rightbarTrack: boolean
   /** Reported fullscreen presentation; hides the outer resize handle. */
   rightbarFullscreen: boolean
+  /** Suppress transitions for a fullscreen exit until another geometry action. */
+  rightbarInstant: boolean
 }
 
 /**
@@ -72,27 +74,38 @@ export function createLayoutStore(): EngineStoreHandle<LayoutState, LayoutAction
       rightbarShown: false,
       rightbarTrack: false,
       rightbarFullscreen: false,
+      rightbarInstant: false,
     }),
     actions: {
-      setSidebar: (d, px: number) => { d.sidebar = clampWidth(px, SIDEBAR_MIN, SIDEBAR_MAX) },
+      setSidebar: (d, px: number) => {
+        d.rightbarInstant = false
+        d.sidebar = clampWidth(px, SIDEBAR_MIN, SIDEBAR_MAX)
+      },
       // Narrow toggles flip only the override: the width preference survives
       // untouched, so re-widening restores the pre-squeeze layout.
       toggleSidebar: (d) => {
+        d.rightbarInstant = false
         if (d.viewportWidth < SIDEBAR_AUTO_COLLAPSE) d.narrowExpanded = !d.narrowExpanded
         else d.sidebar = d.sidebar === 0 ? SIDEBAR_DEFAULT : 0
       },
       // Crossing the breakpoint in either direction drops the override: the
       // narrow default is auto-collapsed, the wide state is the preference.
       setViewportWidth: (d, width: number) => {
+        if (d.viewportWidth === width) return
+        d.rightbarInstant = false
         if ((d.viewportWidth < SIDEBAR_AUTO_COLLAPSE) !== (width < SIDEBAR_AUTO_COLLAPSE)) {
           d.narrowExpanded = false
         }
         d.viewportWidth = width
       },
       setRightbar: (d, px: number) => {
+        d.rightbarInstant = false
         d.rightbar = clampWidth(px, RIGHTBAR_MIN, Math.max(RIGHTBAR_MIN, d.viewportWidth * RIGHTBAR_MAX_RATIO))
       },
       openRightbar: (d, track: boolean, fullscreen: boolean) => {
+        if (!d.rightbarShown || d.rightbarTrack !== track || d.rightbarFullscreen !== fullscreen) {
+          d.rightbarInstant = d.rightbarFullscreen && !fullscreen
+        }
         if (!d.rightbarShown && d.viewportWidth < SIDEBAR_AUTO_COLLAPSE) d.narrowExpanded = false
         d.rightbar ??= Math.max(RIGHTBAR_MIN, Math.round(d.viewportWidth * RIGHTBAR_DEFAULT_RATIO))
         d.rightbarShown = true
@@ -100,6 +113,7 @@ export function createLayoutStore(): EngineStoreHandle<LayoutState, LayoutAction
         d.rightbarFullscreen = fullscreen
       },
       closeRightbar: (d) => {
+        if (d.rightbarShown) d.rightbarInstant = d.rightbarFullscreen
         d.rightbarShown = false
         d.rightbarTrack = false
         d.rightbarFullscreen = false

+ 48 - 0
packages/client/ui-layout/tests/app-frame.client.spec.tsx

@@ -317,6 +317,54 @@ describe('AppFrame normal width concessions', () => {
 })
 
 describe('AppFrame right panel presentation', () => {
+  it('releases the fullscreen track with the instant marker while clearing fullscreen', () => {
+    const { frame, instance } = mountFrame()
+    act(() => { instance.actions.openRightbar(true, true) })
+    expect(tracks(frame)).toEqual([280, 864])
+    act(() => { instance.actions.closeRightbar() })
+    expect(tracks(frame)).toEqual([280, 0])
+    expect(frame.dataset.rightbarFullscreen).toBeUndefined()
+    expect(frame.dataset.rightbarInstant).toBe('true')
+    expect(frame.querySelector('[data-side="rightbar"]')).toBeNull()
+    act(() => { instance.actions.closeRightbar() })
+    expect(frame.dataset.rightbarInstant).toBe('true')
+  })
+
+  it('marks restoration instant without suppressing the following normal close', () => {
+    const { frame, instance } = mountFrame()
+    act(() => { instance.actions.openRightbar(true, true) })
+    act(() => { instance.actions.openRightbar(true, false) })
+    expect(tracks(frame)).toEqual([280, 864])
+    expect(frame.dataset.rightbarFullscreen).toBeUndefined()
+    expect(frame.dataset.rightbarInstant).toBe('true')
+    act(() => { instance.actions.closeRightbar() })
+    expect(tracks(frame)).toEqual([280, 0])
+    expect(frame.dataset.rightbarFullscreen).toBeUndefined()
+    expect(frame.dataset.rightbarInstant).toBeUndefined()
+  })
+
+  it.each(['setSidebar', 'toggleSidebar', 'setRightbar', 'viewport', 'open'] as const)('reenables normal transitions after %s', (action) => {
+    const { frame, instance } = mountFrame()
+    act(() => { instance.actions.openRightbar(true, true); instance.actions.closeRightbar() })
+    expect(frame.dataset.rightbarInstant).toBe('true')
+    act(() => {
+      if (action === 'viewport') resize(1800)
+      else if (action === 'open') instance.actions.openRightbar(true, false)
+      else if (action === 'toggleSidebar') instance.actions.toggleSidebar()
+      else instance.actions[action](350)
+    })
+    expect(frame.dataset.rightbarInstant).toBeUndefined()
+    expect(frame.dataset.rightbarFullscreen).toBeUndefined()
+  })
+
+  it('keeps fullscreen suppression independent from resetting the instant marker', () => {
+    const { frame, instance } = mountFrame()
+    act(() => { instance.actions.openRightbar(true, true) })
+    act(() => { instance.actions.setSidebar(350) })
+    expect(frame.dataset.rightbarInstant).toBeUndefined()
+    expect(frame.dataset.rightbarFullscreen).toBe('true')
+  })
+
   it('preserves normal tracks through fullscreen and hides the outer resize handle', () => {
     const { frame, instance, rightOwner } = mountFrame()
     act(() => { instance.actions.openRightbar(true, false) })

+ 70 - 0
packages/client/ui-layout/tests/layout-store.client.spec.ts

@@ -17,6 +17,7 @@ describe('createLayoutStore', () => {
       rightbarShown: false,
       rightbarTrack: false,
       rightbarFullscreen: false,
+      rightbarInstant: false,
     })
   })
 
@@ -154,3 +155,72 @@ describe('right panel', () => {
     expect(store.getSnapshot()).toMatchObject({ sidebar: 420, rightbarShown: false, rightbarTrack: false })
   })
 })
+
+describe('right panel instant geometry', () => {
+  it.each([true, false])('closes fullscreen with track=%s in one instant update and retains repeated close reports', (track) => {
+    const { store, actions } = createLayoutStore().create()
+    actions.openRightbar(track, true)
+    actions.closeRightbar()
+    expect(store.getSnapshot()).toMatchObject({
+      rightbarShown: false, rightbarTrack: false, rightbarFullscreen: false, rightbarInstant: true,
+    })
+    const closed = store.getSnapshot()
+    actions.closeRightbar()
+    expect(store.getSnapshot()).toBe(closed)
+  })
+
+  it('restores the normal track instantly, retaining the marker on an identical report', () => {
+    const { store, actions } = createLayoutStore().create()
+    actions.openRightbar(true, true)
+    actions.openRightbar(true, false)
+    expect(store.getSnapshot()).toMatchObject({
+      rightbarShown: true, rightbarTrack: true, rightbarFullscreen: false, rightbarInstant: true,
+    })
+    const restored = store.getSnapshot()
+    actions.openRightbar(true, false)
+    expect(store.getSnapshot()).toBe(restored)
+    actions.openRightbar(false, false)
+    expect(store.getSnapshot().rightbarInstant).toBe(false)
+  })
+
+  it('allows a normal close to animate, including after restoring from fullscreen', () => {
+    const { store, actions } = createLayoutStore().create()
+    actions.openRightbar(true, false)
+    actions.closeRightbar()
+    expect(store.getSnapshot().rightbarInstant).toBe(false)
+    actions.openRightbar(true, true)
+    actions.openRightbar(true, false)
+    expect(store.getSnapshot().rightbarInstant).toBe(true)
+    actions.closeRightbar()
+    expect(store.getSnapshot()).toMatchObject({ rightbarTrack: false, rightbarInstant: false })
+  })
+
+  it.each(['setSidebar', 'toggleSidebar', 'setRightbar', 'setViewportWidth'] as const)('clears instant geometry on %s', (action) => {
+    const { store, actions } = createLayoutStore().create()
+    actions.openRightbar(true, true)
+    actions.closeRightbar()
+    expect(store.getSnapshot().rightbarInstant).toBe(true)
+    if (action === 'toggleSidebar') actions.toggleSidebar()
+    else actions[action](action === 'setViewportWidth' ? 1800 : 350)
+    expect(store.getSnapshot().rightbarInstant).toBe(false)
+    actions.closeRightbar()
+    expect(store.getSnapshot().rightbarInstant).toBe(false)
+  })
+
+  it('does not let an unchanged frame measurement reset the fullscreen-exit marker', () => {
+    const { store, actions } = createLayoutStore().create()
+    actions.openRightbar(true, true)
+    actions.closeRightbar()
+    const closed = store.getSnapshot()
+    actions.setViewportWidth(closed.viewportWidth)
+    expect(store.getSnapshot()).toBe(closed)
+  })
+
+  it.each([true, false])('clears the exit marker on a fresh opening with fullscreen=%s', (fullscreen) => {
+    const { store, actions } = createLayoutStore().create()
+    actions.openRightbar(true, true)
+    actions.closeRightbar()
+    actions.openRightbar(true, fullscreen)
+    expect(store.getSnapshot()).toMatchObject({ rightbarShown: true, rightbarFullscreen: fullscreen, rightbarInstant: false })
+  })
+})

+ 2 - 2
packages/client/ui-sidebar-right/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-sidebar-right/README.md
-README.md: fff53260dfc1142cfa6eea0adaa3c25c14330d85
-README.zh.md: f5fcb8fe306d847c05d2c5333cc1322129b1a774
+README.md: feb75c6184098f54961ca161b4dc5ca5d0c7fc78
+README.zh.md: 03c841dfdaac970e938cf934e7f09d151268be8b

+ 1 - 1
packages/client/ui-sidebar-right/README.md

@@ -36,7 +36,7 @@ The layout itself — the split tree, its operations, the drag gestures, the flo
 <a id="presentations"></a>
 ## Presentations
 
-Normal and fullscreen presentations share the same content tree, so switching does not remount tabs. The normal panel anchors to the right column; fullscreen covers the viewport while retaining the wide-screen columns underneath. Opening below 768px uses fullscreen automatically; leaving fullscreen on a narrow viewport closes the panel, and widening does not reopen a closed panel. A fullscreen opening keeps the underlying columns unchanged until its slide finishes, then prepares the normal track without a column transition.
+Normal and fullscreen presentations share the same content tree, so switching does not remount tabs. The normal panel anchors to the right column; fullscreen covers the viewport while retaining the wide-screen columns underneath. Opening below 768px uses fullscreen automatically; leaving fullscreen on a narrow viewport closes the panel, and widening does not reopen a closed panel. A fullscreen opening keeps the underlying columns unchanged until its slide finishes, then prepares the normal track without a column transition. Before a fullscreen panel retreats, closing prepares a full-width conversation and restoring prepares the normal right track; the background does not animate during the retreat.
 
 | Mode | The track | The panel |
 |---|---|---|

+ 1 - 1
packages/client/ui-sidebar-right/README.zh.md

@@ -36,7 +36,7 @@ kind: "package-reference"
 <a id="presentations"></a>
 ## 呈现形态
 
-普通与全屏共用同一棵面板内容树,切换不会重挂载Tab。普通面板贴靠右栏;全屏面板覆盖窗口并保留宽屏底层列宽。窗口低于768px时打开右栏自动全屏;窄屏退出全屏会收起右栏,变宽不重新打开已关闭的右栏。 全屏打开时,底层列宽保持不变,直到滑入结束后才无过渡地准备普通轨道。
+普通与全屏共用同一棵面板内容树,切换不会重挂载Tab。普通面板贴靠右栏;全屏面板覆盖窗口并保留宽屏底层列宽。窗口低于768px时打开右栏自动全屏;窄屏退出全屏会收起右栏,变宽不重新打开已关闭的右栏。 全屏打开时,底层列宽保持不变,直到滑入结束后才无过渡地准备普通轨道。 全屏面板退场前,关闭先准备全宽会话区,恢复先准备普通右轨道;退场期间底层不播放宽度动画。
 
 | 形态 | 轨道 | 面板 |
 |---|---|---|

+ 11 - 0
snapshots/web/details-session-lifecycle/sidebar.expected.md

@@ -13,6 +13,7 @@
     644,
     756
   ],
+  "columnTransition": "grid-template-columns",
   "expanded": true,
   "mode": "push",
   "panelContentWidth": 756,
@@ -60,6 +61,7 @@
     644,
     756
   ],
+  "columnTransition": "none",
   "expanded": true,
   "mode": "fullscreen",
   "panelContentWidth": 1680,
@@ -107,6 +109,7 @@
     1400,
     0
   ],
+  "columnTransition": "none",
   "expanded": false,
   "mode": "fullscreen",
   "panelContentWidth": 0,
@@ -154,6 +157,7 @@
     1400,
     0
   ],
+  "columnTransition": "grid-template-columns",
   "expanded": false,
   "mode": "push",
   "panelContentWidth": 0,
@@ -190,6 +194,7 @@
     644,
     756
   ],
+  "columnTransition": "none",
   "expanded": true,
   "mode": "fullscreen",
   "panelContentWidth": 1680,
@@ -237,6 +242,7 @@
     644,
     756
   ],
+  "columnTransition": "grid-template-columns",
   "expanded": true,
   "mode": "push",
   "panelContentWidth": 756,
@@ -273,6 +279,7 @@
     644,
     756
   ],
+  "columnTransition": "grid-template-columns",
   "expanded": true,
   "mode": "push",
   "panelContentWidth": 756,
@@ -320,6 +327,7 @@
     604,
     0
   ],
+  "columnTransition": "grid-template-columns",
   "expanded": false,
   "mode": "push",
   "panelContentWidth": 0,
@@ -367,6 +375,7 @@
     1260,
     0
   ],
+  "columnTransition": "grid-template-columns",
   "expanded": false,
   "mode": "push",
   "panelContentWidth": 0,
@@ -414,6 +423,7 @@
     711,
     0
   ],
+  "columnTransition": "none",
   "expanded": true,
   "mode": "fullscreen",
   "panelContentWidth": 767,
@@ -461,6 +471,7 @@
     1260,
     0
   ],
+  "columnTransition": "grid-template-columns",
   "expanded": false,
   "mode": "push",
   "panelContentWidth": 0,