Merge updated DeepSeek onboarding base

# Conflicts:
#	.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.md
#	.agents/notes/implemented/feature/2026-07-30-deepseek-onboarding-credential-setup.zh.md
#	packages/client/ui-models/README.i18n.yaml
#	packages/client/ui-models/README.md
#	packages/client/ui-models/README.zh.md
#	packages/client/ui-models/src/client/DeepSeekOnboardingDialog.module.css
#	packages/client/ui-models/src/client/DeepSeekOnboardingDialog.tsx
#	packages/client/ui-models/tests/onboarding-dialog.spec.tsx
This commit is contained in:
NI0317
2026-07-31 10:29:20 +08:00
90 changed files with 2496 additions and 346 deletions

View File

@@ -1392,14 +1392,14 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
const spec = PERMISSION_PRESETS[preset]
if (preset === '') {
const current = permissionSelectOf(logOf(id)).currentValue
append(id, { type: 'command/done', data: { commandId, kind: 'success', text: `Current permission preset: ${current}. Available: ${Object.keys(PERMISSION_PRESETS).join(', ')}.` } })
append(id, { type: 'command/done', data: { commandId, kind: 'success', text: `current preset ${current} (available: ${Object.keys(PERMISSION_PRESETS).join(', ')})` } })
} else if (spec === undefined) {
append(id, { type: 'command/done', data: { commandId, kind: 'error', text: `unknown permission preset ${JSON.stringify(preset)} (available: ${Object.keys(PERMISSION_PRESETS).join(', ')})` } })
append(id, { type: 'command/done', data: { commandId, kind: 'error', text: `unknown preset "${preset}" (available: ${Object.keys(PERMISSION_PRESETS).join(', ')})` } })
} else {
if (permissionSelectOf(logOf(id)).currentValue !== preset) append(id, { type: 'permission/preset', data: { preset } })
append(id, { type: 'sandbox/mode', data: { mode: spec.sandbox } })
append(id, { type: 'approval/policy', data: { policy: spec.approval } })
append(id, { type: 'command/done', data: { commandId, kind: 'success', text: `Permission preset: ${preset}.` } })
append(id, { type: 'command/done', data: { commandId, kind: 'success', text: `preset ${preset}` } })
}
return ok(request, { matched: true as const, commandId })
}

View File

@@ -1,12 +1,14 @@
import type {
HistoryEntry, IApiClient, MuxFrame, RpcError, SessionId,
} from '@deepseek-ai/dsh-client-connection/client'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import type {
SessionHistoryFace, SessionHistorySnapshot,
} from '../contract/session-history.ts'
import { createHistoryInspection } from '../sessions/history.ts'
import { Notifier } from '../sessions/notifier.ts'
import { PartialAccumulator } from '../sessions/partial.ts'
const HISTORY_PAGE_MESSAGES = 50
@@ -33,6 +35,9 @@ export class SessionHistorySource implements SessionHistoryFace {
entries: readonly HistoryEntry[]
value: SessionHistorySnapshot['inspection']
} | null = null
private streamPublishToken: object | null = null
private streamBaseInspection: SessionHistorySnapshot['inspection'] | null = null
private streamPartial: PartialAccumulator | null = null
private snapshotCache: SessionHistorySnapshot
private readonly notifier = new Notifier(() => {
this.snapshotCache = this.buildSnapshot()
@@ -125,7 +130,7 @@ export class SessionHistorySource implements SessionHistoryFace {
if (this.state !== 'cold') {
this.state = 'cold'
this.error = null
this.notifier.markDirty()
this.publishDirtyNow()
}
}
@@ -143,7 +148,7 @@ export class SessionHistorySource implements SessionHistoryFace {
this.hasMore = false
this.state = 'cold'
this.error = null
this.notifier.markDirty()
this.publishDirtyNow()
void this.loadForConsumers()
}
@@ -155,6 +160,9 @@ export class SessionHistorySource implements SessionHistoryFace {
this.openPromise = null
this.olderPromise = null
this.liveBuffer = []
this.streamPublishToken = null
this.streamBaseInspection = null
this.streamPartial = null
}
private open(): Promise<void> {
@@ -188,7 +196,7 @@ export class SessionHistorySource implements SessionHistoryFace {
private async doOpen(generation: number): Promise<void> {
this.state = 'loading'
this.error = null
this.notifier.markDirty()
this.publishDirtyNow()
try {
let { result } = await this.api.sessions.history({
sessionId: this.sessionId,
@@ -222,7 +230,7 @@ export class SessionHistorySource implements SessionHistoryFace {
/* v8 ignore next -- transportError always returns the error branch. */
this.error = folded.ok ? null : folded.error
} finally {
if (generation === this.generation) this.notifier.markDirty()
if (generation === this.generation) this.publishDirtyNow()
}
}
@@ -261,7 +269,7 @@ export class SessionHistorySource implements SessionHistoryFace {
const settled = operation.finally(() => {
if (this.olderPromise !== settled) return
this.olderPromise = null
this.notifier.markDirty()
this.publishDirtyNow()
})
this.olderPromise = settled
return settled
@@ -286,7 +294,7 @@ export class SessionHistorySource implements SessionHistoryFace {
const buffered = this.liveBuffer
this.liveBuffer = []
for (const entry of buffered) this.appendLive(entry)
this.notifier.markDirty()
this.publishDirtyNow()
}
private acceptLive(entry: HistoryEntry): void {
@@ -301,8 +309,16 @@ export class SessionHistorySource implements SessionHistoryFace {
void this.repairGap()
return
}
if (
entry.event.type === 'assistant/chunk'
&& entry.event.data.chunk.type !== 'usage'
) {
if (!this.appendIncrementalChunk(entry, entry.event)) return
this.publishStreamDirty()
return
}
this.appendLive(entry)
this.notifier.markDirty()
this.publishDirtyNow()
}
private appendLive(entry: HistoryEntry): void {
@@ -311,6 +327,66 @@ export class SessionHistorySource implements SessionHistoryFace {
this.entries = [...this.entries, entry]
}
/** Append a chunk against the cached finalized projection; false means no visible publish. */
private appendIncrementalChunk(
entry: HistoryEntry,
event: SessionEvent<'assistant/chunk'>,
): boolean {
const { turn, step, chunk } = event.data
if (!isVisibleAssistantChunk(chunk.type)) {
const inspection = this.currentInspection()
this.appendLive(entry)
this.inspectionCache = { entries: this.entries, value: inspection }
return false
}
const base = this.streamBaseInspection ?? this.currentInspection()
this.streamBaseInspection = base
if (
this.streamPartial === null
|| this.streamPartial.turn !== turn
|| this.streamPartial.step !== step
) {
const current = base.partial
this.streamPartial = new PartialAccumulator(
turn,
step,
current?.turn === turn && current.step === step ? current.blocks : [],
)
}
this.streamPartial.push(chunk)
this.appendLive(entry)
this.inspectionCache = {
entries: this.entries,
value: { ...base, partial: this.streamPartial.toPartial() },
}
return true
}
/** Coalesce token-stream projection and rendering work to one publish per browser frame. */
private publishStreamDirty(): void {
if (this.streamPublishToken !== null) return
const token = {}
this.streamPublishToken = token
const publish = () => {
if (this.streamPublishToken !== token) return
this.streamPublishToken = null
this.notifier.markDirty()
}
if (typeof globalThis.requestAnimationFrame === 'function') {
globalThis.requestAnimationFrame(publish)
} else {
queueMicrotask(publish)
}
}
/** Publish structural changes immediately and invalidate an older scheduled stream publish. */
private publishDirtyNow(): void {
this.streamPublishToken = null
this.streamBaseInspection = null
this.streamPartial = null
this.notifier.markDirty()
}
private async repairGap(): Promise<void> {
if (this.stitching) return
this.stitching = true
@@ -335,6 +411,16 @@ export class SessionHistorySource implements SessionHistoryFace {
}
private buildSnapshot(): SessionHistorySnapshot {
return {
state: this.state,
error: this.error,
hasMore: this.hasMore,
inspection: this.currentInspection(),
}
}
/** Inspection pinned to the source's current immutable entry array. */
private currentInspection(): SessionHistorySnapshot['inspection'] {
if (this.inspectionCache?.entries !== this.entries) {
const entries = this.entries
this.inspectionCache = {
@@ -342,11 +428,14 @@ export class SessionHistorySource implements SessionHistoryFace {
value: createHistoryInspection(() => entries),
}
}
return {
state: this.state,
error: this.error,
hasMore: this.hasMore,
inspection: this.inspectionCache.value,
}
return this.inspectionCache.value
}
}
function isVisibleAssistantChunk(type: string): boolean {
return type === 'block-start'
|| type === 'text-delta'
|| type === 'reasoning-delta'
|| type === 'tool-call-delta'
|| type === 'block-end'
}

View File

@@ -13,8 +13,18 @@ export class PartialAccumulator {
private changed = true
private snapshot: PartialAssistant
constructor(readonly turn: number, readonly step: number) {
this.snapshot = { turn, step, blocks: [] }
/**
* @param turn - Owning agent turn.
* @param step - Owning model step.
* @param initialBlocks - Materialized prefix when accumulation begins after history replay.
*/
constructor(
readonly turn: number,
readonly step: number,
initialBlocks: readonly AssistantBlock[] = [],
) {
this.blocks = [...initialBlocks]
this.snapshot = { turn, step, blocks: initialBlocks }
}
/**

View File

@@ -41,6 +41,12 @@ describe('PartialAccumulator', () => {
expect(acc.toPartial().blocks).toEqual([{ kind: 'reasoning', text: '思考' }])
})
it('continues from a materialized history prefix', () => {
const acc = new PartialAccumulator(1, 0, [{ kind: 'text', text: '已有' }])
acc.push(chunk({ type: 'text-delta', index: 0, text: '增量' }))
expect(acc.toPartial().blocks).toEqual([{ kind: 'text', text: '已有增量' }])
})
it('folds tool-call deltas: first id pins callId, late name overrides, argsRaw concatenates', () => {
const acc = new PartialAccumulator(1, 0)
acc.push(chunk({ type: 'tool-call-delta', index: 0, id: 'c1', argumentsDelta: '{"a"' }))

View File

@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { SessionHistorySource } from '../src/client/session-history/source.ts'
@@ -7,6 +7,10 @@ import { entries, ev, plainTurn } from './event-script.ts'
const SID = 'history-s1' as SessionId
afterEach(() => {
vi.unstubAllGlobals()
})
function histResponse(events: SessionEvent[], hasMore = false) {
return Promise.resolve(ok({ events: entries(events) as never[], hasMore }))
}
@@ -52,6 +56,71 @@ describe('SessionHistorySource', () => {
.toEqual([1, 3, 6])
})
it('publishes multiple assistant chunks once per browser frame', async () => {
const api = new FakeApiClient()
api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答'))
const source = new SessionHistorySource(SID, api)
await source.loadAll()
const frames: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
frames.push(callback)
return frames.length
})
let notifications = 0
const unsubscribe = source.subscribe(() => { notifications++ })
const before = source.getSnapshot().inspection
const finalizedNodes = before.eventNodes
const requests = before.requests
const contexts = before.contexts
for (const event of [
ev.chunkStart(6, 1),
ev.chunkText(7, 1, 'stream '),
ev.chunkText(8, 1, 'content'),
]) {
source.handleMuxFrame({
type: 'session/event',
sessionId: SID,
event,
})
}
expect(frames).toHaveLength(1)
expect(notifications).toBe(0)
frames[0]?.(0)
await Promise.resolve()
expect(notifications).toBe(1)
const streamed = source.getSnapshot().inspection
expect(streamed.eventNodes).toBe(finalizedNodes)
expect(streamed.requests).toBe(requests)
expect(streamed.contexts).toBe(contexts)
expect(streamed.partial?.blocks).toEqual([
{ kind: 'text', text: 'stream content' },
])
source.handleMuxFrame({
type: 'session/event',
sessionId: SID,
event: ev.chunkText(9, 1, ' then final'),
})
source.handleMuxFrame({
type: 'session/event',
sessionId: SID,
event: ev.assistant(10, 1, 'stream content then final'),
})
await Promise.resolve()
expect(notifications).toBe(2)
const finalized = source.getSnapshot().inspection
expect(finalized.eventNodes).not.toBe(finalizedNodes)
expect(finalized.partial).toBeNull()
frames[1]?.(0)
await Promise.resolve()
expect(notifications).toBe(2)
unsubscribe()
})
it('stops loading when an older page fails to advance', async () => {
const api = new FakeApiClient()
api.onHistory = payload => payload.beforeSeq === undefined

View File

@@ -135,7 +135,7 @@ describe('live event path', () => {
expect(session.getSnapshot().composerPhase).toBe('blank')
const feed = (event: SessionEvent) => { session.handleMuxEnvelope('r' as never, { type: 'session/event', sessionId: SID, event }) }
feed(ev.commandRun(0, 'cmd-perm', 'permission', ' danger-full-access'))
feed(ev.commandDone(1, 'cmd-perm', 'success', 'Permission preset: danger-full-access.'))
feed(ev.commandDone(1, 'cmd-perm', 'success', 'preset danger-full-access'))
const snapshot = session.getSnapshot()
expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'command', name: 'permission' })
expect(snapshot.composerPhase).toBe('blank')

View File

@@ -1,6 +1,6 @@
// GenericCommandCard: the default command row — a stripped-down
// GenericToolCard rendering the dispatched command line and the settlement
// text. Supplied by the chat view as the keyed commandview slot's render-site
// GenericToolCard rendering the command name and its settlement text.
// Supplied by the chat view as the keyed commandview slot's render-site
// fallback (an unregistered command name lands here); registrants may compose
// it as a base, feeding the same owner payload through.
@@ -20,10 +20,11 @@ export function GenericCommandCard({ node }: CommandRowOwnerProps) {
const summary = node.outcome === null
? '执行中…'
: text ?? (node.outcome.kind === 'error' ? '命令失败' : '已完成')
// Display line rebuilt from the structured payload (args carries its own
// separator whitespace verbatim); a cross-window node whose run page fell
// out of the window has neither.
const title = node.name === null ? '命令' : `/${node.name}${node.args ?? ''}`
// Title is the bare command name: the row already reads `name · outcome`,
// and the dispatched line's own `/` and arguments only restate what the
// settlement text says (`permission · preset workspace-write`). A
// cross-window node whose run page fell out of the window has no name.
const title = node.name ?? '命令'
return (
<ToolRow
variant="others"

View File

@@ -19,6 +19,13 @@
border-radius: 20px;
background: var(--dsw-specific-input-major);
box-shadow: var(--dsw-shadow-lv2);
/* Elevated surface in dark, same as the menus: `.body` inside scrolls once
the justification or command passes the cap, so the thumb takes the l2
pair. Declared on the card because the elevation belongs to the surface,
and the custom properties inherit down to the region that actually
scrolls (see ui-theme styles/scrollbar.css for the rebinding contract). */
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
/* Tinted full-width header band. */
@@ -40,11 +47,22 @@
background: var(--dsw-alias-state-warn-primary);
}
/* Scroll region: an agent's justification and its command are unbounded model
text (a one-line `cd` or a 40-line heredoc), and the seat sits in a
fixed-height column — uncapped, a long command pushed the action row past
the viewport and the approval could not be answered at all. The strip and
the action row stay outside, so the buttons are always on screen. */
.body {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 16px 14px;
/* border-box so the cap is the region's OUTER height: the composer's draft
area counts its padding inside the same number, and the two seats are
only interchangeable if they occupy the same box. */
box-sizing: border-box;
max-height: var(--dsh-composer-text-max-height);
overflow-y: auto;
padding: 12px 16px 0;
}
/* The model's justification is the panel's message, not a footnote. */
@@ -63,11 +81,15 @@
word-break: break-all;
}
/* Card-level row, not body content. Its padding reproduces the metrics the row
had inside the body: 14px above (the flex gap of 6 plus the row's 8px top
margin, neither of which reaches it out here) and the body's former 14px
bottom pad below, so the resting card is unchanged. */
.actionRow {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 8px;
padding: 14px 16px 14px;
}
.allow,

View File

@@ -4,7 +4,11 @@
// pending, this panel occupies the composer slot in place of the InputBar:
// an amber "Waiting for approval" strip on the card top, the model's
// justification as the headline, the paired command in muted code text, and
// a right-aligned refuse/allow action row. One-shot: the buttons disable
// a right-aligned refuse/allow action row. Justification and command are
// unbounded model text, so they scroll inside the card at the shared composer
// cap (`data-approval-scroll`) and the action row stays outside it — the
// buttons must be reachable no matter how long the command is.
// One-shot: the buttons disable
// after a click and the panel leaves (the InputBar returns) on the broadcast
// resolved frame. The draft's "Always allow this type" is deferred with
// grant storage.
@@ -53,17 +57,20 @@ function ApprovalFlow({ pending, command }: { pending: PendingApproval; command?
<div className={css.root} data-approval-key={pending.key}>
<div className={css.card}>
<div className={css.strip}><span className={css.dot} /></div>
<div className={css.body}>
{/* Tab stop: the region scrolls once the command passes the cap and
holds nothing focusable of its own, so without one a keyboard-only
user cannot reach the command's tail before answering. */}
<div className={css.body} data-approval-scroll="" tabIndex={0} role="group" aria-label="审批详情">
<div className={css.headline}>{pending.reason ?? `工具 ${pending.toolName} 请求越权执行`}</div>
{command !== undefined && <div className={css.command}>{command}</div>}
<div className={css.actionRow}>
<button type="button" className={css.reject} disabled={answered} onClick={() => { answer('rejected') }}>
</button>
<button type="button" className={css.allow} disabled={answered} onClick={() => { answer('allowed-once') }}>
</button>
</div>
</div>
<div className={css.actionRow}>
<button type="button" className={css.reject} disabled={answered} onClick={() => { answer('rejected') }}>
</button>
<button type="button" className={css.allow} disabled={answered} onClick={() => { answer('allowed-once') }}>
</button>
</div>
</div>
</div>

View File

@@ -143,6 +143,14 @@
display: flex;
flex: none;
flex-direction: column;
/* One cap for every scrolling text region a composer seat can hold: the
InputBar draft (figma Input 75:8208 max 14 lines × 24px line) and the
takeover panels' bodies top out at the same height, so electing a
takeover never grows the footer past the card it replaces. Declared on
the seat because it is the chain's only shared ancestor — fallback and
elected overlay are siblings — and custom properties inherit down to
whichever entry is mounted. */
--dsh-composer-text-max-height: 336px;
}
/* Active phase: header is ordinary column chrome above the scrollport (not

View File

@@ -209,7 +209,9 @@
.mirror {
visibility: hidden;
pointer-events: none;
max-height: 336px;
/* 14-line cap, shared with the composer takeovers (declared on
ConversationRoot .composerSeat). */
max-height: var(--dsh-composer-text-max-height);
overflow: hidden;
}

View File

@@ -460,10 +460,14 @@ describe('ChatView', () => {
name: 'plan', args: '', outcome: { kind: 'success', text: '已进入 plan mode' },
...over,
})
// Settled success: the command line is the title, the outcome text the summary.
const settled = makeHarness({ nodes: [user(1, 'hi'), command({})] })
// Settled success: the bare command name is the title, the outcome text
// the summary — neither the dispatched `/` nor its arguments reach the row
// (the settlement text already says what the command did).
const settled = makeHarness({ nodes: [user(1, 'hi'), command({ args: ' now' })] })
const view = render(<settled.ChatView {...settled.props} />)
expect(view.getByText('/plan')).toBeTruthy()
expect(view.getByText('plan')).toBeTruthy()
expect(view.queryByText('/plan')).toBeNull()
expect(view.queryByText('/plan now')).toBeNull()
expect(view.getByText('已进入 plan mode')).toBeTruthy()
// Error outcome flips the row state; a text-less error gets the default copy.

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-layout/README.md
README.md: 0e92958c9b088071ab58f7e87e8af68f6c2df68d
README.zh.md: 0fb3b1cd85bbf6e070a2a1dd01b25981e5990df0
README.md: cb99023e6a9e3364c6f48190cf4a0cd71da2cbba
README.zh.md: 3681b4517670eb92d8f32be2ac62d5852ac745a3

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar is fixed-width (only details shrinks, then auto-closes); a closed sidebar retains a 56px control rail while details closes to zero width. The package also seats the theme presenter: it consumes resolved `ctx.theme` snapshots and projects them onto the document (`html { color-scheme }` for native UA chrome, `body[data-ds-dark-theme]` from the active color scheme, plus the theme's alias tokens as inline variables on body).
Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar resize boundary is an invisible hit strip, while the details boundary retains its floating pill; only details shrinks during concession and then auto-closes. A closed sidebar retains a 56px control rail while details closes to zero width. The package also seats the theme presenter: it consumes resolved `ctx.theme` snapshots and projects them onto the document (`html { color-scheme }` for native UA chrome, `body[data-ds-dark-theme]` from the active color scheme, plus the theme's alias tokens as inline variables on body).
AppFrame always mounts the conversation and details columns; a connected Session renders through `SessionProvider`. The transient layout store starts the sidebar at its default width and details closed, and it never reads or writes `localStorage`. Hero and other unselected states also derive a zero rendered details width without changing that stored preference. AppFrame retains the last non-blank Session id across those states: the first Session remains closed, an explicit details action opens the contract default width, returning to the same Session restores its unchanged width, and selecting a different Session closes details before paint. The conversation owner share is empty, while the sidebar owner share contains only `collapsed` and `width`; registrants obtain business data from standard hooks and actions from their own inject faces.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
外壳插件:三栏 AppFrame拖动手柄与让步链`ctx.layout` 面板几何服务;它注册到运行时拥有的 `root` slot并声明 `sidebar``conversation``details``conversation.empty`。侧边栏宽度固定(只会收缩详情栏,然后将其自动关闭关闭的侧边栏仍保留 56px 控制轨道,详情栏则关闭到零宽度。该包还提供主题呈现器:它消费解析后的 `ctx.theme` 快照,并将其投影到 document`html { color-scheme }` 驱动原生 UA 控件,依据当前配色方案设置 `body[data-ds-dark-theme]`,并将主题的别名 token 设为 body 上的内联变量)。
外壳插件:三栏 AppFrame拖动手柄与让步链`ctx.layout` 面板几何服务;它注册到运行时拥有的 `root` slot并声明 `sidebar``conversation``details``conversation.empty`。侧边栏的缩放边界是不可见命中条带,详情栏边界则保留其浮动胶囊;让步期间只有详情栏会收缩并随后自动关闭关闭的侧边栏仍保留 56px 控制轨道,详情栏则关闭到零宽度。该包还提供主题呈现器:它消费解析后的 `ctx.theme` 快照,并将其投影到 document`html { color-scheme }` 驱动原生 UA 控件,依据当前配色方案设置 `body[data-ds-dark-theme]`,并将主题的别名 token 设为 body 上的内联变量)。
AppFrame 始终挂载会话栏和详情栏;已连接 Session 通过 `SessionProvider` 渲染。布局 store 是瞬时状态,侧边栏以默认宽度启动,详情栏则保持关闭,且该 store 从不读写 `localStorage`。hero 和其他未选中状态也会将详情栏的渲染宽度派生为零但不会改变存储的首选宽度。AppFrame 会跨越这些状态保留最后一个非 blank 会话 id首个会话保持关闭显式打开详情栏的操作会使用契约默认宽度返回同一会话时恢复其未改变的宽度选择不同会话时详情栏会在绘制前关闭。会话 owner share 为空,侧边栏 owner share 只包含 `collapsed``width`;注册方通过标准钩子获取业务数据,并从各自的 inject 表层获取操作。

View File

@@ -49,9 +49,8 @@
}
/* Drag handles are frame children (columns clip overflow): an 8px hit strip
centered on the column border via inline left, above column content. The
visible pill (12x32 r10, riding the border at vertical center) is the figma
Handle component; the hit strip stays wider than the pill. */
centered on the column border via inline left, above column content. Details
adds a visible 12x32 pill at vertical center; sidebar keeps only the hit strip. */
.handle {
position: absolute;
top: 0;
@@ -76,7 +75,7 @@
}
}
.handle::after {
.handle[data-side='details']::after {
content: '';
position: absolute;
top: 50%;
@@ -88,23 +87,22 @@
box-sizing: border-box;
background: var(--dsw-alias-button-floating-fill);
border: 1px solid var(--dsw-alias-border-l2-darkmode-thin);
/* Hover affordance: the pill hides until the pointer is over the owning
column (data-side pairs handle and column), the strip itself, or a drag. */
/* Hover affordance: the details pill hides until the pointer is over its
column, the strip itself, or a drag. */
opacity: 0;
transition:
opacity var(--ds-transition-duration-slow) var(--ds-ease-in-out),
background var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
.sidebarCol:hover ~ .handle[data-side='sidebar']::after,
.detailsCol:hover ~ .handle[data-side='details']::after,
.handle:hover::after,
.handle[data-dragging='true']::after {
.handle[data-side='details']:hover::after,
.handle[data-side='details'][data-dragging='true']::after {
opacity: 1;
}
.handle:hover::after,
.handle[data-dragging='true']::after {
.handle[data-side='details']:hover::after,
.handle[data-side='details'][data-dragging='true']::after {
background: var(--dsw-alias-button-floating-hover);
border-color: var(--dsw-alias-border-l3);
}

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-models/README.md
README.md: 2f53024df95a79862d5461d3514987a6e8257f9d
README.zh.md: 7c95709933fa29a8fd6ed773696e9657d959f753
README.md: b438965736c3e765fd6ccd0acb636c076afc4449
README.zh.md: ba95d15316b81d4dd19e2c38445085b942f48895

View File

@@ -6,7 +6,7 @@ Models settings plugin: the provider configuration page and official-DeepSeek co
Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), plus `reasoningEffort` (deepseek) or `reasoning` (pi-ai); every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base).
The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding steps complete. A configured literal `apiKey` secret sidecar or configured credential reference completes the step without rendering, including a read-only launch-environment credential. A mounted adapter with a missing writable reference shows one action that opens Settings on the Models section, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter is skipped because browser navigation cannot mount Cordis plugins, while an unusable settings or credential capability produces a deployment diagnostic with the same route to Models.
The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface.
Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling.

View File

@@ -6,7 +6,7 @@
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出密钥未在任何地方配置的整分节提供方DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器是每个适配器家族各一张的手写卡片主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下profile 没有引用时便派生 `<ROUTE>_API_KEY`pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`deepseek 的占位符显示公共端点),另加 `reasoningEffort`deepseek`reasoning`pi-ai其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base
前序首次使用引导步骤完成后DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。适配器已挂载、引用可写但尚未配置时,该步骤只显示一个操作按钮,用于打开「设置」Models 分区;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失时直接跳过,因为浏览器导航无法挂载 Cordis 插件;设置凭据能力不可用时则显示部署诊断,并提供同一个前往 Models 的入口
前序首次使用引导页面完成后DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。`apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置凭据能力不可用时该步骤均不渲染并直接完成以免首次使用引导阻塞产品Models 页仍是诊断界面
每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除整行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor因此它点名自己看得见的字段而不是重建分节一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed``credentials/changed``models/changed``connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。

View File

@@ -26,8 +26,7 @@
outline: none;
}
.description,
.diagnostic {
.description {
max-width: 600px;
margin: 22px 0 0;
font-size: 17px;
@@ -73,15 +72,13 @@
.brand,
.title,
.description,
.diagnostic,
.provider,
.actions {
animation: credential-enter 280ms cubic-bezier(0.23, 1, 0.32, 1) both;
}
.title { animation-delay: 40ms; }
.description,
.diagnostic { animation-delay: 80ms; }
.description { animation-delay: 80ms; }
.provider { animation-delay: 120ms; }
.actions { animation-delay: 160ms; }
@@ -101,7 +98,6 @@
.brand,
.title,
.description,
.diagnostic,
.provider,
.actions {
animation: none;
@@ -118,8 +114,7 @@
margin-bottom: 30px;
}
.description,
.diagnostic {
.description {
font-size: 16px;
line-height: 27px;
}

View File

@@ -9,7 +9,7 @@ import type { ReactNode } from 'react'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { BrandWordmark, Button } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import type { DeepSeekReadiness, ModelsSettingsState, ModelsSettingsStore } from './store.ts'
import type { ModelsSettingsState, ModelsSettingsStore } from './store.ts'
import { deepSeekReadiness } from './store.ts'
import type { en } from './locales.ts'
import styles from './DeepSeekOnboardingDialog.module.css'
@@ -28,35 +28,11 @@ export interface DeepSeekOnboardingInjected {
export type DeepSeekOnboardingDialogProps =
PropsRuntime<'settings.onboarding'> & DeepSeekOnboardingInjected
type UnavailableReason = Extract<DeepSeekReadiness, { kind: 'unavailable' }>['reason']
/* v8 ignore next 3 -- closed-union defaults only defend future source widening */
function assertNever(_value: never): never {
throw new Error('unexpected DeepSeek onboarding state')
}
function unavailableDiagnostic(
reason: UnavailableReason,
t: DeepSeekOnboardingInjected['t'],
): string {
switch (reason) {
case 'load-failed':
return t('onboardingLoadFailed')
case 'credentials-unavailable':
return t('onboardingCredentialsUnavailable')
case 'settings-read-only':
case 'credential-read-only':
return t('onboardingReadOnly')
case 'provider-inactive':
case 'settings-unavailable':
case 'credential-ref-unavailable':
return t('onboardingConfigurationUnavailable')
/* v8 ignore next -- every current unavailable reason is handled above */
default:
return assertNever(reason)
}
}
/**
* Prompt a first-run user to open Models while the official adapter exists
* and its effective credential is not configured.
@@ -74,42 +50,34 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps):
}, [controller, state.status])
useEffect(() => {
if (readiness.kind === 'adapter-absent' || readiness.kind === 'configured') complete()
if (
readiness.kind === 'adapter-absent'
|| readiness.kind === 'configured'
|| readiness.kind === 'unavailable'
) complete()
}, [complete, readiness.kind])
useEffect(() => {
if (readiness.kind === 'credential-missing') titleRef.current?.focus()
}, [readiness.kind])
const openModels = (): void => {
complete()
openSection('models')
}
useEffect(() => {
if (readiness.kind === 'credential-missing' || readiness.kind === 'unavailable') {
titleRef.current?.focus()
}
}, [readiness.kind])
let unavailableReason: UnavailableReason | undefined
switch (readiness.kind) {
case 'loading':
case 'adapter-absent':
case 'configured':
case 'unavailable':
return null
case 'credential-missing':
unavailableReason = undefined
break
case 'unavailable':
unavailableReason = readiness.reason
break
/* v8 ignore next -- every current readiness variant is handled above */
default:
return assertNever(readiness)
}
const unavailable = unavailableReason !== undefined
const diagnostic = unavailableReason === undefined
? undefined
: unavailableDiagnostic(unavailableReason, t)
const title = unavailable ? t('onboardingUnavailableTitle') : t('onboardingTitle')
return (
<section className={styles['page']} role="region" aria-labelledby="deepseek-onboarding-title">
@@ -120,11 +88,9 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps):
className={styles['title']}
tabIndex={-1}
>
{title}
{t('onboardingTitle')}
</h2>
{unavailable
? <p className={styles['diagnostic']}>{diagnostic}</p>
: <p className={styles['description']}>{t('onboardingDescription')}</p>}
<p className={styles['description']}>{t('onboardingDescription')}</p>
<div className={styles['provider']}>
<span className={styles['providerName']}>DeepSeek</span>
<span className={styles['providerRoute']}>deepseek-official</span>
@@ -133,11 +99,7 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps):
<Button variant="ghost" className={styles['later']} onClick={complete}>
{t('onboardingLater')}
</Button>
<Button
variant="primary"
className={styles['primary']}
onClick={openModels}
>
<Button variant="primary" className={styles['primary']} onClick={openModels}>
{t('onboardingGoToSettings')}
</Button>
</div>

View File

@@ -32,11 +32,6 @@ export const en = {
onboardingDescription: 'Configure the official DeepSeek provider to start building.',
onboardingGoToSettings: 'Go to settings',
onboardingLater: 'Configure later',
onboardingUnavailableTitle: 'DeepSeek setup is unavailable',
onboardingLoadFailed: 'DeepSeek configuration could not be loaded. Check the connection and try again in Models.',
onboardingCredentialsUnavailable: 'Credential storage is unavailable in this deployment. Check the deployment configuration.',
onboardingReadOnly: 'This deployment does not allow the DeepSeek API key to be changed here. Ask an administrator to provide the credential.',
onboardingConfigurationUnavailable: 'DeepSeek configuration is unavailable in this deployment. Check the deployment composition.',
}
/** Chinese strings (same keys as {@link en}). */
@@ -71,9 +66,4 @@ export const zh: typeof en = {
onboardingDescription: '配置 DeepSeek 官方模型,即可开始使用。',
onboardingGoToSettings: '前往配置',
onboardingLater: '稍后配置',
onboardingUnavailableTitle: '无法在此配置 DeepSeek',
onboardingLoadFailed: '无法加载 DeepSeek 配置。请检查连接,然后在模型设置中重试。',
onboardingCredentialsUnavailable: '当前部署无法使用凭据存储。请检查部署配置。',
onboardingReadOnly: '当前部署不允许在此修改 DeepSeek API 密钥。请联系管理员提供凭据。',
onboardingConfigurationUnavailable: '当前部署无法使用 DeepSeek 配置。请检查部署组合。',
}

View File

@@ -215,8 +215,8 @@ export type DeepSeekReadiness =
/**
* Project official-DeepSeek readiness from the provider/settings/credential
* join used by the Models page. A missing directory entry means the adapter
* is not mounted and therefore cannot be repaired by navigating to Models.
* join used by the Models page. A missing official configurable-provider
* declaration means the adapter is not repairable by navigating to Models.
* @param state - current shared Models join snapshot.
* @returns the onboarding state without reading a parallel fact source.
*/
@@ -230,7 +230,10 @@ export function deepSeekReadiness(state: ModelsSettingsState): DeepSeekReadiness
reason: 'load-failed',
}
}
const row = state.rows.find(candidate => candidate.entry.provider === 'deepseek-official')
const row = state.rows.find(candidate =>
candidate.entry.provider === 'deepseek-official'
&& candidate.entry.settingsNs === 'llm-deepseek'
&& candidate.entry.settingsPath.length === 0)
if (row === undefined) return { kind: 'adapter-absent' }
if (!row.entry.active) {
return {

View File

@@ -24,6 +24,7 @@ function fail<T>(message: string): RpcResponse<T> {
function harness(options: {
provider?: boolean
providerSettingsNs?: string
providerActive?: boolean
settingsNamespace?: boolean
apiKeyEnv?: string | null
@@ -32,25 +33,21 @@ function harness(options: {
credential?: { source?: string; writable: boolean }
describeFailure?: string
settingsWritable?: boolean
providersRejectOnce?: boolean
providersReject?: boolean
} = {}) {
let fileConfigured = false
let rejectProviders = options.providersRejectOnce === true
const configured = options.configured ?? (() => fileConfigured)
const face = {
llm: {
providers: () => {
if (rejectProviders) {
rejectProviders = false
return Promise.reject(new Error('provider transport unavailable'))
}
if (options.providersReject === true) return Promise.reject(new Error('provider transport unavailable'))
return Promise.resolve(ok({
providers: options.provider === false
? []
: [{
provider: 'deepseek-official',
displayName: 'DeepSeek',
settingsNs: 'llm-deepseek',
settingsNs: options.providerSettingsNs ?? 'llm-deepseek',
settingsPath: [],
active: options.providerActive ?? true,
}],
@@ -137,45 +134,21 @@ describe('DeepSeekOnboardingDialog', () => {
expect(h.openSection).not.toHaveBeenCalled()
})
it('routes an unavailable credential deployment to Models with a diagnostic', async () => {
const h = harness({ describeFailure: 'credentials service is absent' })
render(<DeepSeekOnboardingDialog {...h.props} />)
await screen.findByRole('region', { name: en.onboardingUnavailableTitle })
expect(screen.getByText(en.onboardingCredentialsUnavailable)).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: en.onboardingGoToSettings }))
expect(h.openSection).toHaveBeenCalledWith('models')
})
it('explains read-only credential and settings deployments', async () => {
it('does not block the product when DeepSeek setup is unavailable', async () => {
for (const h of [
harness({ describeFailure: 'credentials service is absent' }),
harness({ credential: { writable: false } }),
harness({ settingsWritable: false }),
]) {
const view = render(<DeepSeekOnboardingDialog {...h.props} />)
await screen.findByRole('region', { name: en.onboardingUnavailableTitle })
expect(screen.getByText(en.onboardingReadOnly)).toBeTruthy()
view.unmount()
}
})
it('distinguishes an initial transport failure from deployment misconfiguration', async () => {
const h = harness({ providersRejectOnce: true })
render(<DeepSeekOnboardingDialog {...h.props} />)
await screen.findByRole('region', { name: en.onboardingUnavailableTitle })
expect(screen.getByText(en.onboardingLoadFailed)).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: en.onboardingGoToSettings }))
expect(h.openSection).toHaveBeenCalledWith('models')
})
it('uses the configuration diagnostic for inactive or unresolvable adapters', async () => {
for (const h of [
harness({ providersReject: true }),
harness({ providerActive: false }),
harness({ settingsNamespace: false }),
harness({ apiKeyEnv: null }),
]) {
const view = render(<DeepSeekOnboardingDialog {...h.props} />)
await screen.findByRole('region', { name: en.onboardingUnavailableTitle })
expect(screen.getByText(en.onboardingConfigurationUnavailable)).toBeTruthy()
await act(async () => { await h.controller.load() })
expect(screen.queryByRole('region')).toBeNull()
await waitFor(() => { expect(h.complete).toHaveBeenCalledOnce() })
expect(h.openSection).not.toHaveBeenCalled()
view.unmount()
}
})
@@ -183,6 +156,7 @@ describe('DeepSeekOnboardingDialog', () => {
it('skips an absent adapter and already-configured literal or environment credentials', async () => {
for (const h of [
harness({ provider: false }),
harness({ providerSettingsNs: '' }),
harness({ literal: true, describeFailure: 'credential seam absent' }),
harness({ configured: () => true, credential: { source: 'env', writable: false } }),
]) {

View File

@@ -41,6 +41,14 @@ describe('deepSeekReadiness', () => {
expect(deepSeekReadiness(state({ status: 'idle', rows: [] }))).toEqual({ kind: 'loading' })
expect(deepSeekReadiness(state({ status: 'loading', rows: [] }))).toEqual({ kind: 'loading' })
expect(deepSeekReadiness(state({ rows: [] }))).toEqual({ kind: 'adapter-absent' })
expect(deepSeekReadiness(state({
rows: [row({
entry: {
...row().entry,
settingsNs: '',
},
})],
}))).toEqual({ kind: 'adapter-absent' })
})
it('reports a missing writable effective credential', () => {

View File

@@ -16,6 +16,7 @@
flex: 1;
min-width: 0;
overflow: auto;
container: trajectory-table / inline-size;
}
.table {
@@ -235,6 +236,18 @@
width: 3px;
}
.table tbody tr[data-error='true'] .turnRail {
background: color-mix(
in srgb,
var(--dsw-alias-state-error-primary) 22%,
var(--dsw-alias-bg-layer-1)
);
}
.table tbody tr[data-error='true'] .selectionRail {
background: var(--dsw-alias-state-error-primary);
}
.table tbody tr[data-turn-start='true'] td {
position: relative;
overflow: visible;
@@ -279,6 +292,10 @@
white-space: nowrap;
}
.turnLabelCompact {
display: none;
}
.turnLabelActive {
color: color-mix(
in srgb,
@@ -325,11 +342,66 @@
user-select: none;
}
.kindTagIcon {
display: none;
align-items: center;
justify-content: center;
width: 13px;
height: 13px;
}
.kindTagLabel {
display: inline;
}
.table .kindSlot .message {
justify-content: center;
width: 100%;
}
@container trajectory-table (max-width: 620px) {
.eventColumn {
width: 50px;
}
.event {
padding-right: 3px !important;
padding-left: 28px !important;
}
.requestBoundaryControl {
left: 6px;
}
.kindSlot {
width: 19px;
}
.kindTag,
.table .kindSlot .message {
justify-content: center;
width: 19px;
padding-right: 0;
padding-left: 0;
}
.kindTagIcon {
display: inline-flex;
}
.kindTagLabel {
display: none;
}
.turnLabelFull {
display: none;
}
.turnLabelCompact {
display: inline;
}
}
.user {
color: var(--dsw-alias-state-business-primary);
background: var(--dsw-alias-state-business-tertiary);
@@ -576,6 +648,28 @@
color: var(--dsw-alias-state-error-primary);
}
.overview dd.error {
color: var(--dsw-alias-state-error-primary);
}
.details .errorPayload {
color: var(--dsw-alias-state-error-primary);
}
.details .errorPayload .resultBlockText {
color: inherit;
}
.details .jsonPayload.errorPayload,
.details .jsonPreview.errorPayload {
--json-tree-property: var(--dsw-alias-state-error-primary);
--json-tree-string: var(--dsw-alias-state-error-primary);
--json-tree-number: var(--dsw-alias-state-error-primary);
--json-tree-keyword: var(--dsw-alias-state-error-primary);
--json-tree-punctuation: var(--dsw-alias-state-error-primary);
--json-tree-icon: var(--dsw-alias-state-error-primary);
}
.details {
position: relative;
display: flex;

View File

@@ -1,9 +1,15 @@
/** Turn-aware trajectory event ledger with a local record inspector. */
import { useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { CSSProperties, ReactNode } from 'react'
import {
extractMarkdownPlainText, IconChevronRightOutline14, JsonTree, MarkdownText,
IconChevronRightOutline14,
IconSettingsOutline16,
IconSparkle16,
IconUserOutline16,
JsonTree,
MarkdownText,
Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { structuredPatch } from 'diff'
import type {
@@ -13,7 +19,7 @@ import type {
AssistantMetricDetail, TrajectoryCellKind, TrajectoryCellProps, TrajectorySourceBlock,
} from './trajectory-record.ts'
import { formatElapsedSeconds } from './trajectory-record.ts'
import type { TrajectoryTurnModel } from './layout.ts'
import { trajectoryPreviewText, type TrajectoryTurnModel } from './layout.ts'
import css from './TrajectoryTable.module.css'
const KIND_LABEL: Record<TrajectoryCellKind, string> = {
@@ -26,6 +32,77 @@ const KIND_LABEL: Record<TrajectoryCellKind, string> = {
subtool: 'SUBTOOL',
}
function ToolWrenchIcon(): ReactNode {
return (
<svg
width="13"
height="13"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
data-role-icon="wrench"
aria-hidden="true"
>
<path d="M14 3.3a3.8 3.8 0 0 1-4.8 4.8l-5.1 5.1a1.6 1.6 0 1 1-2.3-2.3l5.1-5.1A3.8 3.8 0 0 1 11.7 1l-2.3 2.3 2.3 2.3L14 3.3Z" />
</svg>
)
}
function InformationIcon(): ReactNode {
return (
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
data-role-icon="information"
aria-hidden="true"
>
<circle cx="8" cy="8" r="6.7" />
<circle cx="8" cy="5.5" r=".85" fill="currentColor" stroke="none" />
<path d="M8 7.75v3.4" strokeWidth="1.8" />
</svg>
)
}
function CompactedIcon(): ReactNode {
return (
<svg
width="13"
height="13"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
data-role-icon="compacted"
aria-hidden="true"
>
<path d="m2.5 2.5 3.75 3.75M3 6.25h3.25V3" />
<path d="m13.5 2.5-3.75 3.75M13 6.25H9.75V3" />
<path d="m2.5 13.5 3.75-3.75M3 9.75h3.25V13" />
<path d="m13.5 13.5-3.75-3.75M13 9.75H9.75V13" />
</svg>
)
}
const KIND_ICON: Record<TrajectoryCellKind, ReactNode> = {
system: <IconSettingsOutline16 size={13} />,
user: <IconUserOutline16 size={13} />,
context: <InformationIcon />,
compacted: <CompactedIcon />,
message: <IconSparkle16 size={13} />,
tool: <ToolWrenchIcon />,
subtool: <ToolWrenchIcon />,
}
interface TableRecord {
turn: number
group: string
@@ -225,6 +302,8 @@ export interface TrajectoryTableProps {
onSelectedIndexChange?: (index: number | null) => void
/** Report a direct user selection from a ledger row. */
onRecordSelect?: (index: number) => void
/** One externally requested record selection; a new object repeats the request. */
recordSelection?: { readonly index: number } | null
/** Clear selection state owned by the ledger host. */
onClearSelection?: () => void
/** Turn ids whose rows after the first are folded into a summary. */
@@ -721,13 +800,13 @@ function detailTabs(record: TableRecord): readonly DetailTabItem[] {
function recordDisplayText(cell: TrajectoryCellProps): string {
if (isToolCallOnly(cell)) return ''
if (cell.text !== '') return cell.text
const markdown = cell.kind === 'user' || cell.kind === 'context'
? cell.inputDetail
: cell.kind === 'message'
? cell.outputDetail ?? cell.thinkingDetail
: undefined
if (!markdown) return cell.text
return extractMarkdownPlainText(markdown).replace(/\s+/g, ' ').trim()
return markdown === undefined ? '' : trajectoryPreviewText(markdown)
}
function toolCallTextParts(
@@ -1043,13 +1122,20 @@ function SystemPromptDiff({
function ToolOutputBlocks({
blocks,
error,
preview,
}: {
blocks: readonly TrajectorySourceBlock[]
error: boolean
preview: boolean
}) {
return (
<div className={preview ? `${css.resultBlocks} ${css.resultBlocksPreview}` : css.resultBlocks}>
<div className={[
css.resultBlocks,
preview ? css.resultBlocksPreview : undefined,
error ? css.errorPayload : undefined,
].filter((value): value is string => value !== undefined).join(' ')}
>
{blocks.map((block, index) => (
block.imageSrc !== undefined
? <PanelImage block={block} preview={preview} key={index} />
@@ -1222,6 +1308,9 @@ function RecordPayload({
? 'No payload captured'
: 'No result captured'
if (!value) return <p className={css.noPayload}>{missing}</p>
const error = direction === 'output' && record.cell.isError === true
const payloadClass = preview ? css.jsonPreview : css.jsonPayload
const payloadClassName = error ? `${payloadClass} ${css.errorPayload}` : payloadClass
const json = parseJsonContainer(value)
const singleTextResult = direction === 'output'
@@ -1232,7 +1321,7 @@ function RecordPayload({
<JsonTree
data={json}
label="Result JSON"
className={preview ? css.jsonPreview : css.jsonPayload}
className={payloadClassName}
/>
)
}
@@ -1245,6 +1334,7 @@ function RecordPayload({
return (
<ToolOutputBlocks
blocks={record.cell.outputBlocks}
error={error}
preview={preview}
/>
)
@@ -1258,7 +1348,11 @@ function RecordPayload({
)
if (markdown) {
return (
<div className={preview ? css.markdownPreview : css.markdownPayload}>
<div className={[
preview ? css.markdownPreview : css.markdownPayload,
error ? css.errorPayload : undefined,
].filter((className): className is string => className !== undefined).join(' ')}
>
<MarkdownText text={value} />
</div>
)
@@ -1268,7 +1362,7 @@ function RecordPayload({
<JsonTree
data={json}
label={`${direction === 'input' ? 'Payload' : 'Result'} JSON`}
className={preview ? css.jsonPreview : css.jsonPayload}
className={payloadClassName}
/>
)
}
@@ -1276,7 +1370,7 @@ function RecordPayload({
<pre className={[
css.payload,
preview ? css.payloadPreview : undefined,
record.cell.isError ? css.error : undefined,
error ? css.errorPayload : undefined,
value === 'No output' ? css.noOutputText : undefined,
].filter((value): value is string => value !== undefined).join(' ')}
>
@@ -1397,6 +1491,7 @@ export function TrajectoryTable({
searchMatchIndexes = null,
onSelectedIndexChange,
onRecordSelect,
recordSelection = null,
onClearSelection,
collapsedTurns,
onToggleTurn,
@@ -1406,15 +1501,16 @@ export function TrajectoryTable({
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
const [selectedRequest, setSelectedRequest] = useState<SelectedRequest | null>(null)
const [activeTab, setActiveTab] = useState<DetailTab>('overview')
const [thinkingExpanded, setThinkingExpanded] = useState(true)
const [thinkingExpanded, setThinkingExpanded] = useState(false)
const [detailsWidth, setDetailsWidth] = useState<number | null>(null)
const [toolRequestOffset, setToolRequestOffset] = useState<number | null>(null)
const detailsResizeDrag = useRef<DetailsResizeDrag | null>(null)
const appliedRecordSelection = useRef<TrajectoryTableProps['recordSelection']>(null)
const tabHistory = useRef<Set<DetailTab>>(new Set(['overview']))
useEffect(() => {
onSelectedIndexChange?.(selectedIndex)
}, [onSelectedIndexChange, selectedIndex])
const allRecords = flattenRecords(turns)
const allRecords = useMemo(() => flattenRecords(turns), [turns])
const requestNumbers = indexRequestNumbers(allRecords, sessionRequestNumbers)
const records = searchMatchIndexes === null
? collapseAssistantRecords(
@@ -1531,7 +1627,7 @@ export function TrajectoryTable({
onClearSelection?.()
}
const selectRecord = (index: number) => {
const selectRecord = useCallback((index: number) => {
const record = allRecords.find(candidate => candidate.cell.index === index)
onRecordSelect?.(index)
setSelectedRequest(null)
@@ -1541,7 +1637,15 @@ export function TrajectoryTable({
const available = new Set(tabs.map(tab => tab.id))
const recent = [...tabHistory.current].reverse().find(tab => available.has(tab))
setActiveTab(recent ?? tabs[0]?.id ?? 'overview')
}
}, [allRecords, onRecordSelect])
useEffect(() => {
if (
recordSelection === null
|| appliedRecordSelection.current === recordSelection
) return
appliedRecordSelection.current = recordSelection
selectRecord(recordSelection.index)
}, [recordSelection, selectRecord])
const selectRequest = (
request: SelectedRequest,
@@ -1714,8 +1818,14 @@ export function TrajectoryTable({
className={activeTurn === record.turn
? `${css.turnLabel} ${css.turnLabelActive}`
: css.turnLabel}
aria-label={`Turn ${record.turn}`}
>
Turn {record.turn}
<span className={css.turnLabelFull} aria-hidden="true">
Turn {record.turn}
</span>
<span className={css.turnLabelCompact} aria-hidden="true">
#{record.turn}
</span>
</span>
)}
<div className={css.eventInner}>
@@ -1723,24 +1833,33 @@ export function TrajectoryTable({
<span
className={css.kindSlot}
>
<span className={`${css.kindTag} ${
record.cell.kind === 'system'
? css.systemNeutral
: record.cell.kind === 'context'
? css.contextGreen
: record.cell.kind === 'compacted'
? css.compacted
: record.cell.kind === 'tool'
? css.toolAmber
: record.cell.kind === 'message'
? css.assistantVioletBright
: record.cell.kind === 'subtool'
? css.subtoolAmber
: css[record.cell.kind]
}`}
>
{KIND_LABEL[record.cell.kind]}
</span>
<Tooltip label={KIND_LABEL[record.cell.kind]} side="bottom">
<span
className={`${css.kindTag} ${
record.cell.kind === 'system'
? css.systemNeutral
: record.cell.kind === 'context'
? css.contextGreen
: record.cell.kind === 'compacted'
? css.compacted
: record.cell.kind === 'tool'
? css.toolAmber
: record.cell.kind === 'message'
? css.assistantVioletBright
: record.cell.kind === 'subtool'
? css.subtoolAmber
: css[record.cell.kind]
}`}
data-role-kind={record.cell.kind}
>
<span className={css.kindTagIcon} aria-hidden="true">
{KIND_ICON[record.cell.kind]}
</span>
<span className={css.kindTagLabel}>
{KIND_LABEL[record.cell.kind]}
</span>
</span>
</Tooltip>
</span>
)}
</div>
@@ -1973,7 +2092,9 @@ export function TrajectoryTable({
<dl className={css.overview}>
<div>
<dt>Status</dt>
<dd>{statusLabel(selectedRequestState)}</dd>
<dd className={selectedRequestState === 'error' ? css.error : undefined}>
{statusLabel(selectedRequestState)}
</dd>
</div>
{selectedRequestInfo?.purpose === 'compaction' && (
<div>
@@ -2014,7 +2135,7 @@ export function TrajectoryTable({
{selectedRequestInfo?.error !== undefined && (
<div>
<dt>Error</dt>
<dd>{selectedRequestInfo.error}</dd>
<dd className={css.error}>{selectedRequestInfo.error}</dd>
</div>
)}
{selectedRequestInfo?.retry !== undefined && (
@@ -2122,7 +2243,9 @@ export function TrajectoryTable({
<dl className={css.overview}>
<div>
<dt>Status</dt>
<dd>{statusLabel(selectedState)}</dd>
<dd className={selectedState === 'error' ? css.error : undefined}>
{statusLabel(selectedState)}
</dd>
</div>
<div>
<dt>Duration</dt>
@@ -2225,7 +2348,9 @@ export function TrajectoryTable({
)}
<div>
<dt>Status</dt>
<dd>{statusLabel(selectedState)}</dd>
<dd className={selectedState === 'error' ? css.error : undefined}>
{statusLabel(selectedState)}
</dd>
</div>
{selected.cell.kind === 'message' && (
<TokenRows cell={selected.cell} />

View File

@@ -70,16 +70,29 @@
.lanes {
position: absolute;
z-index: 2;
inset: 7px 0;
top: 7px;
bottom: 7px;
left: var(--trajectory-domain-left);
width: var(--trajectory-domain-width);
}
.turnBoundaries {
position: absolute;
z-index: 3;
inset: 0;
top: 0;
bottom: 0;
left: var(--trajectory-domain-left);
width: var(--trajectory-domain-width);
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.lanes[data-animate-viewport='true'],
.turnBoundaries[data-animate-viewport='true'] {
transition: left 180ms ease-out;
}
}
.turnBoundary {
position: absolute;
top: 0;
@@ -133,6 +146,10 @@
);
}
.span[data-error='true'] {
background: var(--dsw-alias-state-error-primary);
}
.span[data-equal-duration='true'] {
width: 8px;
min-width: 8px;
@@ -142,6 +159,18 @@
opacity: 0.2;
}
.span[data-hovered='true']:not([data-current='true']) {
z-index: 1;
opacity: 0.78;
box-shadow:
0 0 0 1px var(--dsw-alias-bg-layer-2),
0 0 0 2px color-mix(
in srgb,
var(--dsw-alias-state-business-primary) 80%,
transparent
);
}
.span[data-current='true'] {
z-index: 1;
opacity: 1;

View File

@@ -15,12 +15,20 @@ import css from './TrajectoryTimeline.module.css'
const MINIMUM_DRAG_PX = 3
const MINIMUM_ZOOM_OPERATIONS = 4
const EDGE_PAN_ZONE_FRACTION = 0.08
const EDGE_PAN_STEP_FRACTION = 0.025
const MAXIMUM_EDGE_PAN_PX = 32
interface FractionRange {
start: number
end: number
}
interface HoverPoint {
fraction: number
recordIndex: number | null
}
/** Props for the fixed full-domain overview above the trajectory ledger. */
export interface TrajectoryTimelineProps {
turns: readonly TrajectoryTurnModel[]
@@ -30,6 +38,9 @@ export interface TrajectoryTimelineProps {
/** Record indexes matching the active ledger search, or null without a query. */
searchMatchIndexes?: ReadonlySet<number> | null
onRangeChange: (range: TrajectoryTimeRange | null) => void
/** Select a directly clicked timeline block. */
onRecordSelect?: (index: number) => void
/** Bring the nearest record into view after clicking timeline whitespace. */
onRecordFocus?: (index: number) => void
}
@@ -41,11 +52,16 @@ function clampFraction(value: number): number {
return Math.min(1, Math.max(0, value))
}
function centeredRange(center: number, width: number): FractionRange {
const clampedWidth = Math.min(1, Math.max(0, width))
function centeredRange(
center: number,
width: number,
minimum: number,
maximum: number,
): FractionRange {
const clampedWidth = Math.min(maximum - minimum, Math.max(0, width))
const start = Math.min(
Math.max(center - clampedWidth / 2, 0),
1 - clampedWidth,
Math.max(center - clampedWidth / 2, minimum),
maximum - clampedWidth,
)
return { start, end: start + clampedWidth }
}
@@ -79,6 +95,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
selectedIndex = null,
searchMatchIndexes = null,
onRangeChange,
onRecordSelect,
onRecordFocus,
}: TrajectoryTimelineProps) {
const model = useMemo(() => deriveTrajectoryTimeline(turns, mode), [mode, turns])
@@ -94,10 +111,16 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
)),
[turns],
)
const dragRef = useRef<{ pointerId: number; anchor: number; width: number } | null>(null)
const [draft, setDraft] = useState<FractionRange | null>(null)
const [hover, setHover] = useState<number | null>(null)
const dragRef = useRef<{
pointerId: number
anchorTime: number
anchorClientX: number
recordIndex: number | null
} | null>(null)
const [draft, setDraft] = useState<TrajectoryTimeRange | null>(null)
const [hover, setHover] = useState<HoverPoint | null>(null)
const [viewport, setViewport] = useState<TrajectoryTimeRange | null>(null)
const [animateViewport, setAnimateViewport] = useState(false)
useEffect(() => {
if (
model !== null
@@ -109,11 +132,35 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
}, [model, onRangeChange, range])
useEffect(() => {
if (model === null) return
setAnimateViewport(false)
setViewport(current =>
current !== null && (current.end < model.start || current.start > model.end)
? null
: current)
}, [model])
useEffect(() => {
if (model === null || selectedIndex === null) return
const selectedSpan = model.spans.find(span => span.index === selectedIndex)
if (selectedSpan === undefined) return
setAnimateViewport(true)
setViewport((current) => {
if (current === null) return current
if (
selectedSpan.end > current.start
&& selectedSpan.start < current.end
) return current
const duration = Math.max(1, current.end - current.start)
const desiredStart = selectedSpan.end <= current.start
? selectedSpan.start
: selectedSpan.end - duration
const nextStart = Math.min(
Math.max(desiredStart, model.start),
Math.max(model.start, model.end - duration),
)
if (nextStart === current.start) return current
return { start: nextStart, end: nextStart + duration }
})
}, [model, selectedIndex])
const fullDuration = Math.max(1, (model?.end ?? 0) - (model?.start ?? 0))
const viewportDuration = Math.min(
fullDuration,
@@ -127,16 +174,21 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
)
const domainDuration = viewport === null ? fullDuration : viewportDuration
const domainStart = viewport === null ? model?.start ?? 0 : viewportStart
const projectedDomainStyle = model === null
? undefined
: {
'--trajectory-domain-left':
`${-(domainStart - model.start) / domainDuration * 100}%`,
'--trajectory-domain-width': `${fullDuration / domainDuration * 100}%`,
} as CSSProperties
const committed = model === null || range === null
? null
: rangeFraction(range, domainStart, domainDuration)
const visibleRange = draft ?? committed
const activeRange = draft === null
? range
: {
start: domainStart + draft.start * domainDuration,
end: domainStart + draft.end * domainDuration,
}
const draftFraction = model === null || draft === null
? null
: rangeFraction(draft, domainStart, domainDuration)
const visibleRange = draftFraction ?? committed
const activeRange = draft ?? range
if (model === null) {
return (
@@ -151,9 +203,9 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
)
}
const minimumSelectionFraction = Math.min(
1,
fullDuration / domainDuration / model.spans.length,
const minimumSelectionDuration = Math.min(
domainDuration,
fullDuration / model.spans.length,
)
const fractionAt = (event: PointerEvent<HTMLDivElement>): number => {
@@ -161,51 +213,107 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
return clampFraction((event.clientX - rect.left) / Math.max(1, rect.width))
}
const commit = (fraction: FractionRange) => {
onRangeChange({
start: domainStart + fraction.start * domainDuration,
end: domainStart + fraction.end * domainDuration,
})
const recordIndexAt = (event: PointerEvent<HTMLDivElement>): number | null => {
const target = event.target instanceof HTMLElement ? event.target : null
const value = target?.closest<HTMLElement>('[data-timeline-record-index]')
?.dataset.timelineRecordIndex
if (value === undefined) return null
const index = Number(value)
return Number.isFinite(index) ? index : null
}
const commit = (nextRange: TrajectoryTimeRange) => {
onRangeChange(nextRange)
}
const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return
const rect = event.currentTarget.getBoundingClientRect()
const anchor = fractionAt(event)
setHover(anchor)
dragRef.current = { pointerId: event.pointerId, anchor, width: Math.max(1, rect.width) }
const anchorTime = domainStart + anchor * domainDuration
const recordIndex = recordIndexAt(event)
setHover({ fraction: anchor, recordIndex })
dragRef.current = {
pointerId: event.pointerId,
anchorTime,
anchorClientX: event.clientX,
recordIndex,
}
if (typeof event.currentTarget.setPointerCapture === 'function') {
event.currentTarget.setPointerCapture(event.pointerId)
}
setDraft({ start: anchor, end: anchor })
setDraft({ start: anchorTime, end: anchorTime })
}
const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current
const rect = event.currentTarget.getBoundingClientRect()
const fraction = fractionAt(event)
setHover(fraction)
setHover({ fraction, recordIndex: recordIndexAt(event) })
if (drag === null || drag.pointerId !== event.pointerId) return
setDraft(orderedRange(drag.anchor, fraction))
let nextDomainStart = domainStart
if (viewport !== null) {
const localX = event.clientX - rect.left
const edgeWidth = Math.min(
MAXIMUM_EDGE_PAN_PX,
Math.max(1, rect.width * EDGE_PAN_ZONE_FRACTION),
)
const direction = localX < edgeWidth
? -1
: localX > rect.width - edgeWidth ? 1 : 0
if (direction !== 0) {
const edgeDistance = direction < 0
? edgeWidth - localX
: localX - (rect.width - edgeWidth)
const strength = clampFraction(edgeDistance / edgeWidth)
const desiredStart = domainStart
+ direction * domainDuration * EDGE_PAN_STEP_FRACTION
* Math.max(0.2, strength)
nextDomainStart = Math.min(
Math.max(desiredStart, model.start),
model.end - domainDuration,
)
if (nextDomainStart !== domainStart) {
setAnimateViewport(false)
setViewport({
start: nextDomainStart,
end: nextDomainStart + domainDuration,
})
}
}
}
const pointTime = nextDomainStart + fraction * domainDuration
setDraft(orderedRange(drag.anchorTime, pointTime))
}
const onPointerEnd = (event: PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current
if (drag === null || drag.pointerId !== event.pointerId) return
const point = fractionAt(event)
const selected = orderedRange(drag.anchor, point)
setHover(point)
const pointFraction = fractionAt(event)
const pointTime = domainStart + pointFraction * domainDuration
const selected = orderedRange(drag.anchorTime, pointTime)
setHover({ fraction: pointFraction, recordIndex: recordIndexAt(event) })
dragRef.current = null
setDraft(null)
const click = (selected.end - selected.start) * drag.width < MINIMUM_DRAG_PX
const committedRange = selected.end - selected.start < minimumSelectionFraction
const click = Math.abs(event.clientX - drag.anchorClientX) < MINIMUM_DRAG_PX
const clickedSpan = click && drag.recordIndex !== null
? model.spans.find(span => span.index === drag.recordIndex)
: undefined
if (clickedSpan !== undefined) {
onRangeChange(null)
onRecordSelect?.(clickedSpan.index)
return
}
const committedRange = selected.end - selected.start < minimumSelectionDuration
? centeredRange(
click ? selected.start : (selected.start + selected.end) / 2,
minimumSelectionFraction,
minimumSelectionDuration,
model.start,
model.end,
)
: selected
commit(committedRange)
if (click) {
const timelinePoint = domainStart + selected.start * domainDuration
const timelinePoint = selected.start
const nearest = model.spans.reduce((candidate, span) => {
const candidateDistance = timelinePoint < candidate.start
? candidate.start - timelinePoint
@@ -233,6 +341,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
const onWheel = (event: WheelEvent<HTMLDivElement>) => {
event.preventDefault()
setAnimateViewport(false)
const rect = event.currentTarget.getBoundingClientRect()
const anchorFraction =
clampFraction((event.clientX - rect.left) / Math.max(1, rect.width))
@@ -278,16 +387,18 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
onWheel={onWheel}
onContextMenu={(event) => {
event.preventDefault()
setAnimateViewport(false)
onRangeChange(null)
setViewport(null)
}}
>
{hover !== null && draft === null && (
{hover !== null && hover.recordIndex === null && draft === null && (
<div
className={css.hoverLine}
data-timeline-hover-line
aria-hidden="true"
style={{
'--trajectory-hover-left': `${hover * 100}%`,
'--trajectory-hover-left': `${hover.fraction * 100}%`,
} as CSSProperties}
/>
)}
@@ -313,7 +424,12 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
/>
</>
)}
<div className={css.turnBoundaries} aria-hidden="true">
<div
className={css.turnBoundaries}
data-animate-viewport={animateViewport || undefined}
aria-hidden="true"
style={projectedDomainStyle}
>
{model.turnBoundaries
.slice(1)
.filter(boundary =>
@@ -326,24 +442,35 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
key={boundary.turn}
style={{
'--trajectory-turn-left':
`${(boundary.time - domainStart) / domainDuration * 100}%`,
`${(boundary.time - model.start) / fullDuration * 100}%`,
} as CSSProperties}
/>
))}
</div>
<div className={css.lanes} aria-hidden="true">
<div
className={css.lanes}
data-animate-viewport={animateViewport || undefined}
data-timeline-domain
aria-hidden="true"
style={projectedDomainStyle}
>
{model.spans
.filter(span => span.end >= domainStart && span.start <= domainStart + domainDuration)
.filter(span =>
span.index === selectedIndex
|| (span.end >= domainStart && span.start <= domainStart + domainDuration))
.map((span) => {
const left = (span.start - domainStart) / domainDuration
const width = (span.end - span.start) / domainDuration
const left = (span.start - model.start) / fullDuration
const width = (span.end - span.start) / fullDuration
const durationMs = durationByIndex.get(span.index)
return (
<span
className={css.span}
data-timeline-span={span.kind}
data-timeline-record-index={span.index}
data-error={span.isError || undefined}
data-equal-duration={mode === 'time' || undefined}
data-current={span.index === selectedIndex || undefined}
data-hovered={hover?.recordIndex === span.index || undefined}
data-search-match={searchMatchIndexes === null
? undefined
: searchMatchIndexes.has(span.index) ? 'true' : 'false'}

View File

@@ -147,6 +147,9 @@ export function TrajectoryView({
const [actualTime, setActualTime] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [selectedTimelineIndex, setSelectedTimelineIndex] = useState<number | null>(null)
const [timelineRecordSelection, setTimelineRecordSelection] = useState<{
readonly index: number
} | null>(null)
const ledgerRef = useRef<HTMLDivElement>(null)
const inspection = useHistory(snapshot => snapshot.inspection)
const nodes = inspection.eventNodes
@@ -478,7 +481,20 @@ export function TrajectoryView({
selectedIndex={selectedTimelineIndex}
searchMatchIndexes={searchMatchIndexes}
onRangeChange={(range) => {
setTimelineSelection(range === null ? null : { branchId: currentBranch.id, range })
setTimelineSelection(range === null ? null : {
branchId: currentBranch.id,
range,
})
}}
onRecordSelect={(index) => {
setTimelineSelection(null)
setTimelineRecordSelection({ index })
setSelectedTimelineIndex(index)
const row = ledgerRef.current
?.querySelector<HTMLElement>(`tr[data-record-index="${index}"]`)
if (row !== undefined && row !== null && typeof row.scrollIntoView === 'function') {
row.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}}
onRecordFocus={(index) => {
const row = ledgerRef.current
@@ -497,6 +513,7 @@ export function TrajectoryView({
searchMatchIndexes={searchMatchIndexes}
onSelectedIndexChange={setSelectedTimelineIndex}
onRecordSelect={handleRecordSelect}
recordSelection={timelineRecordSelection}
onClearSelection={() => { setTimelineSelection(null) }}
collapsedTurns={collapsedTurns}
onToggleTurn={toggleTurn}

View File

@@ -12,6 +12,7 @@ import type {
RequestView,
ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { extractMarkdownPlainText } from '@deepseek-ai/dsh-client-ui-primitives'
import type {
TrajectoryCellProps,
TrajectorySourceBlock,
@@ -66,6 +67,9 @@ interface TurnBucket {
groups: LaidGroup[]
}
const PREVIEW_SOURCE_CHARACTERS = 2_048
const PREVIEW_OUTPUT_CHARACTERS = 512
type InputNode = Extract<
ConversationSnapshot['nodes'][number],
{ kind: 'user' | 'steering' | 'context' }
@@ -126,6 +130,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
nodes, partial, runningCalls, requests = [], callSchemas, codeDispatches,
} = input
const resultByCall = indexResults(nodes)
const emittedCallIds = indexAssistantCallIds(nodes)
const callStartById = new Map<string, number>()
for (const result of resultByCall.values()) {
const startedAt = finiteTime(result.callTime)
@@ -353,7 +358,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
continue
}
if (node.kind === 'tool-result') {
if (!callEmittedInAssistant(nodes, node.callId)) {
if (!emittedCallIds.has(node.callId)) {
const toolName = node.call?.name
const laidList: LaidCell[] = [{
absTime: finiteTime(node.callTime ?? node.time),
@@ -764,12 +769,15 @@ function indexResults(nodes: ConversationSnapshot['nodes']): Map<string, ToolRes
return map
}
function callEmittedInAssistant(nodes: ConversationSnapshot['nodes'], callId: string): boolean {
function indexAssistantCallIds(nodes: ConversationSnapshot['nodes']): ReadonlySet<string> {
const ids = new Set<string>()
for (const node of nodes) {
if (node.kind !== 'assistant') continue
if (node.blocks.some(b => b.kind === 'tool-call' && b.callId === callId)) return true
for (const block of node.blocks) {
if (block.kind === 'tool-call') ids.add(block.callId)
}
}
return false
return ids
}
function collectCallIds(
@@ -849,7 +857,7 @@ function expandSubCalls(
}
function summarizeCall(name: string, argsRaw: string): string {
const args = argsRaw.replace(/\s+/g, ' ').trim()
const args = trajectoryPreviewText(argsRaw)
if (args === '') return name
return `${name} · ${args}`
}
@@ -907,5 +915,20 @@ function summarizeContent(content: readonly { type: string; text?: string }[]):
}
function summarizeText(text: string): string {
return text.replace(/\s+/g, ' ').trim()
return trajectoryPreviewText(text)
}
/**
* Build a bounded one-line ledger preview without parsing the complete Markdown document.
* Full source remains on the cell for the inspector.
* @param text - Untrusted message, reasoning, payload, or result text.
* @returns A compact preview capped independently from the retained source.
*/
export function trajectoryPreviewText(text: string): string {
const source = text.slice(0, PREVIEW_SOURCE_CHARACTERS)
const compact = extractMarkdownPlainText(source).replace(/\s+/g, ' ').trim()
const preview = compact.slice(0, PREVIEW_OUTPUT_CHARACTERS).trimEnd()
return source.length < text.length || preview.length < compact.length
? `${preview}`
: preview
}

View File

@@ -15,6 +15,7 @@ export interface TrajectoryTimeRange {
/** One ledger record projected into the active timeline domain. */
export interface TrajectoryTimelineSpan extends TrajectoryTimeRange {
index: number
isError: boolean
kind: TrajectoryCellKind
label: string
lane: number
@@ -94,6 +95,7 @@ export function deriveTrajectoryTimeline(
start: spans.length + offset,
end: spans.length + offset + 1,
index: cell.index,
isError: cell.isError === true,
kind: cell.kind,
label: cell.text,
lane: laneFor(cell.kind),
@@ -129,6 +131,7 @@ function deriveTimedTimeline(
: [{
...range,
index: cell.index,
isError: cell.isError === true,
kind: cell.kind,
label: cell.text,
lane: laneFor(cell.kind),

View File

@@ -176,6 +176,25 @@ describe('deriveTrajectoryLayout', () => {
})
})
it('bounds a long Markdown-like thinking preview while retaining its full detail', () => {
const thinking = `# Investigation\n\n**NAVIGATION_OK file_path** ${'- repeated detail '.repeat(1_000)}`
const nodes = [{
kind: 'assistant', seq: 1, time: 5_000, turn: 1, step: 0,
blocks: [{ kind: 'reasoning', text: thinking }],
}] as unknown as ConversationSnapshot['nodes']
const turns = deriveTrajectoryLayout({
codeDispatches: new Map(), nodes, partial: null, runningCalls: [],
})
const message = turns[0]?.groups.flatMap(group => group.cells)
.find(cell => cell.kind === 'message')
expect(message?.text.startsWith('Investigation NAVIGATION_OK file_path')).toBe(true)
expect(message?.text.endsWith('…')).toBe(true)
expect(message?.text.length).toBeLessThanOrEqual(513)
expect(message?.thinkingDetail).toBe(thinking)
})
it('advances the duration cursor over context nodes', () => {
const nodes = [
{ kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'hi' }], source: null },

View File

@@ -83,6 +83,31 @@ describe('TrajectoryTable', () => {
expect(screen.getByText('15 tok')).toBeTruthy()
})
it('keeps long thinking collapsed until the user asks to render it', () => {
const thinking = 'private chain '.repeat(1_000)
const turns: readonly TrajectoryTurnModel[] = [{
turn: 1,
groups: [{
title: 'Step 1',
cells: [{
index: 1,
kind: 'message',
text: 'private chain…',
thinkingDetail: thinking,
timeSeconds: 1,
}],
}],
}]
render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
fireEvent.click(screen.getByRole('row', { name: /ASSISTANT/ }))
const toggle = screen.getByRole('button', { name: 'Thinking ...' })
expect(screen.queryByText(thinking)).toBeNull()
fireEvent.click(toggle)
expect(toggle.parentElement?.textContent?.length).toBeGreaterThan(thinking.length)
})
it('keeps raw HTML tags in a Markdown-derived context preview', () => {
const html = [
'<background-task-complete id="trajectory-ui-watch">',
@@ -144,8 +169,53 @@ describe('TrajectoryTable', () => {
expect(screen.getByText('Pending')).toBeTruthy()
fireEvent.click(screen.getByRole('row', { name: /TOOL, bash \{"command":"false"\}/ }))
expect(screen.getByText('Failed')).toBeTruthy()
expect(screen.getByText('Failed').className).toContain('error')
fireEvent.click(screen.getByRole('tab', { name: 'Result' }))
expect(screen.getByText('ToolError: non_zero_exit')).toBeTruthy()
const errorResult = screen.getByText('ToolError: non_zero_exit')
expect(errorResult.closest('[class*="errorPayload"]')).toBeTruthy()
})
it('renders responsive role icons with a custom tooltip', () => {
const view = render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
const toolTag = view.container.querySelector<HTMLElement>('[data-role-kind="tool"]')
expect(toolTag).not.toBeNull()
expect(toolTag?.getAttribute('title')).toBeNull()
expect(toolTag?.querySelector('[data-role-icon="wrench"]')).toBeTruthy()
fireEvent.mouseEnter(toolTag as HTMLElement)
expect(screen.getByRole('tooltip').textContent).toBe('TOOL')
fireEvent.mouseLeave(toolTag as HTMLElement)
expect(screen.queryByRole('tooltip')).toBeNull()
})
it('uses information and compression glyphs for injected and compacted context', () => {
const turns: readonly TrajectoryTurnModel[] = [{
turn: 1,
groups: [{
title: 'Context',
cells: [
{ index: 1, kind: 'context', text: 'Workspace context', timeSeconds: 0 },
{ index: 2, kind: 'compacted', text: 'Compacted history', timeSeconds: 0 },
],
}],
}]
const view = render(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
expect(view.container.querySelector(
'[data-role-kind="context"] [data-role-icon="information"]',
)).toBeTruthy()
expect(view.container.querySelector(
'[data-role-kind="compacted"] [data-role-icon="compacted"]',
)).toBeTruthy()
})
it('keeps a compact turn label available for narrow layouts', () => {
render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
const turnLabel = screen.getByLabelText('Turn 1')
expect(turnLabel.textContent).toContain('Turn 1')
expect(turnLabel.textContent).toContain('#1')
})
it('renders a single-text JSON tool result as a JSON tree', () => {

View File

@@ -25,6 +25,7 @@ import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/cli
import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client'
import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory'
import type { TrajectoryTurnModel } from '../src/client/layout.ts'
import { TrajectoryTimeline } from '../src/client/TrajectoryTimeline.tsx'
import {
TrajectoryView, type TrajectoryViewInjected,
} from '../src/client/TrajectoryView.tsx'
@@ -309,6 +310,44 @@ describe('tab switching in ConversationRoot', () => {
.toBeNull()
})
it('clicking a timeline block clears the range, selects the record, and opens its inspector', async () => {
const b = await bench()
const view = mount(b.slots)
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72,
toJSON: () => ({}),
})
const toolSpan = view.container.querySelector<HTMLElement>(
'[data-timeline-span="tool"]',
)
expect(toolSpan).not.toBeNull()
const recordIndex = toolSpan?.dataset.timelineRecordIndex
expect(recordIndex).toBeTruthy()
fireEvent.pointerMove(toolSpan as HTMLElement, { clientX: 50, pointerId: 1 })
expect(view.container.querySelector('[data-timeline-hover-line]')).toBeNull()
expect(toolSpan?.getAttribute('data-hovered')).toBe('true')
fireEvent.pointerDown(plot, { button: 0, clientX: 5, pointerId: 1 })
fireEvent.pointerMove(plot, { clientX: 95, pointerId: 1 })
fireEvent.pointerUp(plot, { clientX: 95, pointerId: 1 })
expect(view.container.querySelector('tr[data-timeline-focus]')).toBeTruthy()
fireEvent.pointerDown(toolSpan as HTMLElement, {
button: 0, clientX: 50, pointerId: 2,
})
fireEvent.pointerUp(toolSpan as HTMLElement, { clientX: 50, pointerId: 2 })
const selectedRow = view.container.querySelector<HTMLElement>(
`tr[data-record-index="${recordIndex}"]`,
)
expect(selectedRow?.getAttribute('aria-selected')).toBe('true')
expect(view.container.querySelector('tr[data-timeline-focus]')).toBeNull()
expect(screen.getByRole('complementary', { name: 'Event details' })).toBeTruthy()
})
it('empty window keeps the toolbar and reports no timing data', async () => {
const b = await bench(historySnapshot([]))
mount(b.slots)
@@ -332,6 +371,97 @@ describe('timeline projection', () => {
],
}],
}] satisfies readonly TrajectoryTurnModel[]
const longTurns = [{
turn: 1,
groups: [{
title: 'Step 1',
cells: Array.from({ length: 10 }, (_, index) => ({
index,
kind: 'message' as const,
text: `record ${index}`,
timeSeconds: 1,
})),
}],
}] satisfies readonly TrajectoryTurnModel[]
it('pans the zoomed viewport only far enough to reveal a newly selected record', async () => {
const onRangeChange = vi.fn()
const view = render(
<TrajectoryTimeline
turns={longTurns}
mode="sequence"
range={null}
onRangeChange={onRangeChange}
/>,
)
const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72,
toJSON: () => ({}),
})
fireEvent.wheel(plot, { clientX: 50, deltaY: -1_000 })
view.rerender(
<TrajectoryTimeline
turns={longTurns}
mode="sequence"
range={null}
selectedIndex={1}
onRangeChange={onRangeChange}
/>,
)
await vi.waitFor(() => {
const domain = view.container.querySelector<HTMLElement>(
'[data-timeline-domain]',
)
expect(domain?.style.getPropertyValue('--trajectory-domain-left')).toBe('-25%')
})
view.rerender(
<TrajectoryTimeline
turns={longTurns}
mode="sequence"
range={null}
selectedIndex={8}
onRangeChange={onRangeChange}
/>,
)
await vi.waitFor(() => {
const domain = view.container.querySelector<HTMLElement>(
'[data-timeline-domain]',
)
expect(domain?.style.getPropertyValue('--trajectory-domain-left')).toBe('-125%')
})
})
it('auto-pans a zoomed viewport while a range drag pushes against an edge', () => {
const onRangeChange = vi.fn()
render(
<TrajectoryTimeline
turns={longTurns}
mode="sequence"
range={null}
onRangeChange={onRangeChange}
/>,
)
const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72,
toJSON: () => ({}),
})
fireEvent.wheel(plot, { clientX: 50, deltaY: -1_000 })
fireEvent.pointerDown(plot, { button: 0, clientX: 50, pointerId: 1 })
for (let index = 0; index < 24; index++) {
fireEvent.pointerMove(plot, { clientX: 99, pointerId: 1 })
}
fireEvent.pointerUp(plot, { clientX: 99, pointerId: 1 })
const selectedRange = onRangeChange.mock.calls.at(-1)?.[0] as
| { start: number; end: number }
| undefined
expect(selectedRange).toBeDefined()
expect((selectedRange?.end ?? 0) - (selectedRange?.start ?? 0)).toBeGreaterThan(4)
})
it('uses equal-width operation slots and stable semantic lanes', () => {
expect(deriveTrajectoryTimeline(turns)).toEqual({
@@ -339,15 +469,50 @@ describe('timeline projection', () => {
end: 3,
spans: [
{
index: 1, kind: 'message', label: 'assistant', lane: 1, start: 0, end: 1,
index: 1, isError: false, kind: 'message', label: 'assistant',
lane: 1, start: 0, end: 1,
},
{
index: 2, isError: false, kind: 'tool', label: 'bash',
lane: 2, start: 1, end: 2,
},
{
index: 3, isError: false, kind: 'user', label: 'unknown',
lane: 0, start: 2, end: 3,
},
{ index: 2, kind: 'tool', label: 'bash', lane: 2, start: 1, end: 2 },
{ index: 3, kind: 'user', label: 'unknown', lane: 0, start: 2, end: 3 },
],
turnBoundaries: [{ turn: 1, time: 0 }],
})
})
it('marks error records directly on timeline spans', () => {
const errorTurns = [{
turn: 1,
groups: [{
title: 'Step 1',
cells: [{
index: 1,
kind: 'tool' as const,
text: 'failed tool',
timeSeconds: 0.1,
isError: true,
}],
}],
}] satisfies readonly TrajectoryTurnModel[]
const view = render(
<TrajectoryTimeline
turns={errorTurns}
mode="sequence"
range={null}
onRangeChange={() => {}}
/>,
)
expect(view.container.querySelector(
'[data-timeline-span="tool"][data-error="true"]',
)).toBeTruthy()
})
it('ignores durations and idle gaps while retaining turn boundaries', () => {
const separatedTurns = [
{