fix(web): close details when current session changes
This commit is contained in:
@@ -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: 26e909b96412985792eeae72d51a2ab2a315c943
|
||||
README.zh.md: 2e5799fd32c41328f8ca8b9e1a439fbccb3cdba2
|
||||
README.md: 836100066039e3695e314a4a4bbfaba8fb20c652
|
||||
README.zh.md: ffef7511b6cfdc3109203be86199766073bf5efd
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar is fixed-width (only details shrinks, then auto-closes); a closed sidebar retains a 56px control rail while details closes to zero width. The package also seats the theme presenter: it consumes resolved `ctx.theme` snapshots and projects them onto the document (`html { color-scheme }` for native UA chrome, `body[data-ds-dark-theme]` from the active color scheme, plus the theme's alias tokens as inline variables on body).
|
||||
|
||||
AppFrame reads the runtime Session projection: `baselinesReady` selects loading, a page-local `SessionListState.intent` selects the empty composer, and a connected Session renders through `SessionProvider`. The conversation and empty-state owner shares are empty; each registrant obtains business data from standard hooks and actions from its own inject face. The sidebar owner share contains only `collapsed` and `width`; navigation actions belong to sidebar's own injected service face.
|
||||
AppFrame reads the runtime Session projection: `baselinesReady` selects loading, a page-local `SessionListState.intent` selects the empty composer, and a connected Session renders through `SessionProvider`. The first ready active Session may restore an open details width across reload; New Session and every later current-Session change close details before paint, including selection invalidation after deletion. The conversation and empty-state owner shares are empty; each registrant obtains business data from standard hooks and actions from its own inject face. The sidebar owner share contains only `collapsed` and `width`; navigation actions belong to sidebar's own injected service face.
|
||||
|
||||
The `/client` export surface is the plugin body (`apply`/`inject`), `LayoutService`, and the four owner-share interfaces. AppFrame, the panel store, and the concession solver remain package-internal; tests import internals through `/src`.
|
||||
|
||||
@@ -18,6 +18,6 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Details open/width state is global** — it does not follow the session (arbitrated for P-I); the per-session keyed upgrade slot is reserved.
|
||||
- **Details width is global, not retained per Session** — changing or losing its active Session closes the panel and forgets a dragged width; returning to that Session does not reopen it.
|
||||
- **Concession-chain auto-close derives a zero width without touching the persisted open flag** — the panel restores itself when the window widens; consumers must not read `details.open` as the rendered truth.
|
||||
- **Scroll anchoring during squeeze reflow is not implemented** — deferred with the virtualized-list project.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
外壳插件:三栏 AppFrame(拖动手柄与让步链)加 `ctx.layout` 面板几何服务;它注册到运行时拥有的 `root` slot,并声明 `sidebar`、`conversation`、`details` 和 `conversation.empty`。侧边栏宽度固定(只会收缩详情栏,然后将其自动关闭);关闭的侧边栏仍保留 56px 控制轨道,详情栏则关闭到零宽度。该包还提供主题呈现器:它消费解析后的 `ctx.theme` 快照,并将其投影到 document(用 `html { color-scheme }` 驱动原生 UA 控件,依据当前配色方案设置 `body[data-ds-dark-theme]`,并将主题的别名 token 设为 body 上的内联变量)。
|
||||
|
||||
AppFrame 读取运行时 Session 投影:`baselinesReady` 选择加载状态,页面局部的 `SessionListState.intent` 选择空白编辑器,已连接 Session 则通过 `SessionProvider` 渲染。会话及空状态的 owner share 为空;每个注册方通过标准 hook 获取业务数据,并从自身的 inject 表层获取操作。侧边栏 owner share 只包含 `collapsed` 和 `width`;导航操作属于侧边栏自身注入的服务表层。
|
||||
AppFrame 读取运行时 Session 投影:`baselinesReady` 选择加载状态,页面局部的 `SessionListState.intent` 选择空白编辑器,已连接 Session 则通过 `SessionProvider` 渲染。首次就绪的活动会话可在重新加载后恢复已打开的详情宽度;New Session 以及后续每次当前会话变化,都会在绘制前关闭详情栏,包括删除后选中状态失效的情况。会话及空状态的 owner share 为空;每个注册方通过标准 hook 获取业务数据,并从自身的 inject 表层获取操作。侧边栏 owner share 只包含 `collapsed` 和 `width`;导航操作属于侧边栏自身注入的服务表层。
|
||||
|
||||
`/client` 导出表层包含插件主体(`apply`/`inject`)、`LayoutService` 和四个 owner-share 接口。AppFrame、面板 store 与让步求解器仍属于包内部;测试通过 `/src` 导入内部实现。
|
||||
|
||||
@@ -18,6 +18,6 @@ AppFrame 读取运行时 Session 投影:`baselinesReady` 选择加载状态,
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **详情栏打开/宽度状态是全局状态**:它不会随会话变化(P-I 已裁定);为逐会话键控升级预留了 slot。
|
||||
- **详情宽度是全局状态,不按会话保留**:切换或失去当前活动会话会关闭详情栏,并忘记拖动后的宽度;返回该会话时不会重新打开详情栏。
|
||||
- **让步链自动关闭通过推导零宽度实现,不会改动持久化的打开标志**:窗口变宽时面板会自行恢复;消费方禁止把 `details.open` 当作实际渲染状态。
|
||||
- **挤压重排期间尚未实现滚动锚定**:与虚拟化列表项目一并暂缓。
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* through the three framework shares — zero cordis or framework imports,
|
||||
* zero self-made hooks.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { computeColumns } from './columns.ts'
|
||||
@@ -86,13 +86,39 @@ function DragHandle(props: { side: 'sidebar' | 'details'; left: number; onStart:
|
||||
/** The three-column frame (see module doc). */
|
||||
export function AppFrame({
|
||||
useStore,
|
||||
useSessions,
|
||||
actions,
|
||||
renderSlot,
|
||||
}: AppFrameProps) {
|
||||
const panels = useStore(s => s)
|
||||
const sessionsPhase = useSessions(s => s.phase)
|
||||
const detailsSession = useSessions((s) => {
|
||||
const current = s.current
|
||||
if (current === undefined) return undefined
|
||||
const session = s.byId[current]
|
||||
return session !== undefined && !session.blank ? current : undefined
|
||||
})
|
||||
const frameRef = useRef<HTMLDivElement | null>(null)
|
||||
const [viewport, setViewport] = useState(() => window.innerWidth)
|
||||
|
||||
// The first ready active Session is baseline restoration, so its persisted
|
||||
// panel may remain open. New Session has no inspectable selection, and any
|
||||
// later details owner change closes the root-scoped column before paint.
|
||||
const detailsBaselineReady = useRef(false)
|
||||
const previousDetailsSession = useRef(detailsSession)
|
||||
useLayoutEffect(() => {
|
||||
if (sessionsPhase !== 'ready') return
|
||||
if (!detailsBaselineReady.current) {
|
||||
detailsBaselineReady.current = true
|
||||
previousDetailsSession.current = detailsSession
|
||||
if (detailsSession === undefined) actions.closeDetails()
|
||||
return
|
||||
}
|
||||
if (previousDetailsSession.current === detailsSession) return
|
||||
previousDetailsSession.current = detailsSession
|
||||
actions.closeDetails()
|
||||
}, [actions, detailsSession, sessionsPhase])
|
||||
|
||||
// Track the frame's own box (not the window): rAF-throttled ResizeObserver.
|
||||
useEffect(() => {
|
||||
const el = frameRef.current
|
||||
|
||||
@@ -21,8 +21,10 @@ import type {
|
||||
SessionId, SessionListState, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
// Session-mode switch for the SessionProvider stub prop.
|
||||
const sessionMode = { current: true }
|
||||
// Session selection controls for the SessionProvider and useSessions stubs.
|
||||
const selectedSession = { current: 's-test' as SessionId | undefined }
|
||||
const selectedSessionBlank = { current: false }
|
||||
const sessionsPhase = { current: 'ready' as SessionListState['phase'] }
|
||||
const baselinesReady = { current: true }
|
||||
|
||||
// Render-prop contract stub fed through the standard seat prop (the renderer
|
||||
@@ -31,7 +33,7 @@ const baselinesReady = { current: true }
|
||||
// shape. Typed as the seat's own component type so the branded sessionId
|
||||
// parameter stays contract-checked.
|
||||
const SessionProviderStub: AppFrameProps['SessionProvider'] = ({ children, empty }) =>
|
||||
sessionMode.current ? <>{children('s-test' as Parameters<typeof children>[0])}</> : <>{empty?.() ?? null}</>
|
||||
selectedSession.current === undefined ? <>{empty?.() ?? null}</> : <>{children(selectedSession.current)}</>
|
||||
|
||||
|
||||
/** Observer stub: captures the callback so tests can fire resizes manually. */
|
||||
@@ -64,32 +66,35 @@ function mountFrame() {
|
||||
if (key === 'conversation.empty') return <div data-testid="empty-content" />
|
||||
return <div data-testid="other-content" />
|
||||
}) as AppFrameProps['renderSlot']
|
||||
const sessionId = 's-test' as SessionId
|
||||
const sessionState = {
|
||||
ids: sessionMode.current ? [sessionId] : [],
|
||||
byId: sessionMode.current
|
||||
? { [sessionId]: { id: sessionId, displayTitle: 'Test', running: false, blank: false, updatedAt: 1 } }
|
||||
: {},
|
||||
current: sessionMode.current ? sessionId : undefined,
|
||||
phase: 'ready',
|
||||
} as SessionListState
|
||||
const useSessions = ((sel: (s: SessionListState) => unknown) => sel(sessionState)) as never
|
||||
const useSessions = ((sel: (s: SessionListState) => unknown) => {
|
||||
const current = selectedSession.current
|
||||
const sessionState = {
|
||||
ids: current === undefined ? [] : [current],
|
||||
byId: current === undefined
|
||||
? {}
|
||||
: { [current]: { id: current, displayTitle: 'Test', running: false, blank: selectedSessionBlank.current, updatedAt: 1 } },
|
||||
current,
|
||||
phase: sessionsPhase.current,
|
||||
} as SessionListState
|
||||
return sel(sessionState)
|
||||
}) as never
|
||||
const workspaceState: WorkspaceListState = {
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: baselinesReady.current, recentWorkspaceId: undefined,
|
||||
}
|
||||
const utils = render(
|
||||
const element = () => (
|
||||
<AppFrame
|
||||
useStore={hookOf(instance) as never}
|
||||
useStore={hookOf(instance)}
|
||||
actions={instance.actions}
|
||||
renderSlot={renderSlot}
|
||||
useSessions={useSessions}
|
||||
useWorkspaces={((sel: (s: WorkspaceListState) => unknown) => sel(workspaceState)) as never}
|
||||
SessionProvider={SessionProviderStub}
|
||||
/>,
|
||||
/>
|
||||
)
|
||||
const utils = render(element())
|
||||
const frame = utils.container.firstElementChild as HTMLElement
|
||||
return { instance, frame, slotCalls, ...utils }
|
||||
return { instance, frame, slotCalls, rerenderFrame: () => { utils.rerender(element()) }, ...utils }
|
||||
}
|
||||
|
||||
function tracks(frame: HTMLElement): number[] {
|
||||
@@ -109,7 +114,9 @@ function drag(handle: Element, fromX: number, toX: number): void {
|
||||
|
||||
beforeEach(() => {
|
||||
frameWidth = 1920
|
||||
sessionMode.current = true
|
||||
selectedSession.current = 's-test' as SessionId
|
||||
selectedSessionBlank.current = false
|
||||
sessionsPhase.current = 'ready'
|
||||
baselinesReady.current = true
|
||||
localStorage.clear() // the layout store persists; instances must not bleed across tests
|
||||
vi.useFakeTimers()
|
||||
@@ -154,7 +161,7 @@ describe('AppFrame', () => {
|
||||
it('keeps the conversation slot mounted while no session is current', () => {
|
||||
// No current session: the session-maybe conversation shell owns the New
|
||||
// Session view itself — the center column renders it unconditionally.
|
||||
sessionMode.current = false
|
||||
selectedSession.current = undefined
|
||||
const { slotCalls, getByTestId } = mountFrame()
|
||||
expect(getByTestId('center-content')).toBeTruthy()
|
||||
expect(slotCalls.map(c => c.key)).toContain('conversation')
|
||||
@@ -169,6 +176,46 @@ describe('AppFrame', () => {
|
||||
expect(slotCalls.map(c => c.key)).toContain('details')
|
||||
})
|
||||
|
||||
it('closes details when the ready current Session changes, including New Session, and keeps it closed on return', () => {
|
||||
const { frame, instance, rerenderFrame } = mountFrame()
|
||||
expect(tracks(frame)).toEqual([280, 360])
|
||||
|
||||
selectedSession.current = 's-next' as SessionId
|
||||
act(() => { rerenderFrame() })
|
||||
expect(tracks(frame)).toEqual([280, 0])
|
||||
|
||||
act(() => { instance.actions.openDetails() })
|
||||
selectedSession.current = 's-blank' as SessionId
|
||||
selectedSessionBlank.current = true
|
||||
act(() => { rerenderFrame() })
|
||||
expect(tracks(frame)).toEqual([280, 0])
|
||||
|
||||
selectedSession.current = 's-test' as SessionId
|
||||
selectedSessionBlank.current = false
|
||||
act(() => { rerenderFrame() })
|
||||
expect(tracks(frame)).toEqual([280, 0])
|
||||
|
||||
act(() => { instance.actions.openDetails() })
|
||||
selectedSession.current = undefined
|
||||
act(() => { rerenderFrame() })
|
||||
expect(tracks(frame)).toEqual([280, 0])
|
||||
})
|
||||
|
||||
it('preserves open details across active-session baseline restore but closes it for an initial New Session view', () => {
|
||||
sessionsPhase.current = 'pending'
|
||||
const active = mountFrame()
|
||||
expect(tracks(active.frame)).toEqual([280, 360])
|
||||
sessionsPhase.current = 'ready'
|
||||
act(() => { active.rerenderFrame() })
|
||||
expect(tracks(active.frame)).toEqual([280, 360])
|
||||
active.unmount()
|
||||
|
||||
selectedSession.current = 's-blank' as SessionId
|
||||
selectedSessionBlank.current = true
|
||||
const blank = mountFrame()
|
||||
expect(tracks(blank.frame)).toEqual([280, 0])
|
||||
})
|
||||
|
||||
it('sidebar slot receives live concession output as owner props', () => {
|
||||
const { slotCalls } = mountFrame()
|
||||
expect(slotCalls.find(c => c.key === 'sidebar')!.props).toEqual({ collapsed: false, width: 280 })
|
||||
|
||||
Reference in New Issue
Block a user