1. 统一会话列宽度轴:新增 --dsh-chat-content-width(748px),输入框、todo、goal、queue、approval、plan review、ask question 等容器的宽度与边距全部由该变量推导,消除各面板之间的像素漂移,窄视口下的边缘留白也保持一致。 2. 输入框自适应与细节:控制行改为容器查询(460px 阈值以下权限选择器只显示图标+下拉,隐藏文字);卡片圆角 20→22、行内边距调整并整体下移 2px(发送按钮除外);permission/model 触发器统一 24px 圆角;Plan 与 Read Only 间距 +8。 3. 修复浮层菜单溢出:slash 菜单与命令弹层钳制到输入卡片宽度,超长行以省略号截断;Tooltip 增加 12px 视口边缘安全距离。 4. 增加与替换图标:Add provider 改用输入框同款加号图标(IconPlusOutline16),统一图标尺寸与字号。 5. 统一 Settings → Models 组件:补齐按钮 hover 态、select 下拉箭头不再贴边、标题区与 provider 卡片间距 +12。 6. 侧边栏交互:add workspace / group by / create session / 收起侧边栏四个图标按钮增加 500ms 延迟 tooltip(前两个向下弹出);展开态的 New Session 不再重复显示 tooltip;侧边栏窄屏自适应收起逻辑微调。 7. 其他:hero 区 workspace 徽章右移对齐;附带 Agent Note(中英双语)记录共享宽度轴与容器查询的设计取舍。
199 lines
8.6 KiB
TypeScript
199 lines
8.6 KiB
TypeScript
/**
|
|
* Three-column shell frame, registered into the built-in 'root' slot (the web
|
|
* shell renders only 'root'). Owns the grid tracks (sidebar | center |
|
|
* details), the drag handles (pointer capture + rAF throttle), the concession
|
|
* chain (columns.ts), and the child-slot render decisions: the sidebar slot
|
|
* renders HERE with live parameters from the concession solve, and the
|
|
* session-aware occupants render in fixed column positions; strict entries
|
|
* gate themselves on current-session availability while session-maybe
|
|
* entries retain identity. Pure component: everything arrives
|
|
* through the three framework shares — zero cordis or framework imports,
|
|
* zero self-made hooks.
|
|
*/
|
|
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, SIDEBAR_AUTO_COLLAPSE, SIDEBAR_DEFAULT } from './columns.ts'
|
|
import type { createLayoutStore } from './stores.ts'
|
|
import css from './AppFrame.module.css'
|
|
|
|
/** Full composed props: runtime share + child-slot render share + store share. */
|
|
export type AppFrameProps =
|
|
& PropsRuntime<'root'>
|
|
& PropsRenderSlots<'sidebar' | 'conversation' | 'details'>
|
|
& PropsStore<ReturnType<typeof createLayoutStore>>
|
|
|
|
/** Center column grid item (session-body building block). */
|
|
function CenterColumn(props: { children?: ReactNode }) {
|
|
return <div className={css.centerCol}>{props.children}</div>
|
|
}
|
|
|
|
/** Details column grid item; width 0 keeps the subtree mounted (never unmount on close). */
|
|
function DetailsColumn(props: { children?: ReactNode }) {
|
|
return <div className={css.detailsCol}>{props.children}</div>
|
|
}
|
|
|
|
/**
|
|
* One drag handle: pointer capture, rAF-throttled dx reports against the drag-start origin.
|
|
* `side` keys the hover-reveal CSS to the owning column.
|
|
*/
|
|
function DragHandle(props: { side: 'sidebar' | 'details'; left: number; onStart: () => void; onDrag: (dx: number) => void; onEnd: () => void }) {
|
|
const [dragging, setDragging] = useState(false)
|
|
const origin = useRef(0)
|
|
const latest = useRef(0)
|
|
const frame = useRef<number | null>(null)
|
|
const callbacks = useRef({ onStart: props.onStart, onDrag: props.onDrag, onEnd: props.onEnd })
|
|
callbacks.current = { onStart: props.onStart, onDrag: props.onDrag, onEnd: props.onEnd }
|
|
|
|
const onPointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
|
e.preventDefault()
|
|
e.currentTarget.setPointerCapture(e.pointerId)
|
|
origin.current = e.clientX
|
|
latest.current = e.clientX
|
|
callbacks.current.onStart()
|
|
setDragging(true)
|
|
}, [])
|
|
const onPointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
|
if (!e.currentTarget.hasPointerCapture(e.pointerId)) return
|
|
latest.current = e.clientX
|
|
frame.current ??= requestAnimationFrame(() => {
|
|
frame.current = null
|
|
callbacks.current.onDrag(latest.current - origin.current)
|
|
})
|
|
}, [])
|
|
const onPointerUp = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
|
if (!e.currentTarget.hasPointerCapture(e.pointerId)) return
|
|
e.currentTarget.releasePointerCapture(e.pointerId)
|
|
if (frame.current !== null) { cancelAnimationFrame(frame.current); frame.current = null }
|
|
callbacks.current.onDrag(latest.current - origin.current)
|
|
setDragging(false)
|
|
callbacks.current.onEnd()
|
|
}, [])
|
|
|
|
return (
|
|
<div
|
|
className={css.handle}
|
|
style={{ left: props.left }}
|
|
data-side={props.side}
|
|
data-dragging={dragging || undefined}
|
|
onPointerDown={onPointerDown}
|
|
onPointerMove={onPointerMove}
|
|
onPointerUp={onPointerUp}
|
|
/>
|
|
)
|
|
}
|
|
|
|
/** The three-column frame (see module doc). */
|
|
export function AppFrame({
|
|
useStore,
|
|
useSessions,
|
|
actions,
|
|
renderSlot,
|
|
}: AppFrameProps) {
|
|
const panels = useStore(s => s)
|
|
const detailsSession = useSessions((s) => {
|
|
const current = s.current
|
|
return current !== undefined && s.byId[current]?.blank === false ? current : undefined
|
|
})
|
|
const frameRef = useRef<HTMLDivElement | null>(null)
|
|
const [viewport, setViewport] = useState(() => window.innerWidth)
|
|
|
|
const lastSession = useRef(detailsSession)
|
|
useLayoutEffect(() => {
|
|
if (detailsSession === undefined) return
|
|
if (lastSession.current !== undefined && lastSession.current !== detailsSession) {
|
|
actions.closeDetails()
|
|
}
|
|
lastSession.current = detailsSession
|
|
}, [actions, detailsSession])
|
|
|
|
// Track the frame's own box (not the window): rAF-throttled ResizeObserver.
|
|
useEffect(() => {
|
|
const el = frameRef.current
|
|
/* v8 ignore next -- the ref is always attached by effect time: the frame div renders unconditionally. */
|
|
if (el === null) return
|
|
let raf: number | null = null
|
|
const observer = new ResizeObserver(() => {
|
|
raf ??= requestAnimationFrame(() => {
|
|
raf = null
|
|
const width = el.getBoundingClientRect().width
|
|
if (width > 0) setViewport(width)
|
|
})
|
|
})
|
|
observer.observe(el)
|
|
return () => {
|
|
observer.disconnect()
|
|
if (raf !== null) cancelAnimationFrame(raf)
|
|
}
|
|
}, [])
|
|
|
|
// Narrow viewports auto-collapse the sidebar; the store mirror keeps
|
|
// toggleSidebar's semantics right (narrow toggles flip the manual
|
|
// re-expand override, stores.ts). Collapsed is decided here, so the
|
|
// solver stays breakpoint-free: a narrow re-expand passes the preference
|
|
// (or the default when the wide preference is closed) and the center
|
|
// absorbs the squeeze.
|
|
const narrow = viewport < SIDEBAR_AUTO_COLLAPSE
|
|
useEffect(() => { actions.setNarrow(narrow) }, [actions, narrow])
|
|
const sidebarCollapsed = narrow ? !panels.narrowExpanded : panels.sidebar === 0
|
|
const sidebarPreference = sidebarCollapsed
|
|
? 0
|
|
: panels.sidebar === 0 ? SIDEBAR_DEFAULT : panels.sidebar
|
|
const cols = computeColumns(viewport, sidebarPreference, detailsSession === undefined ? 0 : panels.details)
|
|
const colsRef = useRef(cols)
|
|
colsRef.current = cols
|
|
|
|
// The drag base is the rendered width captured at drag start (grabbing a
|
|
// concession-clamped panel must not jump back to the stored preference);
|
|
// it stays frozen for the whole gesture so dx deltas do not compound.
|
|
const sidebarBase = useRef(0)
|
|
const detailsBase = useRef(0)
|
|
// Track-level transitions pause for the whole gesture: eased tracks would
|
|
// detach the column edge from the pointer (AppFrame.module.css).
|
|
const [dragging, setDragging] = useState(false)
|
|
const onDragEnd = useCallback(() => { setDragging(false) }, [])
|
|
const onSidebarStart = useCallback(() => { sidebarBase.current = colsRef.current.sidebar; setDragging(true) }, [])
|
|
const onDetailsStart = useCallback(() => { detailsBase.current = colsRef.current.details; setDragging(true) }, [])
|
|
const onSidebarDrag = useCallback((dx: number) => {
|
|
actions.setSidebar(sidebarBase.current + dx)
|
|
}, [actions])
|
|
const onDetailsDrag = useCallback((dx: number) => {
|
|
actions.setDetails(detailsBase.current - dx)
|
|
}, [actions])
|
|
|
|
return (
|
|
<div
|
|
ref={frameRef}
|
|
className={css.frame}
|
|
style={{ gridTemplateColumns: `${cols.sidebar}px minmax(0, 1fr) ${cols.details}px` }}
|
|
data-sidebar-collapsed={sidebarCollapsed || undefined}
|
|
data-details-collapsed={cols.details === 0 || undefined}
|
|
data-dragging={dragging || undefined}
|
|
>
|
|
<div className={css.sidebarCol}>
|
|
{/* Render-site slot call with live concession output: a closed
|
|
sidebar keeps the mounted slot at the compact-rail width, and the
|
|
component sees its rendered state as owner params decided here
|
|
(collapsed follows the resolved rail, so a derived auto-collapse
|
|
renders the rail UI too). */}
|
|
{renderSlot('sidebar', {
|
|
collapsed: sidebarCollapsed,
|
|
width: cols.sidebar,
|
|
})}
|
|
</div>
|
|
<>
|
|
{/* Both column occupants stay at fixed tree positions from first
|
|
paint — no loading gate (user ruling: the bare status line looked
|
|
worse than the shell's own pending rendering). The conversation
|
|
is session-maybe; the strict details entry naturally renders
|
|
empty while no session is current. */}
|
|
<CenterColumn>{renderSlot('conversation', {})}</CenterColumn>
|
|
<DetailsColumn>{renderSlot('details', {})}</DetailsColumn>
|
|
</>
|
|
{/* The collapsed rail is fixed-width: no resize handle while closed. */}
|
|
{!sidebarCollapsed && <DragHandle side="sidebar" left={cols.sidebar} onStart={onSidebarStart} onDrag={onSidebarDrag} onEnd={onDragEnd} />}
|
|
{cols.details > 0 && <DragHandle side="details" left={viewport - cols.details} onStart={onDetailsStart} onDrag={onDetailsDrag} onEnd={onDragEnd} />}
|
|
</div>
|
|
)
|
|
}
|