Merge remote-tracking branch 'github/master' into xtr/trajectory-timeline-click-focus

# Conflicts:
#	apps/web/tests/details-session-lifecycle.e2e.ts
#	packages/client/ui-layout/README.i18n.yaml
This commit is contained in:
_Kerman
2026-07-30 18:08:22 +08:00
43 changed files with 288 additions and 144 deletions

View File

@@ -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-conversation/README.md
README.md: 06fd6b963639390164582e70476b6a80f189bc61
README.zh.md: 41047d563f5541aaddc87bc7b448f678538ef91a
README.md: 855c42b3377e80b0d8f21a418da0a591782439e1
README.zh.md: 31cf2c7b5a9a0740c2be9079ce55d897d175a6d0

View File

@@ -16,7 +16,7 @@ A tool call declaring the `terminal` render intent renders its command output in
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).
The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1`above the queue rows — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: 10`between Goal and Queue — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks.

View File

@@ -16,7 +16,7 @@
审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位未实例化会话同样点亮镜像该阻塞状态其优先级高于运行中圆环直至问题解决。未决等待完全离开消息流问题ui-question与审批ApprovalPanel都经编辑器接管作答不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数key 缺席即隐藏 chipchip 打开 Menu 原语下拉kebab-case 预设名渲染为 Title Case 标签(与 `/permission` popup 的显示变换孪生),选中会经由输入栏注入的 `command` 回调提交 `/permission <preset>` 命令行。
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']``TodoRow` 占用 `'conversation.chat.toolview'``todo_write` key摘要该次调用「试图写入」的内容从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock``order: -1` 占用 `'conversation.input.dock'` 列表 slot位于队列行之上),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · <n> in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock包括这条计划条。
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']``TodoRow` 占用 `'conversation.chat.toolview'``todo_write` key摘要该次调用「试图写入」的内容从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock``order: 10` 占用 `'conversation.input.dock'` 列表 slot位于 Goal 和 Queue 之间),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · <n> in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock包括这条计划条。
逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store`stores.ts` `createChatStore`InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession``sessionId`、全局 `useSessions``useWorkspaces`,以及输入状态机的 `useInput``inputActions`store 表层与 inject factory 提供其余状态和回调。

View File

@@ -5,9 +5,11 @@
flex: none;
width: 100%;
max-width: 776px;
/* Eat InputBar's 6px top padding and tuck the panel 2px under the card;
the later composer sibling paints its surface and shadow over this edge. */
margin: 0 auto -10px;
/* Flex gap still applies after this item; subtract it together with the
design's overlap so the later composer paints over the queue edge. */
margin: 0 auto calc(
0px - var(--dsh-composer-stack-gap) - var(--dsh-queue-composer-overlap)
);
padding: 2px 12px;
}

View File

@@ -162,14 +162,14 @@ export const queueDockEntry = {
name: 'conversation-queue-dock',
inject: ['slots', 'conversation', 'sessions'],
/**
* Register the queue strip into the input dock (list entry, order 0).
* Register the queue strip as the terminal input-dock entry (order 20).
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/
apply(ctx: Context): void {
ctx.slots.register({
name: 'conversation.input.dock',
id: 'queue',
order: 0,
order: 20,
inject: (sessionId: SessionId): QueueDockInjected => {
const actx = ctx.sessions.scope(sessionId)
if (actx === undefined) throw new Error(`queue dock: session "${sessionId}" resolved no scope`)

View File

@@ -127,14 +127,15 @@
min-height: 0;
}
/* Composer stack: dock strips above the input card (design §6 MIX order).
The stack owns the vertical rhythm: one gap here, entries carry no outer
margins — an entry that renders null costs nothing, so spacing stays
correct for any dock combination. */
/* Composer context stack (Figma 9:937): standalone dock cards share one
rhythm; the terminal queue strip additionally tucks under the input card. */
.composerStack {
--dsh-composer-stack-gap: 6px;
--dsh-queue-composer-overlap: 5px;
display: flex;
flex-direction: column;
gap: 8px;
gap: var(--dsh-composer-stack-gap);
}
/* Common seat for the composer chain (fallback + elected overlay siblings). */

View File

@@ -1,9 +1,8 @@
/* Todo strip above the composer (figma 772:51905 / 772:52972 / 772:53419):
tip surface, 14px radius, status icons + secondary item labels. Column is
calc(100% - 88px) / max 752 (GoalBar's column), centered; the composer
stack owns the gap. */
/* Todo strip in the composer context stack (Figma 9:959): tip surface,
14px radius, status icons + secondary item labels. */
.root {
box-sizing: border-box;
flex: none;
overflow: hidden;
margin: 0 auto;
@@ -21,13 +20,11 @@
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
/* Compact scale (GoalBar reference): collapsed header totals the goal
strip's 38px (8+8 pad + 20 line + 2 border). */
.body {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px 14px;
padding: 9px 15px;
}
.header {
@@ -44,8 +41,8 @@
.title {
flex: none;
font-size: 13px;
line-height: 20px;
font-size: 14px;
line-height: 24px;
font-weight: 500;
color: var(--dsw-alias-label-primary);
}

View File

@@ -135,10 +135,10 @@ export const todoDockEntry = {
name: 'conversation-todo-dock',
inject: ['slots', 'conversation'],
/**
* Register the plan strip into the input dock (list entry, above the queue rows).
* Register the plan strip between the goal and queue entries (order 10).
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
*/
apply(ctx: Context): void {
ctx.slots.register({ name: 'conversation.input.dock', id: 'todo', order: -1 }, TodoDock)
ctx.slots.register({ name: 'conversation.input.dock', id: 'todo', order: 10 }, TodoDock)
},
}

View File

@@ -197,9 +197,14 @@ describe('QueueDock', () => {
expect(container.innerHTML).toBe('')
})
it('ships the session-scoped registrant plugin shape', () => {
it('registers as the terminal composer-context entry', () => {
expect(queueDockEntry.name).toBe('conversation-queue-dock')
expect(queueDockEntry.inject).toEqual(['slots', 'conversation', 'sessions'])
expect(typeof queueDockEntry.apply).toBe('function')
const register = vi.fn()
queueDockEntry.apply({ slots: { register } } as never)
expect(register).toHaveBeenCalledWith(
expect.objectContaining({ name: 'conversation.input.dock', id: 'queue', order: 20 }),
QueueDock,
)
})
})

View File

@@ -92,12 +92,12 @@ describe('TodoDock', () => {
expect(screen.queryByTestId('todo-panel')).toBeNull()
})
it('ships the registrant plugin shape (list entry above the queue rows)', () => {
it('registers between the goal and queue entries', () => {
expect(todoDockEntry.name).toBe('conversation-todo-dock')
expect(todoDockEntry.inject).toEqual(['slots', 'conversation'])
const register = vi.fn()
todoDockEntry.apply({ slots: { register } } as never)
expect(register).toHaveBeenCalledWith({ name: 'conversation.input.dock', id: 'todo', order: -1 }, TodoDock)
expect(register).toHaveBeenCalledWith({ name: 'conversation.input.dock', id: 'todo', order: 10 }, TodoDock)
})
})

View File

@@ -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-goal/README.md
README.md: fed4870f73277b22760417297d668853b8afb2db
README.zh.md: cc607edc856e04c6ee42cc8f596aa658679a02ca
README.md: 2c109ab1fbe0b566b8749a6af44ec5e0055fe3b2
README.zh.md: b81113c67566fd834b3ddb10931d4ecc630aa2f9

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Goal surface plugin, browser half: the `GoalBar` strip in the `conversation.input.dock` list (order 1, tucked against the composer). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no store, no refresh chain, and no event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear over the `goal.*` wire domain — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the settled RPC error inline (the RPC's compare-and-set is the staleness guard — there is no client fence). Goal creation stays on the `/goal` host command; loading, absent, and completed goals render nothing.
Goal surface plugin, browser half: the `GoalBar` strip is the first standalone card in the `conversation.input.dock` composer-context stack (order 0, before Todo and Queue). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no store, no refresh chain, and no event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear over the `goal.*` wire domain — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the settled RPC error inline (the RPC's compare-and-set is the staleness guard — there is no client fence). Goal creation stays on the `/goal` host command; loading, absent, and completed goals render nothing.
The `/client` export surface is the plugin body (`apply`/`inject`), the `GoalBar`/`GoalDock` components, and the injected verb face types.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
Goal 表面插件(浏览器半件):`conversation.input.dock` 列表中的 `GoalBar` 条带order 1紧贴 composer)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词edit / pause / resume / clear`goal.*` 协议域——active 的 goal 提供暂停动作paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref并把结算后的 RPC 错误内联呈现RPC 的 compare-and-set 即陈旧性防护——客户端没有任何栅栏。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成三种状态一律不渲染。
Goal 表面插件(浏览器半件):`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第一张独立卡片order 0位于 Todo 和 Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词edit / pause / resume / clear`goal.*` 协议域——active 的 goal 提供暂停动作paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref并把结算后的 RPC 错误内联呈现RPC 的 compare-and-set 即陈旧性防护——客户端没有任何栅栏。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成三种状态一律不渲染。
`/client` 出口面为插件本体(`apply`/`inject`)、`GoalBar`/`GoalDock` 组件与注入动词面类型。

View File

@@ -1,29 +1,24 @@
/* GoalBar: the goal strip docked above the composer card. The dock's 44px
side padding and the bar's 752px cap match the todo strip's column
(TodoPanel.module.css), 24px inside the composer card's edges. The
negative bottom margin cancels the composer stack's 8px gap and tucks the
bar's square bottom edge 2px under the composer card's top edge (the
card, later in DOM order, paints over it). Surface matches the todo
strip: tip fill, l1 border — no bottom edge where it disappears under the
card. All states share one fixed 38px height so switching between them
never resizes the strip. */
/* GoalBar: the first standalone card in the composer context stack (Figma
9:939). Its 752px column matches Todo and the Queue panel. */
.dock {
box-sizing: border-box;
width: 100%;
padding: 0 44px;
}
.bar {
box-sizing: border-box;
display: flex;
align-items: center;
gap: 6px;
box-sizing: border-box;
gap: 10px;
width: 100%;
max-width: 752px;
height: 38px;
margin: 0 auto -10px;
padding: 0 14px;
height: 36px;
margin: 0 auto;
padding: 4px 5px 4px 12px;
border: 1px solid var(--dsw-alias-border-l1);
border-bottom: none;
border-radius: 14px 14px 0 0;
border-radius: 14px;
background: var(--dsw-specific-tip);
}
@@ -37,8 +32,8 @@
flex: none;
font-size: 13px;
line-height: 20px;
font-weight: 600;
color: var(--dsw-alias-label-primary);
font-weight: 500;
color: var(--dsw-alias-label-primary-dimmed);
}
.objective {
@@ -47,7 +42,7 @@
overflow: hidden;
font-size: 13px;
line-height: 20px;
color: var(--dsw-alias-label-secondary);
color: var(--dsw-alias-label-primary-dimmed);
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -92,7 +87,7 @@
.actions {
display: flex;
align-items: center;
gap: 8px;
gap: 10px;
flex: none;
}
@@ -100,11 +95,11 @@
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: 6px;
border-radius: 999px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;

View File

@@ -59,7 +59,7 @@ export function apply(ctx: ClientContext): void {
scope.effect(() => scope.slots.register({
name: 'conversation.input.dock',
id: 'goal',
order: 1,
order: 0,
inject: (sessionId): GoalBarActions => ({
onEdit: async (objective) => {
const ref = refOf(sessionId)

View File

@@ -92,7 +92,7 @@ describe('ui-goal browser plugin', () => {
it('registers the GoalBar dock entry with the documented id and order', async () => {
const b = bench()
await b.fiber.await()
expect(b.entry()).toMatchObject({ id: 'goal', order: 1 })
expect(b.entry()).toMatchObject({ id: 'goal', order: 0 })
expect(b.entry()?.inject).toBeTypeOf('function')
})

View File

@@ -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: 250828764ecb2c620410fc59d84076f4117d7fc4
README.zh.md: 17c45794ff7ce7ef90e7a233abcef82321a87407
README.md: cb99023e6a9e3364c6f48190cf4a0cd71da2cbba
README.zh.md: 3681b4517670eb92d8f32be2ac62d5852ac745a3

View File

@@ -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 resize boundary is an invisible hit strip, while the details boundary retains its floating pill; only details shrinks during concession and 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 always mounts the conversation and details columns; a connected Session renders through `SessionProvider`. The transient layout store starts both panels at their default widths and never reads or writes `localStorage`. Hero and other unselected states derive a zero rendered details width without changing that stored preference. AppFrame retains the last non-blank Session id across those states: the first Session opens at the default width, returning to the same Session restores its unchanged width, and selecting a different Session closes details before paint. The conversation owner share is empty, while the sidebar owner share contains only `collapsed` and `width`; registrants obtain business data from standard hooks and actions from their own inject faces.
AppFrame always mounts the conversation and details columns; a connected Session renders through `SessionProvider`. The transient layout store starts the sidebar at its default width and details closed, and it never reads or writes `localStorage`. Hero and other unselected states also derive a zero rendered details width without changing that stored preference. AppFrame retains the last non-blank Session id across those states: the first Session remains closed, an explicit details action opens the contract default width, returning to the same Session restores its unchanged width, and selecting a different Session closes details before paint. The conversation owner share is empty, while the sidebar owner share contains only `collapsed` and `width`; registrants obtain business data from standard hooks and actions from their own inject faces.
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
- **Panel geometry is transient** — reload restores both panels to their defaults; switching between distinct Session ids closes details and forgets its dragged width, while unselected surfaces render details at zero width without modifying geometry.
- **Panel geometry is transient** — reload restores the sidebar default and details closed; switching between distinct Session ids also closes details and forgets its dragged width, while unselected surfaces render details at zero width without modifying geometry.
- **Concession-chain auto-close derives a zero width without touching the preferred width** — the panel restores itself when the window widens; consumers must not read the stored details width as the rendered truth.
- **Scroll anchoring during squeeze reflow is not implemented** — deferred with the virtualized-list project.

View File

@@ -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 通过 `SessionProvider` 渲染。布局 store 是瞬时状态,两个面板均以默认宽度启动,从不读写 `localStorage`。hero 和其他未选中状态会将详情栏的渲染宽度派生为零但不会改变存储的首选宽度。AppFrame 会跨越这些状态保留最后一个非 blank 会话 id首个会话默认宽度打开;返回同一会话时恢复其未改变的宽度;选择不同会话时,详情栏会在绘制前关闭。会话 owner share 为空,侧边栏 owner share 只包含 `collapsed``width`;注册方通过标准钩子获取业务数据,并从各自的 inject 表层获取操作。
AppFrame 始终挂载会话栏和详情栏;已连接 Session 通过 `SessionProvider` 渲染。布局 store 是瞬时状态,侧边栏以默认宽度启动,详情栏则保持关闭,且该 store 从不读写 `localStorage`。hero 和其他未选中状态会将详情栏的渲染宽度派生为零但不会改变存储的首选宽度。AppFrame 会跨越这些状态保留最后一个非 blank 会话 id首个会话保持关闭;显式打开详情栏的操作会使用契约默认宽度;返回同一会话时恢复其未改变的宽度;选择不同会话时,详情栏会在绘制前关闭。会话 owner share 为空,侧边栏 owner share 只包含 `collapsed``width`;注册方通过标准钩子获取业务数据,并从各自的 inject 表层获取操作。
`/client` 导出表层包含插件主体(`apply``inject`)、`LayoutService` 和四个 owner-share 接口。AppFrame、面板 store 与让步求解器仍属于包内部;测试通过 `/src` 导入内部实现。
@@ -18,6 +18,6 @@ AppFrame 始终挂载会话栏和详情栏;已连接 Session 通过 `SessionPr
## 已知限制与暂缓事项
- **面板几何信息是瞬时状态**:重新加载会将两个面板恢复为默认值;在不同会话 id 之间切换会关闭详情栏,并忘记拖动后的宽度,而未选中表面会以零宽度渲染详情栏,但不会修改几何信息。
- **面板几何信息是瞬时状态**:重新加载会恢复侧边栏默认值,并使详情栏保持关闭;在不同会话 id 之间切换同样会关闭详情栏,并忘记拖动后的宽度,而未选中表面会以零宽度渲染详情栏,但不会修改几何信息。
- **让步链自动关闭通过推导零宽度实现,不会改动首选宽度**:窗口变宽时面板会自行恢复;消费方禁止把 store 中的详情宽度当作实际渲染状态。
- **挤压重排期间尚未实现滚动锚定**:与虚拟化列表项目一并暂缓。

View File

@@ -38,7 +38,7 @@ type LayoutActions = {
*/
export function createLayoutStore(): EngineStoreHandle<LayoutState, LayoutActions> {
const handle = defineStore({
init: (): LayoutState => ({ sidebar: SIDEBAR_DEFAULT, details: DETAILS_DEFAULT }),
init: (): LayoutState => ({ sidebar: SIDEBAR_DEFAULT, details: 0 }),
actions: {
setSidebar: (d, px: number) => { d.sidebar = clampWidth(px, SIDEBAR_MIN, SIDEBAR_MAX) },
setDetails: (d, px: number) => { d.details = clampWidth(px, DETAILS_MIN, DETAILS_MAX) },

View File

@@ -139,7 +139,7 @@ afterEach(() => {
describe('AppFrame', () => {
it('renders three tracks from store state', () => {
const { frame } = mountFrame()
expect(tracks(frame)).toEqual([280, 360])
expect(tracks(frame)).toEqual([280, 0])
})
it('renders the session pair with empty owner shares (sessionId is framework-standard)', () => {
@@ -174,6 +174,9 @@ describe('AppFrame', () => {
it('ignores unselected states and closes only when the Session id changes', () => {
const { frame, instance, rerenderFrame } = mountFrame()
expect(tracks(frame)).toEqual([280, 0])
act(() => { instance.actions.openDetails() })
expect(tracks(frame)).toEqual([280, 360])
selectedSession.current = 's-next' as SessionId
@@ -200,15 +203,15 @@ describe('AppFrame', () => {
expect(tracks(frame)).toEqual([280, 0])
})
it('keeps the default details width when the first Session materializes', () => {
it('keeps details closed when the first Session materializes', () => {
selectedSession.current = undefined
const { frame, instance, rerenderFrame } = mountFrame()
expect(tracks(frame)).toEqual([280, 0])
expect(instance.getSnapshot().details).toBe(360)
expect(instance.getSnapshot().details).toBe(0)
selectedSession.current = 's-first' as SessionId
act(() => { rerenderFrame() })
expect(tracks(frame)).toEqual([280, 360])
expect(tracks(frame)).toEqual([280, 0])
})
it('sidebar slot receives live concession output as owner props', () => {
@@ -224,7 +227,8 @@ describe('AppFrame', () => {
})
it('details drag widens leftward (negative dx grows the panel)', () => {
const { frame } = mountFrame()
const { frame, instance } = mountFrame()
act(() => { instance.actions.openDetails() })
const handles = frame.querySelectorAll('[class*="handle"]')
drag(handles[1]!, 1560, 1500)
expect(tracks(frame)[1]).toBe(420)
@@ -233,6 +237,7 @@ describe('AppFrame', () => {
it('drag base is the rendered (concession-clamped) width, not the preference', () => {
frameWidth = 1250 // step-2 squeeze: details renders 330 while preference is 360
const { frame, instance } = mountFrame()
act(() => { instance.actions.openDetails() })
expect(tracks(frame)).toEqual([280, 330])
const handles = frame.querySelectorAll('[class*="handle"]')
drag(handles[1]!, 920, 930) // shrink by 10 from the rendered width
@@ -240,8 +245,7 @@ describe('AppFrame', () => {
})
it('details column stays mounted at zero width', () => {
const { frame, instance, getByTestId } = mountFrame()
act(() => { instance.actions.closeDetails() })
const { frame, getByTestId } = mountFrame()
expect(tracks(frame)).toEqual([280, 0])
expect(getByTestId('details-content')).toBeTruthy()
expect(frame.hasAttribute('data-details-collapsed')).toBe(true)
@@ -250,7 +254,7 @@ describe('AppFrame', () => {
it('closed sidebar keeps its compact rail with mounted slot content and collapsed owner props', () => {
const { frame, instance, slotCalls, getByTestId } = mountFrame()
act(() => { instance.actions.toggleSidebar() })
expect(tracks(frame)).toEqual([SIDEBAR_COLLAPSED, 360])
expect(tracks(frame)).toEqual([SIDEBAR_COLLAPSED, 0])
expect(getByTestId('sidebar-content')).toBeTruthy()
expect(frame.hasAttribute('data-sidebar-collapsed')).toBe(true)
const lastSidebarCall = slotCalls.filter(c => c.key === 'sidebar').at(-1)!
@@ -258,7 +262,8 @@ describe('AppFrame', () => {
})
it('viewport shrink triggers the concession chain via ResizeObserver', () => {
const { frame } = mountFrame()
const { frame, instance } = mountFrame()
act(() => { instance.actions.openDetails() })
frameWidth = 1250
act(() => { fireResize?.(); vi.advanceTimersByTime(20) })
expect(tracks(frame)).toEqual([280, 330])
@@ -269,6 +274,8 @@ describe('AppFrame', () => {
it('drag handles disappear for collapsed columns', () => {
const { frame, instance } = mountFrame()
expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(1)
act(() => { instance.actions.openDetails() })
expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(2)
act(() => { instance.actions.closeDetails() })
expect(frame.querySelectorAll('[class*="handle"]')).toHaveLength(1)
@@ -323,7 +330,7 @@ describe('AppFrame — guard branches', () => {
frameWidth = 0
act(() => { fireResize?.(); vi.advanceTimersByTime(20) })
// Track template still reflects the last non-zero viewport.
expect(tracks(frame)).toEqual([280, 360])
expect(tracks(frame)).toEqual([280, 0])
})
})
@@ -338,7 +345,8 @@ describe('AppFrame — unmount with an in-flight resize frame', () => {
})
it('double resize inside one frame rides the pending rAF (??= guard)', () => {
const { frame } = mountFrame()
const { frame, instance } = mountFrame()
act(() => { instance.actions.openDetails() })
frameWidth = 1250
act(() => { fireResize?.(); fireResize?.(); vi.advanceTimersByTime(20) })
expect(tracks(frame)).toEqual([280, 330])

View File

@@ -17,9 +17,9 @@ const PERSIST_KEY = 'dsh.layout.panels'
beforeEach(() => { localStorage.clear() })
describe('createLayoutStore', () => {
it('initializes both panels at their default widths', () => {
it('initializes the sidebar at its default width and details closed', () => {
const { store } = createLayoutStore().create()
expect(store.getSnapshot()).toEqual({ sidebar: SIDEBAR_DEFAULT, details: DETAILS_DEFAULT })
expect(store.getSnapshot()).toEqual({ sidebar: SIDEBAR_DEFAULT, details: 0 })
})
it('each create() is an independent instance (factory is not a singleton)', () => {
@@ -50,9 +50,8 @@ describe('createLayoutStore', () => {
expect(store.getSnapshot().sidebar).toBe(SIDEBAR_DEFAULT)
})
it('openDetails is a no-op when already open; closeDetails zeroes', () => {
it('openDetails uses the contract default, preserves an open width, and closeDetails zeroes', () => {
const { store, actions } = createLayoutStore().create()
actions.closeDetails()
actions.openDetails()
expect(store.getSnapshot().details).toBe(DETAILS_DEFAULT)
actions.setDetails(500)
@@ -65,13 +64,14 @@ describe('createLayoutStore', () => {
it('does not persist panel geometry', () => {
const first = createLayoutStore().create()
first.actions.setSidebar(400)
first.actions.closeDetails()
first.actions.openDetails()
first.actions.setDetails(500)
expect(localStorage.getItem(PERSIST_KEY)).toBeNull()
const second = createLayoutStore().create()
expect(second.store.getSnapshot()).toEqual({
sidebar: SIDEBAR_DEFAULT,
details: DETAILS_DEFAULT,
details: 0,
})
})
})