mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge branch 'master' into docs/readme-human-polish-3
This commit is contained in:
@@ -272,12 +272,17 @@
|
||||
.scrollBody:has([data-conversation-composer-overlay]) {
|
||||
position: relative;
|
||||
/* A clipping box nothing scrolls out of, stated as a scroll container on both
|
||||
axes rather than `overflow: hidden`: WebKit honours the reservation above
|
||||
only in the `overflow-y: auto` form, and a single-axis scroller computes
|
||||
the other axis to `auto`
|
||||
([decision](../../../../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). */
|
||||
axes rather than `overflow: hidden`: a single-axis scroller computes the
|
||||
other axis to `auto`, and the seat's absolute positioning below needs the
|
||||
box to stay a containing block in every engine. */
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
/* The view owns its own scrollers (the trajectory ledger scrolls inside the
|
||||
view), so this box never scrolls here; reserving a gutter would only
|
||||
narrow the view's content by the bar's width. Chat keeps the reservation
|
||||
on the same element, so the seat below compensates with the bar's width
|
||||
([decision](../../../../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). */
|
||||
scrollbar-gutter: auto;
|
||||
}
|
||||
|
||||
.scrollBody:has([data-conversation-composer-overlay]) > :global([data-slot='conversation.session']) > .viewArea {
|
||||
@@ -288,7 +293,15 @@
|
||||
|
||||
.scrollBody:has([data-conversation-composer-overlay]) > .composerSeat {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
/* Chat's seat rides this box's content box, which its own scrolling shortens
|
||||
by the bar's width; the overlay seat is against the padding box, so it
|
||||
must give back the same width to keep the input card in place across
|
||||
tabs. Reads ui-theme's --dsh-scrollbar-width, which mirrors the WebKit
|
||||
bar's layout width. Chat reserves the engine-resolved width, so on an
|
||||
engine where the resolved width differs from this fixed 8px the card
|
||||
drifts by half the difference — accepted residual risk, recorded in
|
||||
([decision](../../../../../../.agents/notes/implemented/bug-fix/2026-08-12-composer-overlay-seat-width-compensation.md)). */
|
||||
right: var(--dsh-scrollbar-width);
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
@@ -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/README.md
|
||||
README.md: 9974118f69901de985e012e1b62f95a0bcee64c2
|
||||
README.zh.md: 11b0aa142cf62626ab6105e2c405d506e35349b0
|
||||
README.md: 7cc4fe0a722fe5f8cf0e983a0e3fdb30cd31bb64
|
||||
README.zh.md: 96b6f60f871758c126e6c387c6ce3f0993039af0
|
||||
|
||||
@@ -8,6 +8,8 @@ New Session starts the runtime's page-local frontend Session Intent. The runtime
|
||||
|
||||
`SidebarRootComponentProps` composes the layout owner share, the global `useSessions` and `useWorkspaces` hooks, the declared `sidebar.workspaces` and `sidebar.settings` child slots, and injected `startSession` plus sidebar-toggle callbacks. There is no plugin store.
|
||||
|
||||
During a live collapse, the shell holds the expanded content at its current width while it fades out for 150ms. The four upper controls—the shell toggle and New Session plus add and search rendered through `sidebar.workspaces`—then share one 150ms fade and 49px leftward translation into the 56px rail, ending with the layout's 300ms column slide; every 36px control box follows the same path to the rail's 10px left inset. The bottom-pinned `sidebar.settings` control shares the fade timing but has no horizontal translation. A page that starts collapsed renders the rail statically, and reduced-motion mode disables both transitions.
|
||||
|
||||
Scrollbars in the column are a pointer affordance: the shell rebinds ui-theme's [scrollbar indirection](../ui-theme/README.md) to `transparent` whenever the pointer is outside it, and keeps the thumb drawn for 2s after the pointer leaves, so a list nobody is pointing at carries no bar. The reservation that keeps rows from moving belongs to the scrolling region ([ui-workspace](../ui-workspace/README.md)), so revealing a thumb never reflows.
|
||||
|
||||
The foot is the `sidebar.settings` seat: the sidebar renders only the bottom-pinned layout slot and shares its column state (`wide`); ui-settings registers the trigger row and settings panel there.
|
||||
|
||||
@@ -8,6 +8,8 @@ New Session 会启动运行时的页面局部前端 Session Intent。运行时
|
||||
|
||||
`SidebarRootComponentProps` 组合布局 owner share、全局 `useSessions` 和 `useWorkspaces` 钩子、已声明的 `sidebar.workspaces` 与 `sidebar.settings` 子 slot,以及注入的 `startSession` 与侧边栏切换回调。这里没有插件 store。
|
||||
|
||||
实时收起时,外壳会把展开内容固定在当前宽度,并用 150ms 将其淡出。随后,上方四个控件——外壳的侧栏切换与新建会话,以及通过 `sidebar.workspaces` 渲染的添加和搜索——共用一次 150ms 的淡入和 49px 左移,在布局的 300ms 栏滑动结束时一起进入 56px 轨道;每个 36px 控件盒都会沿同一条路径到达轨道左侧 10px 的内边距。固定在底部的 `sidebar.settings` 控件只共用淡入时序,不发生横向位移。页面初始即为收起状态时会静态渲染轨道;减少动态效果模式会禁用两段过渡。
|
||||
|
||||
栏内的滚动条是一种指针可供性:只要指针不在栏内,外壳就把 ui-theme 的[滚动条间接层](../ui-theme/README.md)重新绑定为 `transparent`;指针离开后滑块再保留 2 秒,因此没人指向的列表不会带着滚动条。避免行位移的空间预留属于滚动区域本身([ui-workspace](../ui-workspace/README.md)),所以显示滑块不会引起重排。
|
||||
|
||||
页脚承载 `sidebar.settings`:侧边栏只渲染固定在底部的布局 slot,并共享其栏状态(`wide`);ui-settings 在此注册触发行和设置面板。
|
||||
|
||||
@@ -58,24 +58,35 @@
|
||||
from { opacity: 0; }
|
||||
}
|
||||
|
||||
/* Rail controls hold hidden while the column slides shut, then fade in over
|
||||
the slide's tail: .railIn applies at settle (150ms into the 0.3s AppFrame
|
||||
track transition), so a 100ms delay + 150ms fade starts just before the
|
||||
slide ends (250ms) and finishes at 400ms; `backwards` keeps them at
|
||||
opacity 0 through the delay. Only a live collapse gets .railIn — a
|
||||
refresh straight into the collapsed state renders statically. */
|
||||
/* At the 150ms rail settle, the four upper controls enter from the former
|
||||
rail right edge over the remaining 150ms of the AppFrame track transition.
|
||||
The bottom-pinned settings seat shares their opacity timeline but stays
|
||||
horizontally fixed. Only a live collapse gets .railIn; a cold collapsed
|
||||
render stays static. */
|
||||
.railIn .iconButton,
|
||||
.railIn .newSession,
|
||||
.railIn .regionArea {
|
||||
animation: rail-in 150ms var(--ds-ease-in-out) backwards;
|
||||
}
|
||||
|
||||
.railIn .footArea {
|
||||
animation: rail-in 150ms var(--ds-ease-in-out) 100ms backwards;
|
||||
animation: rail-fade-in 150ms var(--ds-ease-in-out) backwards;
|
||||
}
|
||||
|
||||
@keyframes rail-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(49px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rail-fade-in {
|
||||
from { opacity: 0; }
|
||||
}
|
||||
|
||||
/* Logo row (figma pad (4,8,4,8)): brand left, panel toggle right-anchored —
|
||||
the toggle is the rail's expand control and slides in with the right edge. */
|
||||
/* Logo row (figma pad (4,8,4,8)): expanded keeps the panel toggle at the
|
||||
right edge; the rail gives every control the same base left anchor before
|
||||
the shared entry translation is applied. */
|
||||
.logoRow {
|
||||
flex: none;
|
||||
display: flex;
|
||||
@@ -93,6 +104,7 @@
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
margin-bottom: 12px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
/* Brand group (figma I133:7632): the full wordmark rides the text ink
|
||||
@@ -185,6 +197,8 @@
|
||||
}
|
||||
|
||||
.collapsed .newSession {
|
||||
align-self: flex-start;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
margin: 0 0 12px;
|
||||
@@ -262,7 +276,8 @@
|
||||
.fading > *,
|
||||
.railIn .iconButton,
|
||||
.railIn .newSession,
|
||||
.railIn .footArea {
|
||||
.railIn .footArea,
|
||||
.railIn .regionArea {
|
||||
transition: none;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
* Sidebar shell: column geometry only. Collapse is a slide plus crossfade:
|
||||
* content freezes at its expanded width (inline style) and fades out in place
|
||||
* while the sliding column (AppFrame grid tracks) clips it — nothing reflows
|
||||
* mid-slide. At settle the wide-only content unmounts and the control rows
|
||||
* snap to the 56px rail (one icon each, same top-down order) fading in as the
|
||||
* slide ends. The workspace/session browsing region between the New Session
|
||||
* button and the foot is the `sidebar.workspaces` registrant's, and the foot
|
||||
* holds `sidebar.settings` plus `sidebar.footer.action`; the shell hands them the wide flag
|
||||
* (plus an expand request callback for the browser).
|
||||
* mid-slide. At settle the wide-only content unmounts and the four upper
|
||||
* controls enter the 56px rail from the same horizontal offset (one icon each,
|
||||
* same top-down order) on one fade that ends with the slide. The bottom-pinned
|
||||
* settings control only fades. The workspace/session browsing region between
|
||||
* the New Session button and the foot is the `sidebar.workspaces` registrant's,
|
||||
* and the foot holds `sidebar.settings` plus `sidebar.footer.action`; the shell
|
||||
* hands them the wide flag (plus an expand request callback for the browser).
|
||||
*
|
||||
* The column also owns whether the scroll regions nested in it draw a
|
||||
* scrollbar at all: the shell tracks the pointer and rebinds ui-theme's
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Sidebar shell inset contract shared with the nested workspace browser. */
|
||||
/** Sidebar shell style contracts shared with its slot-owned controls. */
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
@@ -25,7 +25,7 @@ function declarations(selector: string): Map<string, string> | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
describe('SidebarRoot.module.css inset', () => {
|
||||
describe('SidebarRoot.module.css', () => {
|
||||
it('shares and cancels the wide shell trailing padding structurally', () => {
|
||||
const root = declarations('.root')
|
||||
expect(root?.get('--dsh-sidebar-inline-padding')).toBe('12px')
|
||||
@@ -39,4 +39,28 @@ describe('SidebarRoot.module.css inset', () => {
|
||||
expect(declarations('.collapsed .regionArea')?.get('padding-left')).toBe('0')
|
||||
expect(declarations('.collapsed .regionArea')?.get('margin-right')).toBe('0')
|
||||
})
|
||||
|
||||
it('moves the four upper controls while the settings seat only fades', () => {
|
||||
const animation = 'rail-in 150ms var(--ds-ease-in-out) backwards'
|
||||
for (const selector of [
|
||||
'.railIn .iconButton',
|
||||
'.railIn .newSession',
|
||||
'.railIn .regionArea',
|
||||
]) {
|
||||
expect(declarations(selector)?.get('animation')).toBe(animation)
|
||||
}
|
||||
expect(declarations('.railIn .footArea')?.get('animation')).toBe(
|
||||
'rail-fade-in 150ms var(--ds-ease-in-out) backwards',
|
||||
)
|
||||
expect(css).toMatch(
|
||||
/@keyframes rail-in\s*\{\s*from\s*\{\s*opacity: 0;\s*transform: translateX\(49px\);\s*}\s*}/,
|
||||
)
|
||||
expect(css).toMatch(/@keyframes rail-fade-in\s*\{\s*from\s*\{\s*opacity: 0;\s*}\s*}/)
|
||||
})
|
||||
|
||||
it('gives shell rail controls the same base anchor for their shared translation', () => {
|
||||
expect(declarations('.collapsed .logoRow')?.get('justify-content')).toBe('flex-start')
|
||||
expect(declarations('.collapsed .newSession')?.get('align-self')).toBe('flex-start')
|
||||
expect(declarations('.collapsed .newSession')?.get('width')).toBe('36px')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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-theme/README.md
|
||||
README.md: 9388d5c05e5625d1a38b27c4565edf3ed47b3b6d
|
||||
README.zh.md: 9995980e4a64b07c376f90e223c1ec83b5881b73
|
||||
README.md: e84a21f085eaef7546321edc90847a9a3f9bb2b8
|
||||
README.zh.md: 758b6f52d330f4518a2df5267117b61446102ea7
|
||||
|
||||
@@ -8,7 +8,7 @@ When the host composition includes an HTTP server, the host half injects a synch
|
||||
|
||||
`src/styles/` holds five sheets, all imported by the web shell's `base.css`: `base.css`, `design-platform.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. `scrollbar.css` is the sole consumer of the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them.
|
||||
|
||||
Scrollbar rebinding contract: `scrollbar.css` binds `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover` on `body` to the l1 (base-surface) tokens, and both rendering paths read that pair. An elevated surface (menu, popover, dialog) sets `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` and `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)` on its own container; one rebind retints whichever path the engine took. The pair's other legal target is `transparent`, which draws no thumb at all — [ui-sidebar](../ui-sidebar/README.md) rebinds its column that way while the pointer is elsewhere. A rebind to the l1 pair is not a rebind; it restates the base-surface default.
|
||||
Scrollbar rebinding contract: `scrollbar.css` binds `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover` on `body` to the l1 (base-surface) tokens, and both rendering paths read that pair. An elevated surface (menu, popover, dialog) sets `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` and `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)` on its own container; one rebind retints whichever path the engine took. The pair's other legal target is `transparent`, which draws no thumb at all — [ui-sidebar](../ui-sidebar/README.md) rebinds its column that way while the pointer is elsewhere. A rebind to the l1 pair is not a rebind; it restates the base-surface default. `--dsh-scrollbar-width` mirrors the WebKit bar's layout width for surfaces that align themselves beside a space-consuming bar — [ui-conversation](../ui-conversation/README.md) reads it for the overlay composer seat's `right` offset — and the scrollbar-styles spec pairs it with the mirrored rule and the consumer.
|
||||
|
||||
The two paths are mutually exclusive by construction. `scrollbar-width`/`scrollbar-color` sit inside `@supports not selector(::-webkit-scrollbar)` because a non-`auto` value of either makes Chromium and Safari discard every `::-webkit-scrollbar*` rule for that element, `::-webkit-scrollbar-thumb:hover` included — declaring both unconditionally leaves `--dsh-scrollbar-thumb-hover` with no rendering anywhere. Firefox therefore takes the standard properties and WebKit-based engines take the pseudo-elements, so the hover token only ever renders through the pseudo-element path. Reasoning and the measured computed values: [the scrollbar Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md).
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
`src/styles/` 下有五张样式表,全部由 web 壳的 `base.css` 导入:`base.css`、`design-platform.css`、`scrollbar.css`、`gradient-shadow-text.css` 与 `shiki.css`。`scrollbar.css` 是 `--dsw-alias-scrollbar-*` token 的唯一消费方,必须排在声明这些 token 的 `design-platform.css` 之后。
|
||||
|
||||
滚动条重新绑定约定:`scrollbar.css` 在 `body` 上把 `--dsh-scrollbar-thumb` 与 `--dsh-scrollbar-thumb-hover` 绑定到 l1(基础表面)token,两条渲染路径都读取这一组变量。高层级表面(菜单、浮层、对话框)在自己的容器上设置 `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` 与 `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)`;一次重新绑定即可为引擎实际走的那条路径换色。这组变量的另一个合法目标是 `transparent`,即完全不绘制滑块——[ui-sidebar](../ui-sidebar/README.md) 在指针不在栏内时就这样重新绑定自己的列。绑回 l1 那组不算重新绑定,它只是重述基础表面的默认值。
|
||||
滚动条重新绑定约定:`scrollbar.css` 在 `body` 上把 `--dsh-scrollbar-thumb` 与 `--dsh-scrollbar-thumb-hover` 绑定到 l1(基础表面)token,两条渲染路径都读取这一组变量。高层级表面(菜单、浮层、对话框)在自己的容器上设置 `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` 与 `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)`;一次重新绑定即可为引擎实际走的那条路径换色。这组变量的另一个合法目标是 `transparent`,即完全不绘制滑块——[ui-sidebar](../ui-sidebar/README.md) 在指针不在栏内时就这样重新绑定自己的列。绑回 l1 那组不算重新绑定,它只是重述基础表面的默认值。`--dsh-scrollbar-width` 镜像 WebKit 滚动条的布局宽度,供需要与占布局宽度的滚动条对齐的表面使用——[ui-conversation](../ui-conversation/README.md) 用它作为覆盖 composer 座位 `right` 偏移——scrollbar-styles 规格把它与镜像规则及消费者配对检查。
|
||||
|
||||
两条路径在构造上互斥。`scrollbar-width`/`scrollbar-color` 写在 `@supports not selector(::-webkit-scrollbar)` 之内,因为这两个属性中的任一个只要取非 `auto` 值,Chromium 与 Safari 就会丢弃该元素上的全部 `::-webkit-scrollbar*` 规则,`::-webkit-scrollbar-thumb:hover` 也在其中——若无条件地同时声明,`--dsh-scrollbar-thumb-hover` 在任何引擎上都不会被渲染。因此 Firefox 走标准属性,WebKit 系引擎走伪元素,hover token 只经由伪元素这条路径渲染。相关原理与实测计算值见[滚动条 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md)。
|
||||
|
||||
|
||||
@@ -17,6 +17,11 @@
|
||||
body {
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l1);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l1);
|
||||
/* The WebKit bar's layout width, mirrored by the ::-webkit-scrollbar rule
|
||||
below. A surface that must align itself beside a space-consuming bar
|
||||
(ConversationRoot's overlay composer seat) reads this instead of
|
||||
hardcoding the number. */
|
||||
--dsh-scrollbar-width: 8px;
|
||||
}
|
||||
|
||||
/* The two paths are mutually exclusive, and the gate is load-bearing rather
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
* Scrollbar stylesheet contract, asserted against the CSS text on disk: every
|
||||
* --dsw-alias-scrollbar-* token design-platform.css defines has a consumer,
|
||||
* scrollbar.css binds the base-surface pair through the rebindable
|
||||
* indirection, and elevated surfaces rebind that indirection in complete
|
||||
* pairs. The expected token set is scanned out of design-platform.css, so
|
||||
* adding, renaming, or dropping a scrollbar token moves these assertions with
|
||||
* it.
|
||||
* indirection, the width variable mirrors the ::-webkit-scrollbar rule for
|
||||
* consumers that align beside the bar, and elevated surfaces rebind that
|
||||
* indirection in complete pairs. The expected token set is scanned out of
|
||||
* design-platform.css, so adding, renaming, or dropping a scrollbar token
|
||||
* moves these assertions with it.
|
||||
*/
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
@@ -341,6 +342,67 @@ describe('scrollbar.css base-surface binding', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('scrollbar.css width variable', () => {
|
||||
const WIDTH_VARIABLE = `${INDIRECTION_PREFIX}width`
|
||||
|
||||
it('defines the width variable on body as a static length', () => {
|
||||
// The overlay seat compensation reads a fixed number, not a second
|
||||
// indirection: the mirror check below compares the WebKit rule against
|
||||
// this value, so a var()-to-var() chain would compare one indirection to
|
||||
// another instead of pinning the number.
|
||||
const value = scrollbarRules
|
||||
.filter(rule => rule.selectors.includes('body'))
|
||||
.flatMap(rule => rule.declarations)
|
||||
.findLast(([property]) => property === WIDTH_VARIABLE)?.[1]
|
||||
expect(value, WIDTH_VARIABLE).toBeDefined()
|
||||
expect(value, WIDTH_VARIABLE).toMatch(/^\d+(?:\.\d+)?px$/)
|
||||
})
|
||||
|
||||
it('mirrors the ::-webkit-scrollbar width rule with the variable value', () => {
|
||||
// The compensation stays aligned with the WebKit bar only while both read
|
||||
// the same number. A change to one side without the other puts the overlay
|
||||
// seat a band off from Chat on WebKit engines.
|
||||
const variableValue = scrollbarRules
|
||||
.filter(rule => rule.selectors.includes('body'))
|
||||
.flatMap(rule => rule.declarations)
|
||||
.findLast(([property]) => property === WIDTH_VARIABLE)?.[1]
|
||||
const webkitWidth = scrollbarRules
|
||||
.filter(rule => rule.selectors.includes('::-webkit-scrollbar'))
|
||||
.flatMap(rule => rule.declarations)
|
||||
.findLast(([property]) => property === 'width')?.[1]
|
||||
expect(webkitWidth, '::-webkit-scrollbar width').toBeDefined()
|
||||
expect(webkitWidth).toBe(variableValue)
|
||||
})
|
||||
|
||||
it('every reader of the width variable outside ui-theme references a defined variable', () => {
|
||||
// The consumer is ConversationRoot's overlay composer seat
|
||||
// (`right: var(--dsh-scrollbar-width)`); a rename in scrollbar.css without
|
||||
// the consumer, or a typo in the consumer, leaves the value
|
||||
// guaranteed-invalid and the seat loses the band. The equal-rectangle e2e
|
||||
// would catch it only on an engine that draws the bar, so the sheet
|
||||
// contract states it here.
|
||||
const defined = new Set(
|
||||
scrollbarRules
|
||||
.flatMap(rule => rule.declarations)
|
||||
.filter(([property]) => property.startsWith(INDIRECTION_PREFIX))
|
||||
.map(([property]) => property),
|
||||
)
|
||||
expect(defined).toContain(WIDTH_VARIABLE)
|
||||
const readers: string[] = []
|
||||
for (const file of packageStylesheets()) {
|
||||
if (file === fileURLToPath(new URL('scrollbar.css', STYLES))) continue
|
||||
for (const rule of parseRules(readFileSync(file, 'utf8'))) {
|
||||
for (const [property, value] of rule.declarations) {
|
||||
for (const name of varReferences(value)) {
|
||||
if (name === WIDTH_VARIABLE) readers.push(`${file} ${rule.selectors.join(', ')}: ${property}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(readers.length, 'compensation consumer').toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('scrollbar.css selectors', () => {
|
||||
const scrollbarColorSelectors = scrollbarRules
|
||||
.filter(rule => rule.declarations.some(([property]) => property === 'scrollbar-color'))
|
||||
|
||||
@@ -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-trajectory/README.md
|
||||
README.md: d3786b6460c5df7eaa6d24e68c80025e7fb29ae4
|
||||
README.zh.md: b3bc88d8ab73ccc0da9a49d1867fe2c66be5ca43
|
||||
README.md: 9ad42a3d3bcfb8fbf4842a66577e65794a716dfd
|
||||
README.zh.md: df7661ef7d4861dbc6c8e365a18b3e3252d4d4af
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned and while an older page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble business records, including cancellation-frozen Assistant and Tool records, from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Contract: api-contracts v3 §8.
|
||||
Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned. While an older prefix remains unloaded, a first-row control precedes the loaded records, loads one earlier page on click, and changes in place to a disabled loading status while that page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble business records, including cancellation-frozen Assistant and Tool records, from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Contract: api-contracts v3 §8.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前以及更早页面的加载尚未完成时,记录表会用明确的加载行遮住真实记录。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service,也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder,以及会话 `'conversation.view'` slot 环中的一个视图标签页。约定:api-contracts v3 §8。
|
||||
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前,记录表会用明确的加载行遮住真实记录。更早的前缀仍未加载时,已加载记录前会始终保留首行控件;单击它会加载一页更早的历史,页面加载期间则会原地变为禁用的加载状态。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service,也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder,以及会话 `'conversation.view'` slot 环中的一个视图标签页。约定:api-contracts v3 §8。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -55,6 +55,53 @@
|
||||
animation: history-loading-spin 700ms linear infinite;
|
||||
}
|
||||
|
||||
.table tbody .historyLoadRow td {
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* The pagination control does not create a trajectory turn boundary. */
|
||||
.table tbody .historyLoadRow + tr[data-turn-start='true'] td::before {
|
||||
content: none;
|
||||
}
|
||||
|
||||
.historyLoadButton {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 29px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
border: 0;
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
cursor: pointer;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
}
|
||||
|
||||
.historyLoadButton:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.historyLoadButton:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-state-business-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.historyLoadButton:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.visuallyHidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.table:not([data-scroll-ready='true']) {
|
||||
visibility: hidden;
|
||||
}
|
||||
@@ -137,7 +184,7 @@
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.table tbody tr:not([data-collapsed-summary]):not([data-virtual-spacer]) {
|
||||
.table tbody tr:not([data-collapsed-summary]):not([data-virtual-spacer]):not([data-history-load]) {
|
||||
cursor: default;
|
||||
outline: none;
|
||||
transition:
|
||||
@@ -149,7 +196,7 @@
|
||||
opacity: 0.24;
|
||||
}
|
||||
|
||||
.table tbody tr:not([data-collapsed-summary]):not([data-virtual-spacer]):not([data-selected='true']):hover {
|
||||
.table tbody tr:not([data-collapsed-summary]):not([data-virtual-spacer]):not([data-history-load]):not([data-selected='true']):hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import css from './TrajectoryTable.module.css'
|
||||
|
||||
const BOTTOM_FOLLOW_THRESHOLD_PX = 2
|
||||
const OLDER_LOAD_THRESHOLD_PX = 48
|
||||
const HISTORY_LOAD_ROW_HEIGHT_PX = 30
|
||||
const VIRTUALIZATION_THRESHOLD = 100
|
||||
const VIRTUAL_OVERSCAN_ROWS = 12
|
||||
const VIRTUAL_INITIAL_VIEWPORT_HEIGHT_PX = 600
|
||||
@@ -364,6 +365,8 @@ export interface TrajectoryTableProps {
|
||||
recordFocus?: { readonly index: number } | null
|
||||
/** Whether the initial history tail is still loading. */
|
||||
historyLoading?: boolean
|
||||
/** Whether one older history page request is pending anywhere. */
|
||||
olderHistoryLoading?: boolean
|
||||
/** First loaded raw event, used to preserve scroll position after prepending a page. */
|
||||
historyStartSeq?: number | undefined
|
||||
/** Whether one older history page can be requested. */
|
||||
@@ -1698,6 +1701,7 @@ export function TrajectoryTable({
|
||||
recordSelection = null,
|
||||
recordFocus = null,
|
||||
historyLoading = false,
|
||||
olderHistoryLoading = false,
|
||||
historyStartSeq,
|
||||
hasOlderRecords = false,
|
||||
onLoadOlder,
|
||||
@@ -1769,6 +1773,7 @@ export function TrajectoryTable({
|
||||
const virtualRowStructure = useStableVirtualRowStructure(projectedVirtualRows)
|
||||
const virtualizationEnabled = hasOlderRecords
|
||||
|| records.length > VIRTUALIZATION_THRESHOLD
|
||||
const virtualScrollMargin = hasOlderRecords ? HISTORY_LOAD_ROW_HEIGHT_PX : 0
|
||||
const estimateVirtualRowSize = useCallback(
|
||||
(index: number) => virtualRowStructure[index]?.height ?? 30,
|
||||
[virtualRowStructure],
|
||||
@@ -1787,6 +1792,7 @@ export function TrajectoryTable({
|
||||
initialRect: { width: 0, height: VIRTUAL_INITIAL_VIEWPORT_HEIGHT_PX },
|
||||
anchorTo: 'end',
|
||||
overscan: VIRTUAL_OVERSCAN_ROWS,
|
||||
scrollMargin: virtualScrollMargin,
|
||||
scrollEndThreshold: BOTTOM_FOLLOW_THRESHOLD_PX,
|
||||
})
|
||||
const virtualIndexByRecordId = useMemo(() => {
|
||||
@@ -1801,10 +1807,15 @@ export function TrajectoryTable({
|
||||
return indexes
|
||||
}, [projectedVirtualRows])
|
||||
const virtualItems = virtualizationEnabled ? rowVirtualizer.getVirtualItems() : []
|
||||
const virtualTop = virtualItems[0]?.start ?? 0
|
||||
const virtualTop = Math.max(0, (virtualItems[0]?.start ?? 0) - virtualScrollMargin)
|
||||
const virtualBottom = virtualItems.length === 0
|
||||
? 0
|
||||
: Math.max(0, rowVirtualizer.getTotalSize() - (virtualItems.at(-1)?.end ?? 0))
|
||||
: Math.max(
|
||||
0,
|
||||
rowVirtualizer.getTotalSize()
|
||||
+ virtualScrollMargin
|
||||
- (virtualItems.at(-1)?.end ?? 0),
|
||||
)
|
||||
const renderedRecords = virtualizationEnabled
|
||||
? virtualItems.flatMap((item) => {
|
||||
const row = projectedVirtualRows[item.index]
|
||||
@@ -2123,12 +2134,13 @@ export function TrajectoryTable({
|
||||
virtualIndexByRecordId,
|
||||
virtualizationEnabled,
|
||||
])
|
||||
const requestOlder = useCallback((pane: HTMLDivElement) => {
|
||||
const requestOlder = useCallback((pane: HTMLDivElement, requireTop: boolean) => {
|
||||
if (
|
||||
!hasOlderRecords
|
||||
|| onLoadOlder === undefined
|
||||
|| loadingOlder.current
|
||||
|| pane.scrollTop > OLDER_LOAD_THRESHOLD_PX
|
||||
|| olderHistoryLoading
|
||||
|| (requireTop && pane.scrollTop > OLDER_LOAD_THRESHOLD_PX)
|
||||
) return
|
||||
loadingOlder.current = true
|
||||
setOlderLoading(true)
|
||||
@@ -2143,7 +2155,7 @@ export function TrajectoryTable({
|
||||
loadingOlder.current = false
|
||||
setOlderLoading(false)
|
||||
})
|
||||
}, [hasOlderRecords, historyStartSeq, onLoadOlder])
|
||||
}, [hasOlderRecords, historyStartSeq, olderHistoryLoading, onLoadOlder])
|
||||
useLayoutEffect(() => {
|
||||
const pane = tablePaneRef.current
|
||||
if (pane === null) return
|
||||
@@ -2176,10 +2188,9 @@ export function TrajectoryTable({
|
||||
virtualizationEnabled,
|
||||
])
|
||||
|
||||
const loadingLabel = olderLoading
|
||||
? 'Loading earlier history…'
|
||||
: 'Loading trajectory…'
|
||||
const showLoading = historyLoading || olderLoading || !tableScrollReady
|
||||
const olderBusy = olderHistoryLoading || olderLoading
|
||||
const showInitialLoading = historyLoading || !tableScrollReady
|
||||
const historyRowOffset = hasOlderRecords ? 1 : 0
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={css.split} style={splitStyle}>
|
||||
@@ -2192,30 +2203,62 @@ export function TrajectoryTable({
|
||||
followsTableTail.current =
|
||||
pane.scrollHeight - pane.clientHeight - pane.scrollTop
|
||||
<= BOTTOM_FOLLOW_THRESHOLD_PX
|
||||
requestOlder(pane)
|
||||
requestOlder(pane, true)
|
||||
}}
|
||||
onClick={(event) => {
|
||||
if (event.target === event.currentTarget) clearAllSelections()
|
||||
}}
|
||||
>
|
||||
{showLoading && (
|
||||
{showInitialLoading && (
|
||||
<div className={css.historyLoading} role="status" aria-live="polite">
|
||||
<span className={css.historyLoadingBar}>
|
||||
<span className={css.historyLoadingSpinner} aria-hidden="true" />
|
||||
{loadingLabel}
|
||||
Loading trajectory…
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<table
|
||||
className={css.table}
|
||||
data-scroll-ready={tableScrollReady || undefined}
|
||||
aria-rowcount={records.length}
|
||||
aria-rowcount={records.length + historyRowOffset}
|
||||
>
|
||||
<colgroup>
|
||||
<col className={css.eventColumn} />
|
||||
<col className={css.contentColumn} />
|
||||
</colgroup>
|
||||
<tbody>
|
||||
{hasOlderRecords && (
|
||||
<tr
|
||||
className={css.historyLoadRow}
|
||||
data-history-load=""
|
||||
aria-rowindex={1}
|
||||
>
|
||||
<td colSpan={2}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.historyLoadButton}
|
||||
disabled={olderBusy || onLoadOlder === undefined}
|
||||
aria-label={olderBusy
|
||||
? 'Loading earlier history…'
|
||||
: 'Load earlier history'}
|
||||
onClick={() => {
|
||||
const pane = tablePaneRef.current
|
||||
if (pane !== null) requestOlder(pane, false)
|
||||
}}
|
||||
>
|
||||
{olderBusy && (
|
||||
<span className={css.historyLoadingSpinner} aria-hidden="true" />
|
||||
)}
|
||||
<span aria-hidden="true">
|
||||
{olderBusy ? 'Loading earlier history…' : 'Load earlier history'}
|
||||
</span>
|
||||
<span className={css.visuallyHidden} role="status" aria-live="polite">
|
||||
{olderBusy ? 'Loading earlier history…' : ''}
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{virtualTop > 0 && (
|
||||
<tr className={css.virtualSpacer} data-virtual-spacer="top" aria-hidden="true">
|
||||
<td
|
||||
@@ -2263,7 +2306,7 @@ export function TrajectoryTable({
|
||||
return (
|
||||
<tr
|
||||
tabIndex={isRequestOnly ? -1 : 0}
|
||||
aria-rowindex={position + 1}
|
||||
aria-rowindex={position + 1 + historyRowOffset}
|
||||
aria-label={isCollapsedSummary
|
||||
? `Collapsed ${record.collapsedSummaryKind} summary, ${record.collapsedSummary}`
|
||||
: isRequestOnly
|
||||
|
||||
@@ -141,8 +141,8 @@ export function TrajectoryView({
|
||||
} | null>(null)
|
||||
const inspection = useSession(snapshot =>
|
||||
snapshot.views.get('trajectory') ?? EMPTY_TRAJECTORY_SNAPSHOT)
|
||||
const historyLoading = useSession(snapshot =>
|
||||
snapshot.openState === 'loading' || snapshot.loadingOlder)
|
||||
const historyLoading = useSession(snapshot => snapshot.openState === 'loading')
|
||||
const olderHistoryLoading = useSession(snapshot => snapshot.loadingOlder)
|
||||
const hasOlderHistory = useSession(snapshot => snapshot.hasMore)
|
||||
const nodes = inspection.eventNodes
|
||||
const eventLocations = inspection.eventLocations
|
||||
@@ -488,6 +488,7 @@ export function TrajectoryView({
|
||||
recordSelection={timelineRecordSelection}
|
||||
recordFocus={timelineRecordFocus}
|
||||
historyLoading={historyLoading}
|
||||
olderHistoryLoading={olderHistoryLoading}
|
||||
historyStartSeq={historyBaseSeq}
|
||||
hasOlderRecords={hasOlderHistory}
|
||||
onLoadOlder={loadEarlierHistory}
|
||||
|
||||
@@ -418,7 +418,9 @@ describe('TrajectoryTable', () => {
|
||||
await waitFor(() => { expect(onLoadOlder).toHaveBeenCalledOnce() })
|
||||
expect(screen.getByRole('status').textContent).toContain('Loading earlier history…')
|
||||
resolveOlder?.(true)
|
||||
await waitFor(() => { expect(screen.queryByRole('status')).toBeNull() })
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('status').textContent).toBe('')
|
||||
})
|
||||
scrollHeight = 260
|
||||
view.rerender(
|
||||
<TrajectoryTable
|
||||
@@ -438,6 +440,68 @@ describe('TrajectoryTable', () => {
|
||||
expect(tablePane.scrollTop).toBe(60)
|
||||
})
|
||||
|
||||
it('keeps an idle older-history control as the first row until paging completes', async () => {
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(600)
|
||||
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
})
|
||||
let resolveOlder: ((advanced: boolean) => void) | undefined
|
||||
const older = new Promise<boolean>((resolve) => { resolveOlder = resolve })
|
||||
const onLoadOlder = vi.fn(() => older)
|
||||
const view = render(
|
||||
<TrajectoryTable
|
||||
turns={TURNS}
|
||||
{...FOLD_PROPS}
|
||||
hasOlderRecords
|
||||
onLoadOlder={onLoadOlder}
|
||||
/>,
|
||||
)
|
||||
|
||||
const table = screen.getByRole('table')
|
||||
const loadButton = screen.getByRole('button', { name: 'Load earlier history' })
|
||||
const loadRow = table.querySelector('tbody > tr:first-child')
|
||||
expect(loadRow?.contains(loadButton)).toBe(true)
|
||||
expect(loadRow?.getAttribute('aria-rowindex')).toBe('1')
|
||||
expect(screen.getByRole('status').textContent).toBe('')
|
||||
expect(table.getAttribute('aria-rowcount')).toBe('4')
|
||||
expect((await screen.findByRole('row', { name: /ASSISTANT/ })).getAttribute('aria-rowindex'))
|
||||
.toBe('2')
|
||||
|
||||
fireEvent.click(loadButton)
|
||||
expect(onLoadOlder).toHaveBeenCalledOnce()
|
||||
expect(loadButton.hasAttribute('disabled')).toBe(true)
|
||||
expect(screen.getByRole('status').textContent).toBe('Loading earlier history…')
|
||||
|
||||
resolveOlder?.(false)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Load earlier history' })
|
||||
.hasAttribute('disabled')).toBe(false)
|
||||
})
|
||||
|
||||
view.rerender(
|
||||
<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />,
|
||||
)
|
||||
expect(screen.queryByRole('button', { name: 'Load earlier history' })).toBeNull()
|
||||
expect(table.getAttribute('aria-rowcount')).toBe('3')
|
||||
})
|
||||
|
||||
it('reflects an older page started outside the ledger in the persistent control', () => {
|
||||
render(
|
||||
<TrajectoryTable
|
||||
turns={TURNS}
|
||||
{...FOLD_PROPS}
|
||||
hasOlderRecords
|
||||
olderHistoryLoading
|
||||
onLoadOlder={vi.fn(async () => true)}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Loading earlier history…' })
|
||||
.hasAttribute('disabled')).toBe(true)
|
||||
expect(screen.getByRole('status').textContent).toBe('Loading earlier history…')
|
||||
})
|
||||
|
||||
it('covers the ledger while the initial tail is loading', () => {
|
||||
const view = render(
|
||||
<TrajectoryTable turns={TURNS} {...FOLD_PROPS} historyLoading />,
|
||||
|
||||
@@ -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-workspace/README.md
|
||||
README.md: 9d7d4d77cc064146f1fdaed615509215c64308fc
|
||||
README.zh.md: ca35d7cd2e7ff176f4ea40d1e9a3a6d1a7457462
|
||||
README.md: 8878aa49dcccd60ddcde5f0a9563bbfbd969c9c0
|
||||
README.zh.md: c83f1c53d5471dd8d52b933163a573eb98a70c2e
|
||||
|
||||
@@ -6,7 +6,7 @@ Shared Workspace browser and picker plugin. `WorkspaceBrowser` fills the sidebar
|
||||
|
||||
The browser renders grouped or flat Session rows from the global runtime hooks and owns Workspace add/rename/reorder plus Session reorder. A Workspace remembers whether it is closed or showing Sessions; an open Workspace shows five Sessions by default, offers a transient **Show more** control for the remainder, and returns to five after the whole Workspace is closed and reopened. Creating a Session from a Workspace row first opens that group so the new row remains visible when the Session state arrives. Once the Workspace list baseline is ready, browser-persisted expansion and Session-order records retain only current Workspace ids plus Ungrouped and the flat-list account. View options combine grouping with one browser-persisted Session order per account: real Workspaces initialize from `WorkspaceView.sessionIds`, while Ungrouped and the cross-Workspace flat list initialize from recency. **Manual** and **Last updated** apply in either presentation. Entering Last updated performs a complete recency sort and later user prompts or steers promote their Session once, while entering Manual preserves every current position and disables later promotion. Dragging edits the current order in either mode; Manual-mode drags for real Workspaces also update the Host Session account, while Ungrouped and flat-list orders remain browser-local because neither has one Workspace account. Flat rows omit the empty leading status slot because they have no parent hierarchy, but retain it when a Session status is visible. Workspace drag order is Host-durable in either Session order mode.
|
||||
|
||||
Collapsed search is one header action beside the view and add actions. Activating it expands the field across the header; an outside click collapses only a query that is empty after trimming, while the clear control always resets and collapses it. A non-blank search query replaces either browsing mode with one flat result list: case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. The English search input and its defensive request path remove NUL, cap the query at the wire schema's 500 UTF-16 code units without splitting a surrogate pair, and preserve the existing debounce and cancellation behavior. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20, asks the user to narrow broader queries, and opens the selected Session without clearing the query or jumping to a specific event.
|
||||
Collapsed search is one header action beside the view and add actions. In the rail, add and search render as 36px controls on the shell's shared horizontal entry path. Activating search expands the field across the header; an outside click collapses only a query that is empty after trimming, while the clear control always resets and collapses it. A non-blank search query replaces either browsing mode with one flat result list: case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. The English search input and its defensive request path remove NUL, cap the query at the wire schema's 500 UTF-16 code units without splitting a surrogate pair, and preserve the existing debounce and cancellation behavior. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20, asks the user to narrow broader queries, and opens the selected Session without clearing the query or jumping to a specific event.
|
||||
|
||||
The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Distinct canonical paths remain separate id-keyed Workspaces when their basenames and display titles match; the sidebar hover detail exposes the full path. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Add workspace...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default, under which the sidebar header drops its add button rather than offering a dead one). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. Adding has exactly one route: the occupant's own create-folder affordance already covers a brand-new directory, so no separate create-by-name dialog exists. A menu only appears where there is something to choose between — with no Workspace listed, the anchor gesture raises the flow directly instead of a one-row popover, and it waits for the list baseline before treating an empty list as final. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. The Session row's Archive action commits without a confirmation dialog (non-destructive: the log and the workspace accounting slot remain) through `ctx.workspaces.archiveSession`; the row disappears from every grouping surface — workspace groups, Ungrouped, content search, and the flat list — when the archive-set echo lands, and failures are console diagnostics that leave the tree unchanged. A blank New Session row is a pure placeholder: it renders no row menu and no time label (nothing has happened in it yet), so rename, fork, and archive first apply once the first prompt lands.
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 添加/重命名/重排序以及 Session 重排序。每个 Workspace 会记住自身是关闭还是显示 Session;打开后默认显示五条 Session,其余条目通过临时的**展开其余**控件显示,而关闭并重新打开整个 Workspace 后会恢复为五条。从 Workspace 行创建 Session 时会先打开该分组,使 Session 状态到达后新行保持可见。Workspace 列表基线就绪后,浏览器持久化的展开状态与 Session 顺序记录只保留当前 Workspace id、Ungrouped 和单列表记账。视图选项把分组方式和每个记账各自的一份浏览器持久化 Session 顺序放在一起:真实 Workspace 从 `WorkspaceView.sessionIds` 初始化,Ungrouped 和跨 Workspace 的单列表则从最近更新时间顺序初始化。**手动排序**和**最近更新**在两种呈现方式下都可用。进入最近更新时会执行一次完整的时间排序,后续 user prompt 或 steer 会将对应 Session 置顶一次;进入手动排序则保留所有当前位置并停用后续置顶。两种模式下的拖拽都会编辑当前顺序;真实 Workspace 在手动模式下的拖拽还会更新 Host Session 记账,而 Ungrouped 和单列表因没有单一 Workspace 记账,其顺序始终只保存在浏览器本地。单列表没有父级层次,因此不显示空的左侧状态槽;Session 存在可见状态时仍保留该槽。无论采用哪种 Session 顺序,Workspace 拖拽顺序都由 Host 持久化。
|
||||
|
||||
折叠搜索是视图和添加操作旁的一枚区头按钮。激活后,输入框会扩展并占据区头;点击外部只会收起经清除首尾空白后为空的查询,而清除控件总会重置并收起搜索。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL,将查询限制在传输 schema 规定的 500 个 UTF-16 代码单元内且不会拆分代理项对,并保留现有的防抖与取消行为。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。
|
||||
折叠搜索是视图和添加操作旁的一枚区头按钮。在轨道中,添加和搜索会渲染为沿外壳共用横向进入路径移动的 36px 控件。激活搜索后,输入框会扩展并占据区头;点击外部只会收起经清除首尾空白后为空的查询,而清除控件总会重置并收起搜索。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL,将查询限制在传输 schema 规定的 500 个 UTF-16 代码单元内且不会拆分代理项对,并保留现有的防抖与取消行为。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。
|
||||
|
||||
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。不同的规范化路径即使 basename 和显示标题相同,仍会作为由 id 区分的独立 Workspace;侧边栏的悬停详情会显示完整路径。每个注册各自声明一个**目录流子 slot**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **添加工作区…** 操作仅在当前界面的 slot 被占用时渲染(每次菜单渲染读取占用状态;slot 为空意味着该组合没有目录选择能力——seam 文档化的无流程默认行为,此时侧边栏区头直接不渲染添加按钮,而非留下一个点了没反应的按钮)。本包持有触发与接纳:占用方通过 slot 的属主交互约定(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。添加只有一条路径:占用者自带的新建文件夹能力已经覆盖了全新目录,因此不再单设按名称创建的对话框。菜单只在确有多个目标可选时出现——没有 Workspace 可列时,锚点手势直接拉起流程,而不是弹出只有一行的浮层;在列表基线落地前,空列表不算最终结果。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。Session 行内的 Archive 操作不经确认对话框直接提交(非破坏性:日志和 workspace 记账席位保持不变),通过 `ctx.workspaces.archiveSession` 归档;归档集合回声落地后,该行从所有分组视图——workspace 分组、Ungrouped、内容搜索和平铺列表——中消失,失败只作为控制台诊断输出,树保持不变。空白的「新会话」行只是占位符:不渲染行菜单和时间标签(其中还没有发生任何事),重命名、fork 和归档都从首条提示词落地后才可用。
|
||||
|
||||
|
||||
@@ -240,11 +240,13 @@
|
||||
|
||||
/* Rail variant (own .rail class from the wide owner prop — the region never
|
||||
reads the shell's class names): the two icon controls stack as 36x36
|
||||
circles matching the shell's rail rhythm. */
|
||||
circles matching the shell's rail rhythm. Both use the rail's shared base
|
||||
left anchor so the outer shell can translate the whole region uniformly. */
|
||||
.rail .sectionHeader {
|
||||
gap: 0;
|
||||
padding-left: 0;
|
||||
margin-bottom: 12px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.rail .headerActions {
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
* `sidebar.workspaces` hole: section header (title + view options + add
|
||||
* workspace), search, the grouped tree or flat list, and the workspace
|
||||
* dialogs. Wide state renders the full browser; rail state renders the two
|
||||
* region icons (search / add workspace), each requesting shell expansion
|
||||
* through the owner share. Adding is the header button's one action, so it
|
||||
* raises the directory flow with no menu in between; the flow and its error
|
||||
* dialog live in WorkspacePicker (same package — direct composition, no slot
|
||||
* between them).
|
||||
* region icons (search / add workspace) as 36px controls on the shell's shared
|
||||
* rail entry path, each requesting expansion through the owner share. Adding
|
||||
* is the header button's one action, so it raises the directory flow with no
|
||||
* menu in between; the flow and its error dialog live in WorkspacePicker
|
||||
* (same package — direct composition, no slot between them).
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
@@ -107,4 +107,10 @@ describe('WorkspaceBrowser.module.css list', () => {
|
||||
expect(rowDeclarations('.sessionRow.selected')?.get('background'))
|
||||
.toBe('var(--dsw-alias-interactive-bg-hover)')
|
||||
})
|
||||
|
||||
it('pins both rail controls to the shared left anchor during the column slide', () => {
|
||||
expect(declarations('.rail .sectionHeader')?.get('justify-content')).toBe('flex-start')
|
||||
expect(declarations('.rail .iconButton')?.get('width')).toBe('36px')
|
||||
expect(declarations('.rail .search')?.get('width')).toBe('36px')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user