mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-14 04:01:35 +00:00
Merge remote-tracking branch 'refs/remotes/origin/pr-3910' into fix/sidebar-preview-layout-polish
This commit is contained in:
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-10-guide-start-page-and-stat-pill-refinements.md
|
||||
2026-09-10-guide-start-page-and-stat-pill-refinements.md: 1d537479c13b44c57bbc9ba7a0dbe907b2c4c582
|
||||
2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md: a4c261bb90ff36f1818d07762646cba3516f5316
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
# Agent Note: Guide start page and stat pill refinements
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-10-guide-start-page-and-stat-pill-refinements.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The right Sidebar's guide tab was a bare list of entry capsules: no visual anchor above them, a capsule could only say its title, and an entry whose type registered no glyph rendered with no icon at all, so a mixed list read as broken rather than sparse. Separately, two chat stat surfaces showed noise: the session token-usage dialog under the composer printed a `Cache write 0 tok` row for sessions that never wrote cache, and the per-turn time pill always opened a dialog even when that dialog held a single row repeating the pill's own label — same formatter, same value — so the click bought nothing.
|
||||
|
||||
## Decision
|
||||
|
||||
**The guide is a compass over self-describing capsules.** [GuideBody.tsx](../../../../packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx) draws a muted 56px compass hero over the entry capsules and no heading, as a browser start page shows its doors without a caption. [`SidebarRightGuideEntry`](../../../../packages/client/ui-sidebar-right/src/client/tab-registry.ts) gains an optional thunked `description` — read fresh on every render like `title`, so language changes need no re-registration. A capsule shows its description under the title only while the guide lists at most `MAX_DESCRIBED_ENTRIES` (4) entries; a longer list drops every description to stay light, so a type must stand on its title. The glyph rides the capsule's height: 22px beside a bare title, 26px beside two lines.
|
||||
|
||||
**Icon-less entries fall back to a shipped cube placeholder.** The fallback is decided at the render site (`entry.icon ?? CubeGlyph`), not at registration, so every contributor — builtin or extension — gets it uniformly and a chain replacement of the body replaces the rule with it. `CubeGlyph` lives beside `CompassGlyph` in [GuideTitle.tsx](../../../../packages/client/ui-sidebar-right/src/client/tabs/guide/GuideTitle.tsx): an isometric box in 1.1px straight strokes with rounded joins on `currentColor`, drawn on `--dsw-alias-label-tertiary` — one step quieter than a registered glyph's ink — to mark the slot as unclaimed. The files type registers a description and the shared folder glyph in [definition.tsx](../../../../packages/client/ui-sidebar-files/src/client/definition.tsx).
|
||||
|
||||
**The session usage dialog drops the zero cache-write row.** [StatsPills.tsx](../../../../packages/client/ui-chat/src/client/chat/StatsPills.tsx) renders the `Cache write` row only when `cacheWriteTokens !== 0`, as the per-turn panel already drops its absent optional fields; the always-present buckets (input, cache read, output) keep their rows.
|
||||
|
||||
**The turn-time pill goes static when its dialog would only repeat it.** [TurnUsagePanel.tsx](../../../../packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx)'s `TurnTimePanel` renders plain text — no hover, no dialog — exactly when both `tokensPerSecond` and `ttftMs` are undefined, because the dialog's only unconditional row is the duration the pill already shows through the same formatter. With either figure recorded the pill stays a button. The static variant rides the `.trigger` class for geometry and a `.static` class quiets its hover and pointer, keeping the narrow-viewport icon collapse.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Register the cube in `ui-primitives`.** Its `icons/index.tsx` is the imported figma `ic_ds_*` set, and the cube has a single consumer; `CompassGlyph` set the precedent of package-local guide glyphs.
|
||||
|
||||
**Default the icon at registration time.** A `?? default` inside the registry would hide the fallback from the body and make "registered no glyph" undetectable, losing the quieter placeholder ink; explicit render-site fallback keeps registrations honest.
|
||||
|
||||
**Show `Cache write 0`.** A session on a provider that never writes cache would carry the row forever; zero here means "not a thing", not a measurement.
|
||||
|
||||
**Keep the one-row time dialog.** A dialog whose whole content duplicates its trigger label teaches users that the affordance is empty; hiding the affordance is the honest signal.
|
||||
|
||||
## Consequences
|
||||
|
||||
`description` is new pre-stable registry API; every consumer was updated (the files entry registers one). The 4-entry threshold is a shipped constant of the guide body, not configuration. Guide-body specs cover the placeholder (size and ink), the description threshold on both sides, and the registered-glyph path; chat-stats specs cover the dropped and present cache-write row; turn-usage-panel specs cover the static pill and both one-figure dialogs. The `ui-sidebar-right` and `ui-sidebar-files` READMEs restate the guide rules.
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
# Agent Note: 引导起始页与统计 pill 的细化
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-10-guide-start-page-and-stat-pill-refinements.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
右侧边栏的引导 tab 只是一列光秃秃的入口胶囊:上方没有视觉锚点,胶囊只能显示标题,而类型没注册图标的入口干脆不画图标,混合列表看起来像坏了而不是稀疏。另外两个聊天统计面有噪音:输入框下方的会话 token 用量弹窗给从未写入缓存的会话打印一行 `Cache write 0 tok`;turn 级用时 pill 总是可以点开弹窗,即使弹窗只有一行、且内容重复 pill 自己的标签——同一格式化函数、同一数值——点击毫无所得。
|
||||
|
||||
## Decision
|
||||
|
||||
**引导页是罗盘压着能自我说明的胶囊。** [GuideBody.tsx](../../../../packages/client/ui-sidebar-right/src/client/tabs/guide/GuideBody.tsx) 在入口胶囊上方画一枚弱化的 56px 罗盘,不加标题,如同浏览器起始页不给它的入口配说明文字。[`SidebarRightGuideEntry`](../../../../packages/client/ui-sidebar-right/src/client/tab-registry.ts) 新增可选的 thunk 化 `description`——与 `title` 一样每次渲染重新读取,语言切换无需重新注册。仅当引导页列出的入口不超过 `MAX_DESCRIBED_ENTRIES`(4)个时,胶囊才在标题下显示描述;更长的列表去掉所有描述以保持轻盈,所以类型必须靠标题立得住。图标随胶囊高度变化:单行标题旁 22px,两行旁 26px。
|
||||
|
||||
**没有图标的入口回退到内置的立方体占位符。** 回退在渲染点决定(`entry.icon ?? CubeGlyph`)而不在注册时,因此每个贡献者——内置或扩展——得到统一的占位符,链式替换 body 时规则随之整体替换。`CubeGlyph` 与 `CompassGlyph` 一起放在 [GuideTitle.tsx](../../../../packages/client/ui-sidebar-right/src/client/tabs/guide/GuideTitle.tsx):一只等距视角的盒子,1.1px 直线描边、圆角拼接、走 `currentColor`,用 `--dsw-alias-label-tertiary` 着色——比注册图标的墨色浅一档——标记这个槽位无人认领。files 类型在 [definition.tsx](../../../../packages/client/ui-sidebar-files/src/client/definition.tsx) 注册了描述和共享的文件夹图标。
|
||||
|
||||
**会话用量弹窗去掉为零的缓存写入行。** [StatsPills.tsx](../../../../packages/client/ui-chat/src/client/chat/StatsPills.tsx) 仅当 `cacheWriteTokens !== 0` 时渲染 `Cache write` 行,与 per-turn 面板去掉缺失可选字段的做法一致;始终存在的桶(输入、缓存读取、输出)保留各自的行。
|
||||
|
||||
**turn 用时 pill 在弹窗只会重复自己时变为静态。** [TurnUsagePanel.tsx](../../../../packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx) 的 `TurnTimePanel` 恰好在 `tokensPerSecond` 与 `ttftMs` 都缺失时渲染纯文本——无 hover、无弹窗——因为弹窗唯一无条件的行就是 pill 已经用同一格式化函数展示的时长。两者任一有记录时 pill 仍是按钮。静态形态复用 `.trigger` 类保持几何尺寸,再用 `.static` 类压掉 hover 和指针,窄视口的图标折叠行为保持不变。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**把立方体注册进 `ui-primitives`。** 其 `icons/index.tsx` 是导入的 figma `ic_ds_*` 集合,而立方体只有一个消费者;`CompassGlyph` 已开了引导图标包内自持的先例。
|
||||
|
||||
**在注册时默认图标。** 注册表内部的 `?? default` 会对 body 隐藏回退,让"没注册图标"无法辨认,浅色占位墨色随之丢失;显式的渲染点回退让注册保持诚实。
|
||||
|
||||
**显示 `Cache write 0`。** 用不写缓存的 provider 的会话会永远挂着这一行;这里的零意味着"没有这回事",不是一次测量。
|
||||
|
||||
**保留单行的用时弹窗。** 全部内容都在重复触发器标签的弹窗会教用户认为这个交互是空的;隐藏交互本身才是诚实的信号。
|
||||
|
||||
## Consequences
|
||||
|
||||
`description` 是新的 pre-stable 注册表 API;所有消费者已同步更新(files 入口注册了一个)。4 个入口的阈值是引导 body 的内置常量,不是配置。guide-body 用例覆盖占位符(尺寸与墨色)、描述阈值两侧和已注册图标路径;chat-stats 用例覆盖缓存写入行的去除与保留;turn-usage-panel 用例覆盖静态 pill 和两种单数据弹窗。`ui-sidebar-right` 与 `ui-sidebar-files` 的 README 重述了引导页规则。
|
||||
@@ -282,8 +282,10 @@ function UsagePill({ usage, t, dialog }: {
|
||||
</div>
|
||||
<div className={dialogCss.titleRule} aria-hidden />
|
||||
{/* jscpd:ignore-start -- the session-total bucket rows deliberately mirror
|
||||
TurnUsagePanel's per-turn dl: same skin, different data contract (all
|
||||
buckets always present here; per-turn fields are optional). */}
|
||||
TurnUsagePanel's per-turn dl: same skin, different data contract (the
|
||||
buckets are always present here; per-turn fields are optional). A
|
||||
session that never wrote cache drops the row, as the per-turn panel
|
||||
drops its absent fields. */}
|
||||
<dl className={dialogCss.details} data-session-stats-usage>
|
||||
{cacheHit !== null && (
|
||||
<>
|
||||
@@ -295,8 +297,12 @@ function UsagePill({ usage, t, dialog }: {
|
||||
<dd>{exactCount(usage.uncachedInputTokens, t)}</dd>
|
||||
<dt>{t('message.turnUsage.cacheRead')}</dt>
|
||||
<dd>{exactCount(usage.cacheReadTokens, t)}</dd>
|
||||
<dt>{t('message.turnUsage.cacheWrite')}</dt>
|
||||
<dd>{exactCount(usage.cacheWriteTokens, t)}</dd>
|
||||
{usage.cacheWriteTokens !== 0 && (
|
||||
<>
|
||||
<dt>{t('message.turnUsage.cacheWrite')}</dt>
|
||||
<dd>{exactCount(usage.cacheWriteTokens, t)}</dd>
|
||||
</>
|
||||
)}
|
||||
<dt>{t('message.turnUsage.output')}</dt>
|
||||
<dd>{exactCount(usage.outputTokens, t)}</dd>
|
||||
</dl>
|
||||
|
||||
@@ -54,6 +54,18 @@
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* The time pill while it has no dialog to open (neither speed nor TTFT
|
||||
recorded): rides .trigger for geometry, and quiets its hover and pointer.
|
||||
Declared after the hover rules so the equal-specificity override wins. */
|
||||
.static {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.static:hover {
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* Narrow viewport: the pills collapse to bare icons with the sibling
|
||||
`.action` geometry (28px circle, 6px padding, centered glyph); the
|
||||
label-padding rebate no longer applies, so the pair keeps the row's plain
|
||||
|
||||
@@ -126,11 +126,26 @@ export function TurnUsagePanel({ usage, t }: TurnUsagePanelProps) {
|
||||
|
||||
/**
|
||||
* Turn-time IconActions pill with a click-open Turn-time details dialog.
|
||||
*
|
||||
* With neither speed nor TTFT recorded the dialog would hold one row repeating
|
||||
* the pill's own duration (same formatter, same value), so the pill renders as
|
||||
* plain text instead: no hover, no dialog.
|
||||
* @param props - Turn timing facts and locale seat.
|
||||
* @returns The clock-and-duration trigger and, while open, its portaled dialog anchored above the trigger.
|
||||
*/
|
||||
export function TurnTimePanel({ runMs, tokensPerSecond, ttftMs, t }: TurnTimePanelProps) {
|
||||
const { open, setOpen, rootRef, panelRef, pos } = useStatDialog()
|
||||
const label = t('message.ranFor', { duration: formatRunDuration(runMs, t) })
|
||||
if (tokensPerSecond === undefined && ttftMs === undefined) {
|
||||
return (
|
||||
<span className={css.root}>
|
||||
<span className={`${css.trigger} ${css.static}`}>
|
||||
<IconClockOutline16 />
|
||||
<span className={css.label}>{label}</span>
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span ref={rootRef} className={css.root}>
|
||||
<button
|
||||
@@ -141,7 +156,7 @@ export function TurnTimePanel({ runMs, tokensPerSecond, ttftMs, t }: TurnTimePan
|
||||
onClick={() => { setOpen(!open) }}
|
||||
>
|
||||
<IconClockOutline16 />
|
||||
<span className={css.label}>{t('message.ranFor', { duration: formatRunDuration(runMs, t) })}</span>
|
||||
<span className={css.label}>{label}</span>
|
||||
</button>
|
||||
{open && createPortal(
|
||||
<div
|
||||
|
||||
@@ -257,7 +257,8 @@ describe('StatsPills', () => {
|
||||
expect(tokens.textContent).toContain('Cache hit90%')
|
||||
expect(tokens.textContent).toContain('Uncached input10 tok')
|
||||
expect(tokens.textContent).toContain('Cached input90 tok')
|
||||
expect(tokens.textContent).toContain('Cache write0 tok')
|
||||
// A session that never wrote cache drops the row rather than showing 0.
|
||||
expect(tokens.textContent).not.toContain('Cache write')
|
||||
expect(tokens.textContent).toContain('Output5 tok')
|
||||
// The time split lives on the counts pill's own dialog, not here.
|
||||
expect(dialog.textContent).not.toContain('LLM time')
|
||||
@@ -428,6 +429,9 @@ describe('StatsPills', () => {
|
||||
},
|
||||
})} />)
|
||||
expect(view.getAllByRole('button')[0]!.textContent).toBe('207 tok·Cache hit 45%')
|
||||
// A session that did write cache keeps the row, exact.
|
||||
fireEvent.click(view.getAllByRole('button')[0]!)
|
||||
expect(view.getByRole('dialog').textContent).toContain('Cache write100 tok')
|
||||
})
|
||||
|
||||
it('renders ZERO times during streaming chunk frames (RFC hard acceptance)', () => {
|
||||
|
||||
@@ -122,12 +122,25 @@ describe('TurnTimePanel', () => {
|
||||
expect(view.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
|
||||
it('omits unrecorded speed and TTFT rows', () => {
|
||||
it('renders plain text without a dialog while only the duration is known', () => {
|
||||
const view = render(<TurnTimePanel runMs={3_000} t={t} />)
|
||||
fireEvent.click(view.getByRole('button'))
|
||||
const dialog = view.getByRole('dialog')
|
||||
expect(dialog.textContent).toContain('Total run time3s')
|
||||
expect(dialog.textContent).not.toContain('Tokens per second')
|
||||
expect(dialog.textContent).not.toContain('Time to first token')
|
||||
// The dialog would only repeat the pill's own duration, so there is no trigger at all.
|
||||
expect(view.queryByRole('button')).toBeNull()
|
||||
expect(view.container.textContent).toBe('Ran for 3s')
|
||||
})
|
||||
|
||||
it('keeps the dialog and omits the unrecorded row while one of speed and TTFT is known', () => {
|
||||
const speedOnly = render(<TurnTimePanel runMs={3_000} tokensPerSecond={20} t={t} />)
|
||||
fireEvent.click(speedOnly.getByRole('button'))
|
||||
expect(speedOnly.getByRole('dialog').textContent).toContain('Total run time3s')
|
||||
expect(speedOnly.getByRole('dialog').textContent).toContain('Tokens per second (TPS)20 tok/s')
|
||||
expect(speedOnly.getByRole('dialog').textContent).not.toContain('Time to first token')
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
speedOnly.unmount()
|
||||
|
||||
const ttftOnly = render(<TurnTimePanel runMs={3_000} ttftMs={800} t={t} />)
|
||||
fireEvent.click(ttftOnly.getByRole('button'))
|
||||
expect(ttftOnly.getByRole('dialog').textContent).not.toContain('Tokens per second')
|
||||
expect(ttftOnly.getByRole('dialog').textContent).toContain('Time to first token (TTFT)0.8s')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,11 +24,11 @@ The right Sidebar's navigator tab type: the session's workspace root as a tree,
|
||||
<a id="what-it-registers"></a>
|
||||
## What it registers
|
||||
|
||||
- **The type** — `ctx.sidebarRightTabs.register(...)` with kind `files`, id `@deepseek-ai/dsh-client-ui-sidebar-files`, band `builtin`, no patterns, and one guide entry (order 10, titled from the `sidebarFiles` namespace) that opens the type.
|
||||
- **The type** — `ctx.sidebarRightTabs.register(...)` with kind `files`, id `@deepseek-ai/dsh-client-ui-sidebar-files`, band `builtin`, no patterns, and one guide entry (order 10, its title and description from the `sidebarFiles` namespace, its glyph the shared folder icon) that opens the type.
|
||||
- **The body** — the keyed `sidebar.right.pane.tab` seat under that id: a header row under the strip, then the tree. The header row is the document preview's (`ui-sidebar-documentpreview`): the root path, its directories greyed and its last segment in full ink, never ellipsized (a path wider than the row keeps its end and fades its start), with the one control, reload, at its right. The row is copied rather than shared because a plugin bundle shares runtime code only through the platform modules; once the artifact and slot surfaces settle, one copy in `ui-primitives` could serve every pane header.
|
||||
- **The chip title** — the keyed `sidebar.right.pane.tab.title` seat under that id: a shared `FileTypeIcon` folder glyph at 16px before the type's label. The tree's own rows never draw this sheet.
|
||||
|
||||
Seven source files under `src/client/`: `definition.ts` (the type), `store.ts` (what it keeps), `face.ts` (how it lists, Remote binding included), `FilesBody.tsx` (what it draws, with its ordering and failure-line helpers), `FilesTitle.tsx` (the chip title), `locales.ts` (what it says), and `index.ts` (the wiring).
|
||||
Seven source files under `src/client/`: `definition.tsx` (the type), `store.ts` (what it keeps), `face.ts` (how it lists, Remote binding included), `FilesBody.tsx` (what it draws, with its ordering and failure-line helpers), `FilesTitle.tsx` (the chip title), `locales.ts` (what it says), and `index.ts` (the wiring).
|
||||
|
||||
<a id="the-tree"></a>
|
||||
## The tree
|
||||
|
||||
@@ -24,11 +24,11 @@ kind: "package-reference"
|
||||
<a id="what-it-registers"></a>
|
||||
## 注册了什么
|
||||
|
||||
- **类型**:`ctx.sidebarRightTabs.register(...)`,kind 为 `files`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-files`,档位 `builtin`,没有 patterns,另有一个打开该类型的引导页入口(order 10,标题取自 `sidebarFiles` 命名空间)。
|
||||
- **类型**:`ctx.sidebarRightTabs.register(...)`,kind 为 `files`,id 为 `@deepseek-ai/dsh-client-ui-sidebar-files`,档位 `builtin`,没有 patterns,另有一个打开该类型的引导页入口(order 10,标题与描述取自 `sidebarFiles` 命名空间,图标是共享的文件夹图标)。
|
||||
- **正文**:以该 id 为键的 `sidebar.right.pane.tab` 坑位:strip 下的一行标题行,然后是树。标题行与文档预览(`ui-sidebar-documentpreview`)的相同:根路径,目录部分灰色、最后一段正色,从不省略号截断(比行宽的路径保留末尾、淡出开头),右端是它唯一的控件、重新读取。这一行是复制而非共享,因为插件 bundle 只经平台模块共享运行时代码;待 artifact 与各 slot 的形态定下来后,可以在 `ui-primitives` 放一份供每个 pane 标题行使用。
|
||||
- **标签页标题**:以该 id 为键的 `sidebar.right.pane.tab.title` 坑位:类型标签前的一枚 16px 共享 `FileTypeIcon` 文件夹图标。树本身的行不画这枚图标。
|
||||
|
||||
`src/client/` 下七个源文件:`definition.ts`(类型是什么)、`store.ts`(它保存什么)、`face.ts`(它如何列目录,含 Remote 绑定)、`FilesBody.tsx`(它画什么,含排序与失败行两个辅助函数)、`FilesTitle.tsx`(标签页标题)、`locales.ts`(它说什么)、`index.ts`(接线)。
|
||||
`src/client/` 下七个源文件:`definition.tsx`(类型是什么)、`store.ts`(它保存什么)、`face.ts`(它如何列目录,含 Remote 绑定)、`FilesBody.tsx`(它画什么,含排序与失败行两个辅助函数)、`FilesTitle.tsx`(标签页标题)、`locales.ts`(它说什么)、`index.ts`(接线)。
|
||||
|
||||
<a id="the-tree"></a>
|
||||
## 树
|
||||
|
||||
+8
-2
@@ -8,7 +8,7 @@
|
||||
import type { SidebarRightTabDefinition } from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
|
||||
import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type {} from './locales.ts'
|
||||
import { IconFolderClose16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { FileTypeIcon, type IconProps } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
|
||||
/** The tab kind this package owns. */
|
||||
export const FILES_KIND = 'files'
|
||||
@@ -16,6 +16,11 @@ export const FILES_KIND = 'files'
|
||||
/** This implementation's identity in the tab system, and the key its body registers under. */
|
||||
export const FILES_ID = '@deepseek-ai/dsh-client-ui-sidebar-files'
|
||||
|
||||
/** The type's coloured folder sheet at the guide capsule's glyph size, as the chip title draws it. */
|
||||
function FolderSheetGlyph({ size, className }: IconProps) {
|
||||
return <FileTypeIcon kind="folder" size={size} className={className} />
|
||||
}
|
||||
|
||||
/**
|
||||
* The files type's registry definition.
|
||||
* @param t - namespace-bound translate, read fresh on every label call.
|
||||
@@ -30,7 +35,8 @@ export function filesDefinition(t: TranslateNS<'sidebarFiles'>): SidebarRightTab
|
||||
guide: [{
|
||||
order: 10,
|
||||
title: () => t('guide.title'),
|
||||
icon: IconFolderClose16,
|
||||
description: () => t('guide.description'),
|
||||
icon: FolderSheetGlyph,
|
||||
}],
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
* the keyed `sidebar.right.pane.tab.title` seat, both under the type's `id`.
|
||||
*
|
||||
* The file split is this package's layering: what the type IS
|
||||
* (`definition.ts`), what it keeps (`store.ts`), how it lists (`face.ts`), what
|
||||
* (`definition.tsx`), what it keeps (`store.ts`), how it lists (`face.ts`), what
|
||||
* it draws (`FilesBody.tsx`, `FilesTitle.tsx`), what it says (`locales.ts`),
|
||||
* and this module, which only wires them together.
|
||||
*/
|
||||
@@ -15,7 +15,7 @@ import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-session/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
|
||||
import { FILES_ID, filesDefinition } from './definition.ts'
|
||||
import { FILES_ID, filesDefinition } from './definition.tsx'
|
||||
import { createList, filesFace } from './face.ts'
|
||||
import { FilesBody } from './FilesBody.tsx'
|
||||
import { FilesTitle } from './FilesTitle.tsx'
|
||||
|
||||
@@ -22,6 +22,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
export const zh = {
|
||||
'type.label': '文件',
|
||||
'guide.title': '工作区文件',
|
||||
'guide.description': '浏览会话工作区的文件',
|
||||
loading: '正在读取…',
|
||||
empty: '空目录',
|
||||
truncated: '条目太多,只显示了一部分。',
|
||||
@@ -41,6 +42,7 @@ export type SidebarFilesKey = keyof typeof zh
|
||||
export const en = {
|
||||
'type.label': 'Files',
|
||||
'guide.title': 'Workspace files',
|
||||
'guide.description': 'Browse files in this session\'s workspace',
|
||||
loading: 'Reading…',
|
||||
empty: 'Empty directory',
|
||||
truncated: 'Too many entries, showing only some of them.',
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { SidebarRightTabRegistry } from '@deepseek-ai/dsh-client-ui-sidebar-right/src/client/tab-registry.ts'
|
||||
import { FILES_ID, FILES_KIND } from '../src/client/definition.ts'
|
||||
import { FILES_ID, FILES_KIND } from '../src/client/definition.tsx'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
import { apply as hostApply } from '../src/index.ts'
|
||||
import { FilesBody } from '../src/client/FilesBody.tsx'
|
||||
@@ -69,7 +69,8 @@ describe('ui-sidebar-files apply', () => {
|
||||
expect(definition?.id).toBe(FILES_ID)
|
||||
expect(definition?.priority).toBe('builtin')
|
||||
expect(definition?.title('sidebar://files')).toBe('type.label')
|
||||
expect(definition?.guide?.map(entry => [entry.order, entry.title()])).toEqual([[10, 'guide.title']])
|
||||
expect(definition?.guide?.map(entry => [entry.order, entry.title(), entry.description?.()]))
|
||||
.toEqual([[10, 'guide.title', 'guide.description']])
|
||||
expect(dictionaries.get('sidebarFiles')).toEqual({ zh, en })
|
||||
// The seat key is the implementation's id, not the kind: an extension may
|
||||
// take the kind over, and the seat must still find this body.
|
||||
|
||||
@@ -10,7 +10,7 @@ import { SidebarRightTabRegistry } from '@deepseek-ai/dsh-client-ui-sidebar-righ
|
||||
import { sessionFileAddress } from '@deepseek-ai/dsh-util-workspace-path'
|
||||
import {
|
||||
FILES_ID, FILES_KIND, filesDefinition,
|
||||
} from '../src/client/definition.ts'
|
||||
} from '../src/client/definition.tsx'
|
||||
import { zh } from '../src/client/locales.ts'
|
||||
|
||||
const t = makeTranslate(zh)
|
||||
@@ -31,6 +31,7 @@ describe('filesDefinition', () => {
|
||||
expect(entry?.order).toBe(10)
|
||||
expect(entry?.kind).toBe(FILES_KIND)
|
||||
expect(entry?.title()).toBe(zh['guide.title'])
|
||||
expect(entry?.description?.()).toBe(zh['guide.description'])
|
||||
expect(entry?.icon).toBeDefined()
|
||||
})
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ The Tab domain retains navigation, an abort signal, and bound actions per (Sessi
|
||||
|
||||
Default pages depend on the number of registered guide entries, not the number of tab types or open tabs. Exactly one entry opens its page directly (Files in the shipped composition); zero or multiple entries open the guide. Explicitly adding a guide still opens the guide, even with one entry. The sole docked guide is the only tab that cannot close; closing any other sole tab also collapses the column. The chip, context menu, and `close` API apply the same rule.
|
||||
|
||||
The guide tab is one entry capsule (a glyph and a title, nothing else) per `guide` entry the registered types contributed, in `order`, centred in the body; the guide has no words of its own. Picking a capsule calls `tab.actions.openTab(entry.kind, { replaceTab: true })`, so the guide gives way to the page it opened. A pane holds at most one guide tab. The strip's add control is drawn only while its pane holds none and opens one there with `openTab('guide', { paneId, revealIfOpened: false })`, so a guide in another pane does not capture the click; opening the guide into a pane that already has one focuses it instead; a guide dragged, dropped, or docked into such a pane merges into it — the arriving guide closes and the pane's own is focused; `duplicateTab` on the guide records nothing. A split, an expanded empty root pane, and the pane a sole tab vacates by dropping on its own edge use the same default-page rule, one tab per new pane; the self-edge drop leaves the dragged tab focused. A plain `openTab('guide')` opens or focuses the guide only within the active or named pane. Splitting an empty pane does nothing and returns no new pane. The product allows two horizontal panes, initially equal, with divider ratios limited to 20%–80%. Insufficient width blocks a new split; with two panes already present, a body drop moves the tab between panes instead of creating a third. At the two-pane limit, split controls are hidden; closing back to one pane restores them.
|
||||
The guide tab is a muted compass over one entry capsule per `guide` entry the registered types contributed, in `order`, centred in the body; the guide has no words of its own. A capsule shows the entry's glyph — or the guide's quieter cube placeholder when the entry registered none — and its title; while at most four entries are listed, an entry that registered a `description` shows it under the title, and a longer list drops every description. Picking a capsule calls `tab.actions.openTab(entry.kind, { replaceTab: true })`, so the guide gives way to the page it opened. A pane holds at most one guide tab. The strip's add control is drawn only while its pane holds none and opens one there with `openTab('guide', { paneId, revealIfOpened: false })`, so a guide in another pane does not capture the click; opening the guide into a pane that already has one focuses it instead; a guide dragged, dropped, or docked into such a pane merges into it — the arriving guide closes and the pane's own is focused; `duplicateTab` on the guide records nothing. A split, an expanded empty root pane, and the pane a sole tab vacates by dropping on its own edge use the same default-page rule, one tab per new pane; the self-edge drop leaves the dragged tab focused. A plain `openTab('guide')` opens or focuses the guide only within the active or named pane. Splitting an empty pane does nothing and returns no new pane. The product allows two horizontal panes, initially equal, with divider ratios limited to 20%–80%. Insufficient width blocks a new split; with two panes already present, a body drop moves the tab between panes instead of creating a third. At the two-pane limit, split controls are hidden; closing back to one pane restores them.
|
||||
|
||||
<a id="copy"></a>
|
||||
## Copy
|
||||
|
||||
@@ -98,7 +98,7 @@ Tab域按(Session,Tab id)保留导航、中止信号与绑定动作;私
|
||||
|
||||
默认页取决于已注册的引导入口数,不取决于 tab 类型数或已打开的 tab 数。恰好一个入口时直接打开对应页面(随包组合中为 Files);没有入口或有多个入口时打开引导页。即使只有一个入口,显式添加引导页仍会打开引导页。只有作为唯一停靠 tab 的引导页不可关闭;关闭其它任何唯一 tab 时会同时收起整列。chip、上下文菜单与 `close` API 使用同一规则。
|
||||
|
||||
引导 tab 是各已注册类型贡献的每个 `guide` 条目一个入口胶囊(一个图标加一个标题,别无其他),按 `order` 排列并在正文中居中;引导页自己没有文字。选中一个胶囊会调用 `tab.actions.openTab(entry.kind, { replaceTab: true })`,于是引导页让位给它打开的页。一个格最多持有一个引导 tab。tab 条的添加控件只在该格没有引导 tab 时绘制,并以 `openTab('guide', { paneId, revealIfOpened: false })` 在该格打开一个,这样别的格里的引导页不会截走这次点击;把引导页开进已有引导页的格则改为聚焦它;把引导页拖入、放入或收回到这样的格会合并进去——来者关闭,该格自己的被聚焦;对引导页 `duplicateTab` 不记录任何东西。分栏、展开且为空的根格、以及唯一 tab 拖到本格边缘分屏后腾出的格使用相同的默认页规则,每个新格一个 tab;这种本格边缘拖放让被拖的 tab 保持聚焦。普通的 `openTab('guide')` 只在活跃或指定分栏内打开或聚焦引导页。对空分栏执行 split 不产生变化,也不返回新分栏。产品最多保留左右两格,默认均分,分隔条限定20%~80%。宽度不足以容纳两格时不允许新分栏;已有两格时,正文拖放用于跨格移动,不再创建第三格。达到两格上限时隐藏分栏控件;关闭回单格后恢复。
|
||||
引导 tab 是一枚弱化的罗盘,下方是各已注册类型贡献的每个 `guide` 条目一个入口胶囊,按 `order` 排列并在正文中居中;引导页自己没有文字。胶囊显示条目的图标——条目没注册图标时用引导页自己更浅的立方体占位符——和标题;列出的条目不超过四个时,注册了 `description` 的条目在标题下显示它,更长的列表则去掉所有描述。选中一个胶囊会调用 `tab.actions.openTab(entry.kind, { replaceTab: true })`,于是引导页让位给它打开的页。一个格最多持有一个引导 tab。tab 条的添加控件只在该格没有引导 tab 时绘制,并以 `openTab('guide', { paneId, revealIfOpened: false })` 在该格打开一个,这样别的格里的引导页不会截走这次点击;把引导页开进已有引导页的格则改为聚焦它;把引导页拖入、放入或收回到这样的格会合并进去——来者关闭,该格自己的被聚焦;对引导页 `duplicateTab` 不记录任何东西。分栏、展开且为空的根格、以及唯一 tab 拖到本格边缘分屏后腾出的格使用相同的默认页规则,每个新格一个 tab;这种本格边缘拖放让被拖的 tab 保持聚焦。普通的 `openTab('guide')` 只在活跃或指定分栏内打开或聚焦引导页。对空分栏执行 split 不产生变化,也不返回新分栏。产品最多保留左右两格,默认均分,分隔条限定20%~80%。宽度不足以容纳两格时不允许新分栏;已有两格时,正文拖放用于跨格移动,不再创建第三格。达到两格上限时隐藏分栏控件;关闭回单格后恢复。
|
||||
|
||||
<a id="copy"></a>
|
||||
## 文案
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
* builtin resumes when the extension unregisters. Everything else colliding on
|
||||
* a kind throws, as does a second registration of an `id`.
|
||||
*
|
||||
* Thunked copy (`title`, `guide[].title`) is read again on every use, so a
|
||||
* language change needs no re-registration.
|
||||
* Thunked copy (`title`, `guide[].title`, `guide[].description`) is read again
|
||||
* on every use, so a language change needs no re-registration.
|
||||
*/
|
||||
import type { ComponentType } from 'react'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
@@ -62,11 +62,18 @@ export interface SidebarRightGuideEntry {
|
||||
/** Ascending position among every registered type's entries. */
|
||||
readonly order: number
|
||||
/**
|
||||
* The capsule's title, its only words.
|
||||
* The capsule's title.
|
||||
* @returns the title in the current language.
|
||||
*/
|
||||
readonly title: () => string
|
||||
/** Optional glyph, drawn before the title. */
|
||||
/**
|
||||
* One line under the title on what picking the capsule opens. The guide shows
|
||||
* it only while it lists few enough entries to stay light; a crowded guide
|
||||
* falls back to titles alone, so a type must stand on its title.
|
||||
* @returns the description in the current language.
|
||||
*/
|
||||
readonly description?: () => string
|
||||
/** Optional glyph, drawn before the title; without one the guide draws its cube placeholder. */
|
||||
readonly icon?: ComponentType<IconProps>
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
/* The guide body's own sheet: its domain owns these rules, so the shell's
|
||||
stylesheet does not carry them. Tokens only. */
|
||||
|
||||
/* The capsules in one centred column, lifted above the body's middle by the
|
||||
spacer below — the empty-state offset. */
|
||||
/* The capsules in one centred column at the column's own 14px gap, lifted
|
||||
above the body's middle by the spacer below — the empty-state offset. */
|
||||
.guide {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
@@ -15,31 +15,48 @@
|
||||
}
|
||||
|
||||
/* The lift, as a share of the pane's height (a column flex basis resolves
|
||||
against it): the centred column rises 6% of the pane, and a short pane
|
||||
against it): the centred column rises 5% of the pane, and a short pane
|
||||
gives it up before the capsules clip. */
|
||||
.guide::after {
|
||||
content: '';
|
||||
flex: 0 1 12%;
|
||||
flex: 0 1 10%;
|
||||
}
|
||||
|
||||
/* A 48px capsule, 360px wide by default; `max-width: 100%` shrinks it inside
|
||||
the column's own 24px insets, so a narrow pane still keeps that gutter. A
|
||||
long title ellipsizes inside rather than widening it. */
|
||||
/* The guide tab's own compass above the capsules, at 56px on watermark ink —
|
||||
not a control. The ink is pinned to the pure-gray static ramp per theme
|
||||
because the label aliases all ride the bluish ramp, whose cast shows at
|
||||
this size. Its margin over the column's gap holds the first capsule 30px
|
||||
below it; the capsules keep the bare gap between them. */
|
||||
.hero {
|
||||
display: flex;
|
||||
margin-bottom: 16px;
|
||||
color: var(--dsw-static-neutral-200);
|
||||
}
|
||||
|
||||
:global(body[data-ds-dark-theme]) .hero {
|
||||
color: var(--dsw-static-neutral-700);
|
||||
}
|
||||
|
||||
/* A filled pill, 380px wide by default, its icon and text anchored at the
|
||||
left inset; `max-width: 100%` shrinks it inside the column's own 24px
|
||||
insets, so a narrow pane still keeps that gutter. The 56px minimum is the
|
||||
title-only height; a capsule with a description grows on its vertical
|
||||
padding. Long text ellipsizes inside rather than widening it. */
|
||||
.entry {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
width: 360px;
|
||||
width: 380px;
|
||||
max-width: 100%;
|
||||
height: 48px;
|
||||
padding: 0 20px;
|
||||
min-height: 56px;
|
||||
padding: 14px 20px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font: inherit;
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
border: 0.5px solid var(--dsw-alias-border-l4);
|
||||
border-radius: 20px;
|
||||
border-radius: 24px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -47,22 +64,55 @@
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* A fixed 26px box — the described glyph's size — so an entry's glyph sits
|
||||
centred and every capsule's words start at the same left edge while glyph
|
||||
sizes differ. */
|
||||
.entryIcon {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.entryTitle {
|
||||
/* The capsule's placeholder cube on ink one step quieter than a registered
|
||||
glyph's, marking the unclaimed slot. */
|
||||
.placeholderInk {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* The capsule's words in one column beside the glyph: the title, and the
|
||||
description while the guide shows one. */
|
||||
.entryText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.entryTitle {
|
||||
overflow: hidden;
|
||||
font-size: var(--dsh-content-font-size, 14px);
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* The chip title's planet, beside the label the strip lays out. */
|
||||
/* One line under the title on the quieter ink; it ellipsizes like the title. */
|
||||
.entryDescription {
|
||||
overflow: hidden;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* The chip title's compass, beside the label the strip lays out; the glyph
|
||||
draws on currentColor, so this ink is the chip rendering's own. */
|
||||
.titleIcon {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
@@ -8,8 +8,12 @@
|
||||
* entry of its own, so there is always exactly one body and the shipped one
|
||||
* cannot be outvoted by accident.
|
||||
*
|
||||
* The shipped guide is the entry capsules every registered type contributed,
|
||||
* centred in the body, and nothing else. Picking one opens that type as a page
|
||||
* The shipped guide is a muted compass over the entry capsules every
|
||||
* registered type contributed, centred in the body, and nothing else — no
|
||||
* heading, as a browser start page shows its doors without a caption. While
|
||||
* at most four entries are listed, a capsule with a description shows it
|
||||
* under the title; a longer list drops every description to stay light.
|
||||
* Picking one opens that type as a page
|
||||
* in this tab's place, so the guide is a doorway rather than a page that stays
|
||||
* open.
|
||||
*/
|
||||
@@ -17,6 +21,7 @@ import type { ReactNode } from 'react'
|
||||
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
||||
import type { ChainRenderOpts, HookContextOf, InjectFace, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SidebarRightGuideBox } from '../../tab-registry.ts'
|
||||
import { CompassGlyph, CubeGlyph } from './GuideTitle.tsx'
|
||||
import css from './GuideBody.module.css'
|
||||
|
||||
/** What the guide body needs from its host beyond the framework shares. */
|
||||
@@ -25,15 +30,23 @@ export interface GuideInjected {
|
||||
readonly hooks: { readonly guideEntries: ObservableSnapshot<readonly SidebarRightGuideBox[]> }
|
||||
}
|
||||
|
||||
/** The guide body's composed props: the tab it draws, its chain child, and the entries. Its words are the entries' own. */
|
||||
/** The guide body's composed props: the tab it draws, its chain child, and the entries. */
|
||||
export type GuideBodyProps =
|
||||
& PropsRuntime<'sidebar.right.pane.tab'>
|
||||
& PropsRenderSlots<'sidebar.right.tab.guide'>
|
||||
& InjectFace<GuideInjected>
|
||||
|
||||
/** One entry capsule: the contributing type's glyph and title. */
|
||||
function EntryBox({ entry, onPick }: { entry: SidebarRightGuideBox; onPick: (entry: SidebarRightGuideBox) => void }): ReactNode {
|
||||
const Icon = entry.icon
|
||||
/** Entry count past which the guide drops the capsules' descriptions to stay light. */
|
||||
const MAX_DESCRIBED_ENTRIES = 4
|
||||
|
||||
/** One entry capsule: the contributing type's glyph and title, and its description while the guide is short. */
|
||||
function EntryBox({ entry, described, onPick }: {
|
||||
entry: SidebarRightGuideBox
|
||||
described: boolean
|
||||
onPick: (entry: SidebarRightGuideBox) => void
|
||||
}): ReactNode {
|
||||
const Icon = entry.icon ?? CubeGlyph
|
||||
const description = described ? entry.description?.() : undefined
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -41,21 +54,30 @@ function EntryBox({ entry, onPick }: { entry: SidebarRightGuideBox; onPick: (ent
|
||||
data-sidebar-right-guide-entry={entry.kind}
|
||||
onClick={() => { onPick(entry) }}
|
||||
>
|
||||
{Icon !== undefined && <span className={css.entryIcon}><Icon size={16} /></span>}
|
||||
<span className={css.entryTitle}>{entry.title()}</span>
|
||||
{/* The glyph rides the capsule's height: 22 beside a bare title, 26 beside two lines. */}
|
||||
<span className={css.entryIcon}>
|
||||
<Icon size={description === undefined ? 22 : 26} className={entry.icon === undefined ? css.placeholderInk : undefined} />
|
||||
</span>
|
||||
<span className={css.entryText}>
|
||||
<span className={css.entryTitle}>{entry.title()}</span>
|
||||
{description !== undefined && <span className={css.entryDescription}>{description}</span>}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/** The shipped guide: the doors out of the column. */
|
||||
/** The shipped guide: the tab's own compass over the doors out of the column. */
|
||||
function ShippedGuide({ entries, onPick }: {
|
||||
entries: readonly SidebarRightGuideBox[]
|
||||
onPick: (entry: SidebarRightGuideBox) => void
|
||||
}): ReactNode {
|
||||
return (
|
||||
<div className={css.guide} data-sidebar-right-guide>
|
||||
<span className={css.hero} aria-hidden="true"><CompassGlyph size={56} /></span>
|
||||
{/* Keyed by position in the ordered list: one type may contribute several capsules, and `order` is not unique. */}
|
||||
{entries.map((entry, index) => <EntryBox key={`${entry.kind}:${index}`} entry={entry} onPick={onPick} />)}
|
||||
{entries.map((entry, index) => (
|
||||
<EntryBox key={`${entry.kind}:${index}`} entry={entry} described={entries.length <= MAX_DESCRIBED_ENTRIES} onPick={onPick} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -67,7 +89,8 @@ export function GuideBody({ useTabInfo, useGuideEntries, renderSlotChain }: Guid
|
||||
const options = {
|
||||
hookContext: useTabInfo,
|
||||
fallback: (
|
||||
<ShippedGuide entries={entries} onPick={(entry) => { tab.actions.openTab(entry.kind, { replaceTab: true }) }} />
|
||||
<ShippedGuide entries={entries}
|
||||
onPick={(entry) => { tab.actions.openTab(entry.kind, { replaceTab: true }) }} />
|
||||
),
|
||||
} satisfies ChainRenderOpts & { hookContext: HookContextOf<'sidebar.right.tab.guide'> }
|
||||
return renderSlotChain('sidebar.right.tab.guide', {}, options)
|
||||
|
||||
@@ -1,20 +1,53 @@
|
||||
/**
|
||||
* The guide type's chip title: the compass before the type's label. Registered
|
||||
* under `sidebar.right.pane.tab.title`; without it the chip would show the
|
||||
* bare label.
|
||||
* bare label. Both guide glyphs live here: the compass the chip and the body's
|
||||
* hero draw, and the cube the body's icon-less capsules fall back to.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import css from './GuideBody.module.css'
|
||||
|
||||
/**
|
||||
* The compass: a grey ring with the needle's rhombus pointing north-east.
|
||||
* The compass: a ring with the needle's rhombus pointing north-east, on
|
||||
* `currentColor` so each rendering picks its own ink.
|
||||
* @param props - rendered size and class.
|
||||
* @returns the compass glyph.
|
||||
*/
|
||||
function CompassGlyph(): ReactNode {
|
||||
export function CompassGlyph({ size = 16, className }: IconProps): ReactNode {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true" className={css.titleIcon}>
|
||||
<circle cx="8" cy="8" r="6" stroke="var(--dsw-alias-label-tertiary)" strokeWidth="1.4" />
|
||||
<path d="M 10.9 5.1 L 9.1 9.1 L 5.1 10.9 L 6.9 6.9 Z" fill="var(--dsw-alias-label-tertiary)" />
|
||||
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden="true" className={className}>
|
||||
<circle cx="8" cy="8" r="6" stroke="currentColor" strokeWidth="1.4" />
|
||||
<path d="M 10.9 5.1 L 9.1 9.1 L 5.1 10.9 L 6.9 6.9 Z" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The cube: an isometric box — hexagonal silhouette, the top face's two edges,
|
||||
* and the front seam — in straight strokes with softly rounded joins, on
|
||||
* `currentColor`. The guide body draws it in a capsule whose type registered
|
||||
* no glyph of its own.
|
||||
* @param props - rendered size and class.
|
||||
* @returns the cube glyph.
|
||||
*/
|
||||
export function CubeGlyph({ size = 16, className }: IconProps): ReactNode {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden="true" className={className}>
|
||||
<path
|
||||
d="M 8 2.5 L 12.9 5.2 V 10.8 L 8 13.5 L 3.1 10.8 V 5.2 Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.1"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M 3.1 5.2 L 8 7.9 L 12.9 5.2 M 8 7.9 V 13.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.1"
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -28,7 +61,7 @@ export function GuideTitle({ useTabInfo }: PropsRuntime<'sidebar.right.pane.tab.
|
||||
const { tab } = useTabInfo()
|
||||
return (
|
||||
<>
|
||||
<CompassGlyph />
|
||||
<CompassGlyph className={css.titleIcon} />
|
||||
{tab.title}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -95,7 +95,7 @@ describe('ui-sidebar-right apply', () => {
|
||||
expect(guide?.title('sidebar://guide')).toBe('tab.guide.title')
|
||||
// Five registrations: the root and panel seats, the header's corner seat,
|
||||
// and the guide body and chip title under the guide implementation's id.
|
||||
// The guide binds no dictionary: its only words are the entries' own.
|
||||
// The guide draws no product copy of its own, so neither guide seat binds the dictionary.
|
||||
expect(registered.map(entry => [entry.name, entry.key, entry.locale, entry.component])).toEqual([
|
||||
['rightbar', undefined, undefined, RightbarRoot],
|
||||
['rightbar.session', undefined, 'sidebarRight', RightbarSeat],
|
||||
|
||||
@@ -16,6 +16,7 @@ import { GuideBody } from '../src/client/tabs/guide/GuideBody.tsx'
|
||||
import type { GuideBodyProps } from '../src/client/tabs/guide/GuideBody.tsx'
|
||||
import type { SidebarRightGuideBox } from '../src/client/tab-registry.ts'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import css from '../src/client/tabs/guide/GuideBody.module.css'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -27,12 +28,13 @@ function Glyph({ size }: IconProps): ReactNode {
|
||||
}
|
||||
|
||||
/** One entry capsule as the registry lists it. */
|
||||
function box(kind: string, order: number, icon?: SidebarRightGuideBox['icon']): SidebarRightGuideBox {
|
||||
function box(kind: string, order: number, icon?: SidebarRightGuideBox['icon'], description?: string): SidebarRightGuideBox {
|
||||
return {
|
||||
kind,
|
||||
order,
|
||||
title: () => `${kind} title`,
|
||||
...icon === undefined ? {} : { icon },
|
||||
...description === undefined ? {} : { description: () => description },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,15 +63,20 @@ describe('GuideBody', () => {
|
||||
expect(renderSlot).toHaveBeenCalledWith('sidebar.right.tab.guide', {}, {
|
||||
hookContext: useTabInfo, fallback: expect.anything() as ReactNode,
|
||||
})
|
||||
// The guide says nothing of its own: its words are the capsules'.
|
||||
// The guide draws no words of its own; every word is a capsule's.
|
||||
const guide = view.container.querySelector('[data-sidebar-right-guide]')
|
||||
expect(guide?.textContent).toBe('files titleterminal title')
|
||||
// One capsule per entry, in the registry's order, each with its own title; only the first brought a glyph.
|
||||
expect(boxes()).toEqual(['files', 'terminal'])
|
||||
const [files, terminal] = [...view.container.querySelectorAll('[data-sidebar-right-guide-entry]')]
|
||||
expect(files?.textContent).toBe('files title')
|
||||
expect(files?.querySelector('[data-guide-glyph]')?.getAttribute('data-guide-glyph')).toBe('16')
|
||||
expect(files?.querySelector('[data-guide-glyph]')?.getAttribute('data-guide-glyph')).toBe('22')
|
||||
expect(terminal?.querySelector('[data-guide-glyph]')).toBeNull()
|
||||
// The entry without a glyph falls back to the shipped cube, at the same size, on the quieter ink.
|
||||
const placeholder = terminal?.querySelector('svg')
|
||||
expect(placeholder?.getAttribute('width')).toBe('22')
|
||||
expect(placeholder?.getAttribute('class')).toBe(css.placeholderInk)
|
||||
expect(files?.querySelector('svg')).toBeNull()
|
||||
cleanup()
|
||||
})
|
||||
|
||||
@@ -91,6 +98,24 @@ describe('GuideBody', () => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('shows an entry\'s description while at most four entries are listed, and drops every description past that', () => {
|
||||
const four = [box('a', 10, Glyph, 'a desc'), box('b', 20), box('c', 30, undefined, 'c desc'), box('d', 40)]
|
||||
const { view, guideEntries } = mountGuide(four)
|
||||
const capsule = (kind: string) => view.container.querySelector(`[data-sidebar-right-guide-entry="${kind}"]`)
|
||||
// At four: a capsule with a description carries it under the title at the larger glyph; one without stays title-only.
|
||||
expect(capsule('a')?.textContent).toBe('a titlea desc')
|
||||
expect(capsule('a')?.querySelector('[data-guide-glyph]')?.getAttribute('data-guide-glyph')).toBe('26')
|
||||
expect(capsule('b')?.textContent).toBe('b title')
|
||||
// The placeholder follows the described size exactly as a registered glyph does.
|
||||
expect(capsule('c')?.querySelector('svg')?.getAttribute('width')).toBe('26')
|
||||
// A fifth entry tips the whole guide back to titles alone, at the title-only glyph size.
|
||||
act(() => { guideEntries.set([...four, box('e', 50)]) })
|
||||
expect(capsule('a')?.textContent).toBe('a title')
|
||||
expect(capsule('a')?.querySelector('[data-guide-glyph]')?.getAttribute('data-guide-glyph')).toBe('22')
|
||||
expect(capsule('c')?.textContent).toBe('c title')
|
||||
cleanup()
|
||||
})
|
||||
|
||||
it('keeps two boxes of one type at the same order apart', () => {
|
||||
const errors = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user